197 lines
6.6 KiB
PHP
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(),
|
|
);
|
|
}
|
|
}
|