729 lines
28 KiB
PHP
729 lines
28 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\TenantConfiguration;
|
|
|
|
use App\Models\ManagedEnvironment;
|
|
use App\Models\ProviderConnection;
|
|
use App\Models\TenantConfigurationResource;
|
|
use App\Models\TenantConfigurationResourceEvidence;
|
|
use App\Models\TenantConfigurationResourceType;
|
|
use App\Models\TenantConfigurationSupportedScope;
|
|
use App\Models\User;
|
|
use App\Support\OperationRunLinks;
|
|
use App\Support\TenantConfiguration\CaptureOutcome;
|
|
use App\Support\TenantConfiguration\ClaimState;
|
|
use App\Support\TenantConfiguration\CoverageLevel;
|
|
use App\Support\TenantConfiguration\EvidenceState;
|
|
use App\Support\TenantConfiguration\IdentityState;
|
|
use App\Support\TenantConfiguration\SourceClass;
|
|
use App\Support\TenantConfiguration\SupportState;
|
|
use App\Support\TenantConfiguration\Workload;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Gate;
|
|
use UnexpectedValueException;
|
|
|
|
final class CoverageV2ReadinessReadModel
|
|
{
|
|
public function __construct(
|
|
private readonly EntraRenderableSummaryBuilder $entraSummaryBuilder,
|
|
private readonly EntraCoverageComparator $entraCoverageComparator,
|
|
) {}
|
|
|
|
/**
|
|
* @return Builder<TenantConfigurationResourceType>
|
|
*/
|
|
public function resourceTypeQuery(): Builder
|
|
{
|
|
return TenantConfigurationResourceType::query()
|
|
->active()
|
|
->orderBy('workload')
|
|
->orderBy('source_class')
|
|
->orderBy('canonical_type');
|
|
}
|
|
|
|
/**
|
|
* @return Builder<TenantConfigurationResource>
|
|
*/
|
|
public function resourceInstanceQuery(ManagedEnvironment $environment): Builder
|
|
{
|
|
return TenantConfigurationResource::query()
|
|
->where('workspace_id', (int) $environment->workspace_id)
|
|
->where('managed_environment_id', (int) $environment->getKey())
|
|
->with([
|
|
'resourceType:id,canonical_type,display_name,source_class,workload,support_state,default_coverage_level',
|
|
'providerConnection:id,workspace_id,managed_environment_id,display_name,provider',
|
|
'latestEvidence:id,resource_id,operation_run_id,payload_hash,evidence_state,coverage_level,capture_outcome,source_contract_key,source_endpoint,source_version,source_schema_hash,captured_at',
|
|
'latestEvidence.operationRun:id,workspace_id,managed_environment_id,type,status,outcome,created_at,completed_at',
|
|
])
|
|
->latest('latest_captured_at')
|
|
->latest('id');
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function summary(ManagedEnvironment $environment): array
|
|
{
|
|
$resources = $this->resourceInstanceQuery($environment)->get([
|
|
'id',
|
|
'workspace_id',
|
|
'managed_environment_id',
|
|
'provider_connection_id',
|
|
'resource_type_id',
|
|
'source_class',
|
|
'latest_evidence_state',
|
|
'latest_identity_state',
|
|
'latest_claim_state',
|
|
]);
|
|
|
|
$resourceTypes = TenantConfigurationResourceType::query()
|
|
->active()
|
|
->get(['id', 'source_class']);
|
|
|
|
$blockers = $this->activationBlockers($environment);
|
|
$readinessState = $this->readinessState($resources->count(), $blockers);
|
|
|
|
return [
|
|
'readiness_state' => $readinessState,
|
|
'readiness_reason' => $this->readinessReason($resources->count(), $blockers),
|
|
'readiness_next_step' => $this->readinessNextStep($resources->count(), $blockers),
|
|
'resource_types_total' => $resourceTypes->count(),
|
|
'resources_total' => $resources->count(),
|
|
'content_backed_count' => $resources
|
|
->where('latest_evidence_state', EvidenceState::ContentBacked)
|
|
->count(),
|
|
'activation_blocker_count' => $blockers->sum('count'),
|
|
'identity_conflict_count' => $resources
|
|
->where('latest_identity_state', IdentityState::IdentityConflict)
|
|
->count(),
|
|
'claim_allowed_count' => $resources
|
|
->where('latest_claim_state', ClaimState::ClaimAllowed)
|
|
->count(),
|
|
'claim_limited_count' => $resources
|
|
->where('latest_claim_state', ClaimState::ClaimLimited)
|
|
->count(),
|
|
'claim_blocked_count' => $resources
|
|
->where('latest_claim_state', ClaimState::ClaimBlocked)
|
|
->count(),
|
|
'beta_experimental_count' => $resourceTypes
|
|
->where('source_class', SourceClass::GraphBetaExperimental)
|
|
->count(),
|
|
'graph_fallback_count' => $resourceTypes
|
|
->where('source_class', SourceClass::GraphV1Fallback)
|
|
->count(),
|
|
'top_blockers' => $blockers->take(6)->values()->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, array{
|
|
* blocker: string,
|
|
* label: string,
|
|
* count: int,
|
|
* priority: int,
|
|
* example_resource: ?string,
|
|
* example_type: ?string
|
|
* }>
|
|
*/
|
|
public function activationBlockers(ManagedEnvironment $environment): Collection
|
|
{
|
|
$groups = [];
|
|
|
|
$this->resourceInstanceQuery($environment)
|
|
->get([
|
|
'id',
|
|
'workspace_id',
|
|
'managed_environment_id',
|
|
'provider_connection_id',
|
|
'resource_type_id',
|
|
'canonical_type',
|
|
'source_display_name',
|
|
'source_class',
|
|
'latest_evidence_state',
|
|
'latest_identity_state',
|
|
'latest_claim_state',
|
|
])
|
|
->each(function (TenantConfigurationResource $resource) use (&$groups): void {
|
|
foreach ($this->blockersForResource($resource) as $blocker) {
|
|
$groups[$blocker] ??= [
|
|
'blocker' => $blocker,
|
|
'label' => self::blockerLabel($blocker),
|
|
'count' => 0,
|
|
'priority' => self::blockerPriority($blocker),
|
|
'example_resource' => null,
|
|
'example_type' => null,
|
|
];
|
|
|
|
$groups[$blocker]['count']++;
|
|
$groups[$blocker]['example_resource'] ??= (string) ($resource->source_display_name ?: $resource->canonical_resource_key);
|
|
$groups[$blocker]['example_type'] ??= (string) $resource->canonical_type;
|
|
}
|
|
});
|
|
|
|
return collect($groups)
|
|
->sort(function (array $left, array $right): int {
|
|
return ($left['priority'] <=> $right['priority'])
|
|
?: ($right['count'] <=> $left['count'])
|
|
?: strnatcasecmp((string) $left['blocker'], (string) $right['blocker']);
|
|
})
|
|
->values();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public function providerConnectionOptions(ManagedEnvironment $environment): array
|
|
{
|
|
return ProviderConnection::query()
|
|
->where('workspace_id', (int) $environment->workspace_id)
|
|
->where('managed_environment_id', (int) $environment->getKey())
|
|
->orderBy('display_name')
|
|
->pluck('display_name', 'id')
|
|
->mapWithKeys(fn (string $label, int|string $id): array => [(string) $id => $label])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public function supportedScopeOptions(): array
|
|
{
|
|
return app(SupportedScopeResolver::class)
|
|
->activeScopes()
|
|
->mapWithKeys(fn (TenantConfigurationSupportedScope $scope): array => [
|
|
(string) $scope->scope_key => (string) $scope->display_name,
|
|
])
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
public function includedCanonicalTypesForScope(string $scopeKey): array
|
|
{
|
|
$scope = app(SupportedScopeResolver::class)->findActive($scopeKey);
|
|
|
|
if (! $scope instanceof TenantConfigurationSupportedScope) {
|
|
return [];
|
|
}
|
|
|
|
try {
|
|
$resolved = app(SupportedScopeResolver::class)->resolveDefinition(
|
|
$scope,
|
|
TenantConfigurationResourceType::query()
|
|
->active()
|
|
->get(['canonical_type', 'source_class']),
|
|
);
|
|
} catch (UnexpectedValueException) {
|
|
return [];
|
|
}
|
|
|
|
return $resolved['included_resource_types'];
|
|
}
|
|
|
|
public function scopeInclusionLabel(TenantConfigurationResourceType $resourceType, ?string $scopeKey = null): string
|
|
{
|
|
$scopeKey = $scopeKey ?: $this->defaultScopeKey();
|
|
|
|
if (! is_string($scopeKey) || $scopeKey === '') {
|
|
return 'No active scope';
|
|
}
|
|
|
|
return in_array((string) $resourceType->canonical_type, $this->includedCanonicalTypesForScope($scopeKey), true)
|
|
? 'Included'
|
|
: 'Not included';
|
|
}
|
|
|
|
public function defaultScopeKey(): ?string
|
|
{
|
|
$scope = app(SupportedScopeResolver::class)
|
|
->activeScopes()
|
|
->first();
|
|
|
|
return $scope instanceof TenantConfigurationSupportedScope
|
|
? (string) $scope->scope_key
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function inspectDetails(TenantConfigurationResource $resource, ManagedEnvironment $environment, ?User $user): array
|
|
{
|
|
$resource->load([
|
|
'resourceType:id,canonical_type,display_name,source_class,workload,support_state,default_coverage_level',
|
|
'providerConnection:id,workspace_id,managed_environment_id,display_name,provider',
|
|
'latestEvidence:id,resource_id,workspace_id,managed_environment_id,provider_connection_id,resource_type_id,operation_run_id,payload_hash,evidence_state,coverage_level,capture_outcome,source_contract_key,source_endpoint,source_version,source_schema_hash,normalized_payload,captured_at',
|
|
'latestEvidence.operationRun:id,workspace_id,managed_environment_id,type,status,outcome,created_at,completed_at',
|
|
]);
|
|
|
|
if (
|
|
(int) $resource->workspace_id !== (int) $environment->workspace_id
|
|
|| (int) $resource->managed_environment_id !== (int) $environment->getKey()
|
|
) {
|
|
return [];
|
|
}
|
|
|
|
$run = $resource->latestEvidence?->operationRun;
|
|
$runUrl = $run !== null && $user instanceof User && Gate::forUser($user)->allows('view', $run)
|
|
? OperationRunLinks::view($run, $environment)
|
|
: null;
|
|
|
|
return [
|
|
'resource' => (string) ($resource->source_display_name ?: $resource->canonical_resource_key),
|
|
'canonical_resource_key' => (string) $resource->canonical_resource_key,
|
|
'canonical_type' => (string) $resource->canonical_type,
|
|
'resource_type' => (string) ($resource->resourceType?->display_name ?: $resource->canonical_type),
|
|
'provider_connection' => (string) ($resource->providerConnection?->display_name ?: 'Unassigned provider connection'),
|
|
'coverage_level' => $resource->latestEvidence?->coverage_level?->value,
|
|
'evidence_state' => $resource->latest_evidence_state?->value,
|
|
'identity_state' => $resource->latest_identity_state?->value,
|
|
'claim_state' => $resource->latest_claim_state?->value,
|
|
'source_class' => $resource->source_class?->value,
|
|
'evidence_hash' => $resource->latestEvidence?->payload_hash ?: $resource->latest_payload_hash,
|
|
'last_captured' => $resource->latest_captured_at?->toDayDateTimeString(),
|
|
'source_contract_key' => $resource->latestEvidence?->source_contract_key,
|
|
'source_endpoint' => $resource->latestEvidence?->source_endpoint,
|
|
'source_version' => $resource->latestEvidence?->source_version,
|
|
'source_schema_hash' => $resource->latestEvidence?->source_schema_hash,
|
|
'capture_outcome' => $resource->latestEvidence?->capture_outcome?->value,
|
|
'identity_reason_code' => $this->safeIdentityReasonCode($resource),
|
|
'operation_run_url' => $runUrl,
|
|
'operation_run_label' => $run !== null ? 'Operation #'.$run->getKey() : null,
|
|
'typed_render_summary' => $this->typedRenderSummary($resource),
|
|
'blockers' => collect($this->blockersForResource($resource))
|
|
->map(fn (string $blocker): string => self::blockerLabel($blocker))
|
|
->values()
|
|
->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function typedRenderSummary(TenantConfigurationResource $resource): ?array
|
|
{
|
|
$evidence = $resource->latestEvidence;
|
|
$canonicalType = (string) $resource->canonical_type;
|
|
|
|
if (
|
|
! $evidence instanceof TenantConfigurationResourceEvidence
|
|
|| ! $this->isRenderableEvidenceForResource($resource, $evidence)
|
|
|| ! is_array($evidence->normalized_payload)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
$summary = $this->entraSummaryBuilder->build($canonicalType, $evidence->normalized_payload, [
|
|
'claim_state' => $resource->latest_claim_state,
|
|
'identity_state' => $resource->latest_identity_state,
|
|
'last_captured' => $resource->latest_captured_at?->toDayDateTimeString(),
|
|
]);
|
|
|
|
if ($summary === null) {
|
|
return null;
|
|
}
|
|
|
|
$summary['compare_summary'] = $this->compareSummary($resource, $evidence, $canonicalType);
|
|
|
|
return $summary;
|
|
}
|
|
|
|
private function isRenderableEvidenceForResource(
|
|
TenantConfigurationResource $resource,
|
|
TenantConfigurationResourceEvidence $evidence,
|
|
): bool {
|
|
return (int) $evidence->resource_id === (int) $resource->getKey()
|
|
&& (int) $evidence->workspace_id === (int) $resource->workspace_id
|
|
&& (int) $evidence->managed_environment_id === (int) $resource->managed_environment_id
|
|
&& (int) $evidence->provider_connection_id === (int) $resource->provider_connection_id
|
|
&& (int) $evidence->resource_type_id === (int) $resource->resource_type_id
|
|
&& $evidence->evidence_state === EvidenceState::ContentBacked
|
|
&& $evidence->capture_outcome === CaptureOutcome::Captured
|
|
&& $evidence->coverage_level === CoverageLevel::Renderable;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function compareSummary(
|
|
TenantConfigurationResource $resource,
|
|
TenantConfigurationResourceEvidence $latestEvidence,
|
|
string $canonicalType,
|
|
): array {
|
|
$previousEvidence = $this->previousComparableEvidence($resource, $latestEvidence);
|
|
|
|
if (
|
|
! $previousEvidence instanceof TenantConfigurationResourceEvidence
|
|
|| ! is_array($previousEvidence->normalized_payload)
|
|
) {
|
|
return [
|
|
'status' => 'No previous comparable evidence',
|
|
'classification' => 'baseline',
|
|
'changed' => false,
|
|
'previous_captured' => null,
|
|
'changes' => [],
|
|
'diagnostic_count' => 0,
|
|
];
|
|
}
|
|
|
|
$result = $this->entraCoverageComparator->compare(
|
|
$canonicalType,
|
|
$previousEvidence->normalized_payload,
|
|
$latestEvidence->normalized_payload,
|
|
);
|
|
|
|
$changes = collect(is_array($result['changes'] ?? null) ? $result['changes'] : []);
|
|
$materialChanges = $changes
|
|
->filter(fn (mixed $change): bool => is_array($change) && in_array($change['classification'] ?? null, [
|
|
'added',
|
|
'removed',
|
|
'changed',
|
|
], true));
|
|
|
|
$changed = (bool) ($result['changed'] ?? false);
|
|
|
|
return [
|
|
'status' => $changed ? 'Material changes detected' : 'No material changes',
|
|
'classification' => is_scalar($result['classification'] ?? null)
|
|
? (string) $result['classification']
|
|
: 'unchanged',
|
|
'changed' => $changed,
|
|
'previous_captured' => $previousEvidence->captured_at?->toDayDateTimeString(),
|
|
'changes' => $materialChanges
|
|
->take(6)
|
|
->map(fn (array $change): array => [
|
|
'label' => self::compareFieldLabel($change['field'] ?? null),
|
|
'classification' => self::compareToken($change['classification'] ?? null, 'changed'),
|
|
'importance' => self::compareToken($change['importance'] ?? null, 'informational'),
|
|
])
|
|
->values()
|
|
->all(),
|
|
'diagnostic_count' => $changes->count() - $materialChanges->count(),
|
|
];
|
|
}
|
|
|
|
private function previousComparableEvidence(
|
|
TenantConfigurationResource $resource,
|
|
TenantConfigurationResourceEvidence $latestEvidence,
|
|
): ?TenantConfigurationResourceEvidence {
|
|
return TenantConfigurationResourceEvidence::query()
|
|
->where('resource_id', (int) $resource->getKey())
|
|
->where('workspace_id', (int) $resource->workspace_id)
|
|
->where('managed_environment_id', (int) $resource->managed_environment_id)
|
|
->where('provider_connection_id', (int) $resource->provider_connection_id)
|
|
->where('resource_type_id', (int) $resource->resource_type_id)
|
|
->where('id', '<>', (int) $latestEvidence->getKey())
|
|
->where('evidence_state', EvidenceState::ContentBacked->value)
|
|
->where('capture_outcome', CaptureOutcome::Captured->value)
|
|
->whereIn('coverage_level', [
|
|
CoverageLevel::Comparable->value,
|
|
CoverageLevel::Renderable->value,
|
|
])
|
|
->whereNotNull('captured_at')
|
|
->latest('captured_at')
|
|
->latest('id')
|
|
->first([
|
|
'id',
|
|
'resource_id',
|
|
'workspace_id',
|
|
'managed_environment_id',
|
|
'provider_connection_id',
|
|
'resource_type_id',
|
|
'normalized_payload',
|
|
'captured_at',
|
|
]);
|
|
}
|
|
|
|
private static function compareFieldLabel(mixed $field): string
|
|
{
|
|
if (! is_scalar($field) || trim((string) $field) === '') {
|
|
return 'Changed field';
|
|
}
|
|
|
|
return str((string) $field)
|
|
->replace(['.', '_'], ' ')
|
|
->headline()
|
|
->toString();
|
|
}
|
|
|
|
private static function compareToken(mixed $value, string $fallback): string
|
|
{
|
|
if (! is_scalar($value) || trim((string) $value) === '') {
|
|
return $fallback;
|
|
}
|
|
|
|
$token = str((string) $value)
|
|
->replaceMatches('/[^a-zA-Z0-9_]/', '')
|
|
->snake()
|
|
->toString();
|
|
|
|
return $token !== '' ? $token : $fallback;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function resourceTypeInspectDetails(TenantConfigurationResourceType $resourceType, ?string $scopeKey = null): array
|
|
{
|
|
$scopeKey = $scopeKey ?: $this->defaultScopeKey();
|
|
$scope = is_string($scopeKey) && $scopeKey !== ''
|
|
? app(SupportedScopeResolver::class)->findActive($scopeKey)
|
|
: null;
|
|
|
|
return [
|
|
'name' => (string) $resourceType->display_name,
|
|
'canonical_type' => (string) $resourceType->canonical_type,
|
|
'workload' => self::humanize(self::safeStateValue($resourceType->workload)),
|
|
'resource_class' => self::humanize(self::safeStateValue($resourceType->resource_class)),
|
|
'source_class' => self::safeStateValue($resourceType->source_class),
|
|
'support_state' => self::safeStateValue($resourceType->support_state),
|
|
'default_coverage_level' => self::safeStateValue($resourceType->default_coverage_level),
|
|
'default_evidence_state' => self::safeStateValue($resourceType->default_evidence_state),
|
|
'default_identity_state' => self::safeStateValue($resourceType->default_identity_state),
|
|
'default_claim_state' => self::safeStateValue($resourceType->default_claim_state),
|
|
'restore_tier' => self::humanize(self::safeStateValue($resourceType->restore_tier)),
|
|
'supported_scope' => $this->scopeInclusionLabel($resourceType, $scopeKey),
|
|
'scope' => $scope instanceof TenantConfigurationSupportedScope ? (string) $scope->display_name : null,
|
|
'scope_key' => $scopeKey,
|
|
'allows_beta_claims' => (bool) $resourceType->allows_beta_claims,
|
|
'allows_graph_fallback_claims' => (bool) $resourceType->allows_graph_fallback_claims,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public static function coverageLevelOptions(): array
|
|
{
|
|
return self::enumOptions(CoverageLevel::cases());
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public static function evidenceStateOptions(): array
|
|
{
|
|
return self::enumOptions(EvidenceState::cases());
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public static function identityStateOptions(): array
|
|
{
|
|
return self::enumOptions(IdentityState::cases());
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public static function claimStateOptions(): array
|
|
{
|
|
return self::enumOptions(ClaimState::cases());
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public static function sourceClassOptions(): array
|
|
{
|
|
return self::enumOptions(SourceClass::cases());
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public static function supportStateOptions(): array
|
|
{
|
|
return self::enumOptions(SupportState::cases());
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
public static function workloadOptions(): array
|
|
{
|
|
return self::enumOptions(Workload::cases());
|
|
}
|
|
|
|
/**
|
|
* @param array<int, \BackedEnum> $cases
|
|
* @return array<string, string>
|
|
*/
|
|
private static function enumOptions(array $cases): array
|
|
{
|
|
return collect($cases)
|
|
->mapWithKeys(fn (\BackedEnum $case): array => [
|
|
(string) $case->value => self::humanize((string) $case->value),
|
|
])
|
|
->all();
|
|
}
|
|
|
|
private static function humanize(string $value): string
|
|
{
|
|
return str($value)->replace('_', ' ')->headline()->toString();
|
|
}
|
|
|
|
private static function safeStateValue(mixed $state): string
|
|
{
|
|
return $state instanceof \BackedEnum ? (string) $state->value : (string) $state;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function blockersForResource(TenantConfigurationResource $resource): array
|
|
{
|
|
$blockers = [];
|
|
|
|
$identityState = $resource->latest_identity_state instanceof IdentityState
|
|
? $resource->latest_identity_state
|
|
: IdentityState::tryFrom((string) $resource->latest_identity_state);
|
|
|
|
$evidenceState = $resource->latest_evidence_state instanceof EvidenceState
|
|
? $resource->latest_evidence_state
|
|
: EvidenceState::tryFrom((string) $resource->latest_evidence_state);
|
|
|
|
$claimState = $resource->latest_claim_state instanceof ClaimState
|
|
? $resource->latest_claim_state
|
|
: ClaimState::tryFrom((string) $resource->latest_claim_state);
|
|
|
|
$sourceClass = $resource->source_class instanceof SourceClass
|
|
? $resource->source_class
|
|
: SourceClass::tryFrom((string) $resource->source_class);
|
|
|
|
if (in_array($identityState, [
|
|
IdentityState::IdentityConflict,
|
|
IdentityState::MissingExternalId,
|
|
IdentityState::UnsupportedIdentity,
|
|
], true)) {
|
|
$blockers[] = $identityState->value;
|
|
}
|
|
|
|
if ($claimState === ClaimState::ClaimBlocked) {
|
|
$blockers[] = ClaimState::ClaimBlocked->value;
|
|
}
|
|
|
|
if (in_array($evidenceState, [
|
|
EvidenceState::NotCaptured,
|
|
EvidenceState::PermissionBlocked,
|
|
EvidenceState::SourceUnavailable,
|
|
EvidenceState::SchemaUnknown,
|
|
EvidenceState::CaptureFailed,
|
|
], true)) {
|
|
$blockers[] = $evidenceState->value;
|
|
}
|
|
|
|
if ($sourceClass === SourceClass::GraphBetaExperimental) {
|
|
$blockers[] = 'beta_experimental';
|
|
}
|
|
|
|
return array_values(array_unique($blockers));
|
|
}
|
|
|
|
private function readinessState(int $resourceCount, Collection $blockers): string
|
|
{
|
|
if ($resourceCount === 0) {
|
|
return 'unknown';
|
|
}
|
|
|
|
$hasHardBlocker = $blockers->contains(fn (array $blocker): bool => in_array($blocker['blocker'], [
|
|
IdentityState::IdentityConflict->value,
|
|
IdentityState::MissingExternalId->value,
|
|
IdentityState::UnsupportedIdentity->value,
|
|
ClaimState::ClaimBlocked->value,
|
|
], true));
|
|
|
|
if ($hasHardBlocker) {
|
|
return 'blocked';
|
|
}
|
|
|
|
return $blockers->isNotEmpty() ? 'needs_attention' : 'ready';
|
|
}
|
|
|
|
private function readinessReason(int $resourceCount, Collection $blockers): string
|
|
{
|
|
if ($resourceCount === 0) {
|
|
return 'No Coverage v2 resource rows exist for this managed environment.';
|
|
}
|
|
|
|
$topBlocker = $blockers->first();
|
|
|
|
if (is_array($topBlocker)) {
|
|
return sprintf(
|
|
'%s is the highest-priority activation blocker.',
|
|
(string) ($topBlocker['label'] ?? 'Coverage v2 readiness'),
|
|
);
|
|
}
|
|
|
|
return 'Captured Coverage v2 resources have no activation blockers.';
|
|
}
|
|
|
|
private function readinessNextStep(int $resourceCount, Collection $blockers): string
|
|
{
|
|
if ($resourceCount === 0) {
|
|
return 'Review capture prerequisites before using Coverage v2 as activation proof.';
|
|
}
|
|
|
|
$topBlocker = $blockers->first();
|
|
|
|
if (is_array($topBlocker)) {
|
|
$example = $topBlocker['example_resource'] ?? $topBlocker['example_type'] ?? null;
|
|
|
|
if (is_string($example) && $example !== '') {
|
|
return sprintf('Inspect %s and resolve the blocker before cutover planning.', $example);
|
|
}
|
|
|
|
return 'Inspect the top blocker group before cutover planning.';
|
|
}
|
|
|
|
return 'Review the read-only details before cutover planning.';
|
|
}
|
|
|
|
private static function blockerLabel(string $blocker): string
|
|
{
|
|
return match ($blocker) {
|
|
'identity_conflict' => 'Identity conflict',
|
|
'missing_external_id' => 'Missing external ID',
|
|
'unsupported_identity' => 'Unsupported identity',
|
|
'claim_blocked' => 'Claim blocked',
|
|
'permission_blocked' => 'Permission blocked',
|
|
'source_unavailable' => 'Source unavailable',
|
|
'schema_unknown' => 'Schema unknown',
|
|
'capture_failed' => 'Capture failed',
|
|
'not_captured' => 'Not captured',
|
|
'beta_experimental' => 'Beta experimental',
|
|
default => self::humanize($blocker),
|
|
};
|
|
}
|
|
|
|
private static function blockerPriority(string $blocker): int
|
|
{
|
|
return match ($blocker) {
|
|
'identity_conflict' => 10,
|
|
'missing_external_id' => 11,
|
|
'unsupported_identity' => 12,
|
|
'claim_blocked' => 20,
|
|
'permission_blocked' => 30,
|
|
'source_unavailable' => 31,
|
|
'schema_unknown' => 32,
|
|
'capture_failed' => 33,
|
|
'not_captured' => 34,
|
|
'beta_experimental' => 90,
|
|
default => 100,
|
|
};
|
|
}
|
|
|
|
private function safeIdentityReasonCode(TenantConfigurationResource $resource): ?string
|
|
{
|
|
$diagnostics = is_array($resource->identity_diagnostics) ? $resource->identity_diagnostics : [];
|
|
$reasonCode = $diagnostics['reason_code'] ?? null;
|
|
|
|
return is_string($reasonCode) && $reasonCode !== '' ? $reasonCode : null;
|
|
}
|
|
}
|