Renames ManagedEnvironment context badge to Environment context as requested. Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #431
334 lines
15 KiB
PHP
334 lines
15 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Operations\Reconciliation;
|
|
|
|
use App\Models\EnvironmentReview;
|
|
use App\Models\OperationRun;
|
|
use App\Support\EnvironmentReviewStatus;
|
|
use App\Support\Operations\LifecycleReconciliationReason;
|
|
use Illuminate\Support\Collection;
|
|
|
|
final class EnvironmentReviewComposeDecision
|
|
{
|
|
public const string ADAPTER = 'environment_review_compose';
|
|
|
|
public function evaluate(OperationRun $run): ReconciliationResult
|
|
{
|
|
if ((string) $run->type !== 'environment.review.compose') {
|
|
return ReconciliationResult::unsupported(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'This adapter only supports environment review composition runs.',
|
|
evidence: [
|
|
'adapter' => self::ADAPTER,
|
|
'type' => (string) $run->type,
|
|
],
|
|
);
|
|
}
|
|
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$workspaceId = (int) ($run->workspace_id ?? $context['workspace_id'] ?? 0);
|
|
$tenantId = (int) ($run->managed_environment_id ?? $context['managed_environment_id'] ?? 0);
|
|
$fingerprint = is_string($context['review_fingerprint'] ?? null)
|
|
? trim((string) $context['review_fingerprint'])
|
|
: '';
|
|
$explicitReviewId = is_numeric($context['review_id'] ?? null)
|
|
? (int) $context['review_id']
|
|
: null;
|
|
|
|
$evidence = [
|
|
'adapter' => self::ADAPTER,
|
|
'operation_run_id' => (int) $run->getKey(),
|
|
'workspace_id' => $workspaceId,
|
|
'managed_environment_id' => $tenantId,
|
|
'fingerprint' => $fingerprint,
|
|
'explicit_review_id' => $explicitReviewId,
|
|
];
|
|
|
|
if ($workspaceId <= 0 || $tenantId <= 0 || $fingerprint === '') {
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The run no longer carries enough review scope to reconcile it safely.',
|
|
evidence: $evidence,
|
|
);
|
|
}
|
|
|
|
$candidates = EnvironmentReview::query()
|
|
->forWorkspace($workspaceId)
|
|
->forTenant($tenantId)
|
|
->where('fingerprint', $fingerprint)
|
|
->orderByDesc('id')
|
|
->get();
|
|
|
|
$evidence['considered_review_ids'] = $candidates->modelKeys();
|
|
$evidence['considered_reviews'] = $candidates
|
|
->map(fn (EnvironmentReview $review): array => $this->reviewReference($review))
|
|
->values()
|
|
->all();
|
|
|
|
$explicitReview = $explicitReviewId !== null
|
|
? $candidates->firstWhere('id', $explicitReviewId)
|
|
: null;
|
|
|
|
if ($explicitReviewId !== null && ! $explicitReview instanceof EnvironmentReview) {
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The originally queued review no longer matches the current review truth safely.',
|
|
evidence: $evidence + ['explicit_review_missing' => true],
|
|
);
|
|
}
|
|
|
|
if ($explicitReview instanceof EnvironmentReview) {
|
|
$lineageDecision = $this->evaluateExplicitReviewLineage($explicitReview, $candidates, $evidence);
|
|
|
|
if ($lineageDecision instanceof ReconciliationResult) {
|
|
return $lineageDecision;
|
|
}
|
|
}
|
|
|
|
$usable = $candidates->filter(fn (EnvironmentReview $review): bool => $this->isUsableReview($review))->values();
|
|
|
|
if ($usable->count() === 1) {
|
|
/** @var EnvironmentReview $review */
|
|
$review = $usable->first();
|
|
|
|
return ReconciliationResult::reconciledSucceeded(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: $review->status === EnvironmentReviewStatus::Published->value
|
|
? 'A matching published review was already available for this run.'
|
|
: 'A matching ready review was already available for this run.',
|
|
evidence: $evidence + ['chosen_review_id' => (int) $review->getKey()],
|
|
related: $this->relatedReviewMetadata($review),
|
|
summaryCounts: $this->summaryCounts($review),
|
|
);
|
|
}
|
|
|
|
if ($usable->count() > 1) {
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'Multiple matching reviews are available, so this run needs manual review.',
|
|
evidence: $evidence + ['ambiguous_review_ids' => $usable->modelKeys()],
|
|
);
|
|
}
|
|
|
|
if ($candidates->isEmpty()) {
|
|
return ReconciliationResult::notReconciled(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'No matching review truth is available yet for this run.',
|
|
evidence: $evidence,
|
|
);
|
|
}
|
|
|
|
if ($candidates->contains(fn (EnvironmentReview $review): bool => $this->isBlockingState($review))) {
|
|
$blockingReview = $candidates->first(fn (EnvironmentReview $review): bool => $this->isBlockingState($review));
|
|
|
|
if ($blockingReview instanceof EnvironmentReview && (int) ($blockingReview->operation_run_id ?? 0) === (int) $run->getKey()) {
|
|
return ReconciliationResult::notReconciled(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The queued review is still composing and does not prove final review truth yet.',
|
|
evidence: $evidence + [
|
|
'chosen_review_id' => (int) $blockingReview->getKey(),
|
|
],
|
|
related: $this->relatedReviewMetadata($blockingReview),
|
|
);
|
|
}
|
|
|
|
return ReconciliationResult::blocked(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'A matching review exists, but it is not ready yet.',
|
|
evidence: $evidence + [
|
|
'chosen_review_id' => $blockingReview instanceof EnvironmentReview ? (int) $blockingReview->getKey() : null,
|
|
],
|
|
related: $blockingReview instanceof EnvironmentReview ? $this->relatedReviewMetadata($blockingReview) : [],
|
|
summaryCounts: $blockingReview instanceof EnvironmentReview ? $this->summaryCounts($blockingReview) : [],
|
|
);
|
|
}
|
|
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'Review lineage needs operator confirmation before this run can be trusted.',
|
|
evidence: $evidence,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param Collection<int, EnvironmentReview> $candidates
|
|
* @param array<string, mixed> $evidence
|
|
*/
|
|
private function evaluateExplicitReviewLineage(
|
|
EnvironmentReview $review,
|
|
Collection $candidates,
|
|
array $evidence,
|
|
): ?ReconciliationResult {
|
|
if ($this->isUsableReview($review)) {
|
|
return ReconciliationResult::reconciledSucceeded(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: $review->status === EnvironmentReviewStatus::Published->value
|
|
? 'The queued review was already published before the run finished updating.'
|
|
: 'The queued review was already ready before the run finished updating.',
|
|
evidence: $evidence + ['chosen_review_id' => (int) $review->getKey()],
|
|
related: $this->relatedReviewMetadata($review),
|
|
summaryCounts: $this->summaryCounts($review),
|
|
);
|
|
}
|
|
|
|
$successorId = is_numeric($review->superseded_by_review_id)
|
|
? (int) $review->superseded_by_review_id
|
|
: null;
|
|
|
|
if ($successorId === null) {
|
|
if ($this->isBlockingState($review)) {
|
|
if ($candidates->contains(fn (EnvironmentReview $candidate): bool => $this->isUsableReview($candidate))) {
|
|
return null;
|
|
}
|
|
|
|
if ((int) ($review->operation_run_id ?? 0) === (int) ($evidence['operation_run_id'] ?? 0)) {
|
|
return ReconciliationResult::notReconciled(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The queued review is still composing and does not prove final review truth yet.',
|
|
evidence: $evidence + ['chosen_review_id' => (int) $review->getKey()],
|
|
related: $this->relatedReviewMetadata($review),
|
|
);
|
|
}
|
|
|
|
return ReconciliationResult::blocked(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The queued review still exists, but it is not ready yet.',
|
|
evidence: $evidence + ['chosen_review_id' => (int) $review->getKey()],
|
|
related: $this->relatedReviewMetadata($review),
|
|
summaryCounts: $this->summaryCounts($review),
|
|
);
|
|
}
|
|
|
|
if ($review->status === EnvironmentReviewStatus::Superseded->value) {
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'This run points at superseded review lineage that cannot be resolved safely.',
|
|
evidence: $evidence + ['lineage_review_id' => (int) $review->getKey()],
|
|
related: $this->relatedReviewMetadata($review),
|
|
);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/** @var EnvironmentReview|null $successor */
|
|
$successor = $candidates->firstWhere('id', $successorId);
|
|
|
|
if (! $successor instanceof EnvironmentReview) {
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'A successor review was recorded, but the matching review truth could not be resolved safely.',
|
|
evidence: $evidence + [
|
|
'lineage_review_id' => (int) $review->getKey(),
|
|
'lineage_successor_review_id' => $successorId,
|
|
],
|
|
related: $this->relatedReviewMetadata($review),
|
|
);
|
|
}
|
|
|
|
if ($this->isUsableReview($successor)) {
|
|
return ReconciliationResult::reconciledSucceeded(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'A ready successor review was already available for this run.',
|
|
evidence: $evidence + [
|
|
'lineage_review_id' => (int) $review->getKey(),
|
|
'chosen_review_id' => (int) $successor->getKey(),
|
|
],
|
|
related: $this->relatedReviewMetadata($successor) + [
|
|
'lineage_review_id' => (int) $review->getKey(),
|
|
],
|
|
summaryCounts: $this->summaryCounts($successor),
|
|
);
|
|
}
|
|
|
|
if ($this->isBlockingState($successor)) {
|
|
return ReconciliationResult::blocked(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'A successor review exists, but it is not ready yet.',
|
|
evidence: $evidence + [
|
|
'lineage_review_id' => (int) $review->getKey(),
|
|
'chosen_review_id' => (int) $successor->getKey(),
|
|
],
|
|
related: $this->relatedReviewMetadata($successor) + [
|
|
'lineage_review_id' => (int) $review->getKey(),
|
|
],
|
|
summaryCounts: $this->summaryCounts($successor),
|
|
);
|
|
}
|
|
|
|
return ReconciliationResult::attentionRequired(
|
|
reasonCode: LifecycleReconciliationReason::AdapterOutOfSync->value,
|
|
reasonMessage: 'The successor review needs operator review before this run can be trusted.',
|
|
evidence: $evidence + [
|
|
'lineage_review_id' => (int) $review->getKey(),
|
|
'chosen_review_id' => (int) $successor->getKey(),
|
|
],
|
|
related: $this->relatedReviewMetadata($successor) + [
|
|
'lineage_review_id' => (int) $review->getKey(),
|
|
],
|
|
);
|
|
}
|
|
|
|
private function isUsableReview(EnvironmentReview $review): bool
|
|
{
|
|
return in_array((string) $review->status, [
|
|
EnvironmentReviewStatus::Ready->value,
|
|
EnvironmentReviewStatus::Published->value,
|
|
], true);
|
|
}
|
|
|
|
private function isBlockingState(EnvironmentReview $review): bool
|
|
{
|
|
return in_array((string) $review->status, [
|
|
EnvironmentReviewStatus::Draft->value,
|
|
EnvironmentReviewStatus::Failed->value,
|
|
], true);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function relatedReviewMetadata(EnvironmentReview $review): array
|
|
{
|
|
return array_filter([
|
|
'type' => 'environment_review',
|
|
'id' => (int) $review->getKey(),
|
|
'status' => (string) $review->status,
|
|
'fingerprint' => (string) $review->fingerprint,
|
|
'operation_run_id' => is_numeric($review->operation_run_id) ? (int) $review->operation_run_id : null,
|
|
'superseded_by_review_id' => is_numeric($review->superseded_by_review_id) ? (int) $review->superseded_by_review_id : null,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function reviewReference(EnvironmentReview $review): array
|
|
{
|
|
return [
|
|
'id' => (int) $review->getKey(),
|
|
'status' => (string) $review->status,
|
|
'operation_run_id' => is_numeric($review->operation_run_id) ? (int) $review->operation_run_id : null,
|
|
'superseded_by_review_id' => is_numeric($review->superseded_by_review_id) ? (int) $review->superseded_by_review_id : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, int>
|
|
*/
|
|
private function summaryCounts(EnvironmentReview $review): array
|
|
{
|
|
$summary = is_array($review->summary) ? $review->summary : [];
|
|
$counts = [
|
|
'finding_count' => is_numeric($summary['finding_count'] ?? null) ? (int) $summary['finding_count'] : null,
|
|
'report_count' => is_numeric($summary['report_count'] ?? null) ? (int) $summary['report_count'] : null,
|
|
'operation_count' => is_numeric($summary['operation_count'] ?? null) ? (int) $summary['operation_count'] : null,
|
|
'errors_recorded' => 0,
|
|
];
|
|
|
|
return array_filter($counts, static fn (mixed $value): bool => is_int($value));
|
|
}
|
|
}
|