Automated PR provided by Codex via Gitea API. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #485
558 lines
21 KiB
PHP
558 lines
21 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\TenantConfigurationResourceType;
|
|
use App\Models\TenantConfigurationSupportedScope;
|
|
use App\Models\User;
|
|
use App\Support\OperationRunLinks;
|
|
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
|
|
{
|
|
/**
|
|
* @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->loadMissing([
|
|
'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',
|
|
]);
|
|
|
|
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,
|
|
'blockers' => collect($this->blockersForResource($resource))
|
|
->map(fn (string $blocker): string => self::blockerLabel($blocker))
|
|
->values()
|
|
->all(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
}
|
|
}
|