TenantAtlas/apps/platform/app/Services/TenantConfiguration/CoverageV2ReadinessReadModel.php
Ahmed Darrazi 19037e1dd8
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 1m15s
feat: complete spec 421 Entra comparable/renderable pack
2026-06-27 23:42:58 +02:00

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;
}
}