TenantAtlas/apps/platform/app/Services/Operations/OperationRunOperatorActionService.php
Ahmed Darrazi 2a856d2693
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m44s
feat: implement operations UI operator actions regression gate
2026-06-08 03:19:34 +02:00

197 lines
6.6 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Services\Operations;
use App\Models\ManagedEnvironment;
use App\Models\OperationRun;
use App\Models\User;
use App\Models\Workspace;
use App\Services\AdapterRunReconciler;
use App\Services\Audit\AuditRecorder;
use App\Support\Audit\AuditActorSnapshot;
use App\Support\Audit\AuditTargetSnapshot;
use App\Support\OperationCatalog;
use App\Support\Operations\OperationRunActionEligibility;
use Illuminate\Support\Facades\Gate;
final class OperationRunOperatorActionService
{
public function __construct(
private readonly AdapterRunReconciler $reconciler,
private readonly OperationRunActionEligibility $eligibility,
private readonly AuditRecorder $auditRecorder,
) {}
/**
* @return array<string, mixed>
*/
public function reconcile(OperationRun $run, User $actor): array
{
$run->loadMissing(['workspace', 'tenant']);
$authorization = Gate::forUser($actor)->inspect('reconcile', $run);
if ($authorization->denied()) {
$this->recordAction(
run: $run,
actor: $actor,
action: 'operation.reconcile_denied',
status: 'denied',
reasonCode: 'policy_denied',
before: $this->state($run),
after: $this->state($run),
);
abort($authorization->status() ?: 403);
}
$decision = $this->eligibility->forRun($run, $actor);
if (! $this->hasEnabledAction($decision, 'reconcile')) {
$this->recordAction(
run: $run,
actor: $actor,
action: 'operation.reconcile_denied',
status: 'denied',
reasonCode: array_key_exists('reconcile', $decision['disabled_reasons'])
? 'reconcile_unavailable'
: 'reconcile_not_primary',
before: $this->state($run),
after: $this->state($run),
);
abort(403);
}
$before = $this->state($run);
$change = $this->reconciler->reconcileOperationRun($run, false);
$run->refresh();
$after = $this->state($run);
if (! is_array($change) || ($change['applied'] ?? false) !== true) {
$this->recordAction(
run: $run,
actor: $actor,
action: 'operation.reconcile_noop',
status: 'warning',
reasonCode: is_string($change['reason_code'] ?? null) ? (string) $change['reason_code'] : 'no_reconciliation_applied',
before: $before,
after: $after,
);
return [
'applied' => false,
'reason_code' => is_string($change['reason_code'] ?? null) ? (string) $change['reason_code'] : 'no_reconciliation_applied',
'change' => $change,
];
}
$this->recordAction(
run: $run,
actor: $actor,
action: 'operation.reconciled_by_operator',
status: 'success',
reasonCode: is_string($change['reason_code'] ?? null) ? (string) $change['reason_code'] : 'operator_reconcile_applied',
before: $before,
after: $after,
);
return [
'applied' => true,
'reason_code' => is_string($change['reason_code'] ?? null) ? (string) $change['reason_code'] : 'operator_reconcile_applied',
'change' => $change,
];
}
/**
* @param array<string, mixed> $decision
*/
private function hasEnabledAction(array $decision, string $key): bool
{
$primary = $decision['primary_action'] ?? null;
if (is_array($primary) && ($primary['key'] ?? null) === $key) {
return true;
}
foreach ($decision['secondary_actions'] ?? [] as $action) {
if (is_array($action) && ($action['key'] ?? null) === $key) {
return true;
}
}
return false;
}
/**
* @return array{status:string,outcome:string}
*/
private function state(OperationRun $run): array
{
return [
'status' => (string) $run->status,
'outcome' => (string) $run->outcome,
];
}
/**
* @param array{status:string,outcome:string} $before
* @param array{status:string,outcome:string} $after
*/
private function recordAction(
OperationRun $run,
User $actor,
string $action,
string $status,
string $reasonCode,
array $before,
array $after,
): void {
$workspace = $run->workspace instanceof Workspace ? $run->workspace : null;
$tenant = $run->tenant instanceof ManagedEnvironment ? $run->tenant : null;
if (! $workspace instanceof Workspace) {
return;
}
$this->auditRecorder->record(
action: $action,
context: [
'metadata' => [
'operator_action' => 'reconcile',
'operation_run_id' => (int) $run->getKey(),
'workspace_id' => (int) $workspace->getKey(),
'managed_environment_id' => $tenant instanceof ManagedEnvironment ? (int) $tenant->getKey() : null,
'actor_user_id' => (int) $actor->getKey(),
'operation_type' => OperationCatalog::canonicalCode((string) $run->type),
'previous_status' => $before['status'],
'previous_outcome' => $before['outcome'],
'resulting_status' => $after['status'],
'resulting_outcome' => $after['outcome'],
'reason_code' => $reasonCode,
'requested_at' => now()->toIso8601String(),
'mutation_scope' => 'tenantpilot_operation_metadata_only',
],
],
workspace: $workspace,
tenant: $tenant,
actor: AuditActorSnapshot::human($actor),
target: new AuditTargetSnapshot(
type: 'operation_run',
id: (int) $run->getKey(),
label: OperationCatalog::label((string) $run->type).' #'.$run->getKey(),
),
outcome: $status,
summary: match ($status) {
'success' => 'Operation run reconciled by operator',
'warning' => 'Operation run reconcile action had no effect',
default => 'Operation run reconcile action denied',
},
operationRunId: (int) $run->getKey(),
);
}
}