$policyIds * @param array{include_assignments?: bool, include_scope_tags?: bool, include_foundations?: bool} $options */ public function __construct( public int $tenantId, public int $userId, public int $backupSetId, public array $policyIds, public array $options, public string $idempotencyKey, ?OperationRun $operationRun = null ) { $this->operationRun = $operationRun; } public function middleware(): array { return [new TrackOperationRun]; } public function handle( OperationRunService $operationRunService, PolicyCaptureOrchestrator $captureOrchestrator, FoundationSnapshotService $foundationSnapshots, SnapshotValidator $snapshotValidator, ): void { if (! $this->operationRun instanceof OperationRun) { return; } $tenant = Tenant::query()->find($this->tenantId); $initiator = User::query()->find($this->userId); $policyIds = $this->normalizePolicyIds($this->policyIds); $includeAssignments = (bool) ($this->options['include_assignments'] ?? false); $includeScopeTags = (bool) ($this->options['include_scope_tags'] ?? false); $includeFoundations = (bool) ($this->options['include_foundations'] ?? false); try { if (! $tenant instanceof Tenant) { $this->failRun( operationRunService: $operationRunService, tenant: null, code: 'tenant.not_found', message: 'Tenant not found for run.', initiator: $initiator, ); return; } $backupSet = BackupSet::withTrashed() ->where('tenant_id', $tenant->getKey()) ->whereKey($this->backupSetId) ->first(); if (! $backupSet) { $this->failRun( operationRunService: $operationRunService, tenant: $tenant, code: 'backup_set.not_found', message: 'Backup set not found.', initiator: $initiator, ); return; } if ($backupSet->trashed()) { $this->failRun( operationRunService: $operationRunService, tenant: $tenant, code: 'backup_set.archived', message: 'Backup set is archived.', initiator: $initiator, ); return; } $this->operationRun->update([ 'context' => array_merge($this->operationRun->context ?? [], [ 'backup_set_id' => (int) $backupSet->getKey(), 'policy_ids' => $policyIds, 'options' => [ 'include_assignments' => $includeAssignments, 'include_scope_tags' => $includeScopeTags, 'include_foundations' => $includeFoundations, ], 'idempotency_key' => $this->idempotencyKey, ]), ]); $operationRunService->updateRun($this->operationRun, 'running', 'pending'); $operationRunService->incrementSummaryCounts($this->operationRun, [ 'total' => count($policyIds), 'items' => count($policyIds), ]); if ($policyIds === []) { $operationRunService->updateRun( $this->operationRun, status: 'completed', outcome: 'failed', failures: [[ 'code' => 'selection.empty', 'message' => 'No policies selected.', ]], ); $this->notifyRunFailed($initiator, $tenant, 'No policies selected.'); return; } $existingBackupFailures = (array) Arr::get($backupSet->metadata ?? [], 'failures', []); $newBackupFailures = []; $didMutateBackupSet = false; $backupSetItemMutations = 0; $foundationMutations = 0; $foundationFailures = 0; /** @var array $activePolicyIds */ $activePolicyIds = BackupItem::query() ->where('backup_set_id', $backupSet->getKey()) ->whereIn('policy_id', $policyIds) ->pluck('policy_id') ->filter() ->map(fn (mixed $value): int => (int) $value) ->values() ->all(); $activePolicyIdSet = array_fill_keys($activePolicyIds, true); /** @var EloquentCollection $trashedItems */ $trashedItems = BackupItem::onlyTrashed() ->where('backup_set_id', $backupSet->getKey()) ->whereIn('policy_id', $policyIds) ->get() ->keyBy('policy_id'); /** @var EloquentCollection $policies */ $policies = Policy::query() ->where('tenant_id', $tenant->getKey()) ->whereIn('id', $policyIds) ->get() ->keyBy('id'); $runFailuresForOperationRun = []; foreach ($policyIds as $policyId) { if (isset($activePolicyIdSet[$policyId])) { $operationRunService->incrementSummaryCounts($this->operationRun, [ 'processed' => 1, 'skipped' => 1, ]); continue; } $trashed = $trashedItems->get($policyId); if ($trashed instanceof BackupItem) { $trashed->restore(); $activePolicyIdSet[$policyId] = true; $didMutateBackupSet = true; $backupSetItemMutations++; $operationRunService->incrementSummaryCounts($this->operationRun, [ 'processed' => 1, 'succeeded' => 1, 'updated' => 1, ]); continue; } $policy = $policies->get($policyId); if (! $policy instanceof Policy) { $newBackupFailures[] = [ 'policy_id' => $policyId, 'reason' => RunFailureSanitizer::sanitizeMessage('Policy not found.'), 'status' => null, 'reason_code' => 'policy_not_found', ]; $didMutateBackupSet = true; $operationRunService->incrementSummaryCounts($this->operationRun, [ 'processed' => 1, 'failed' => 1, ]); $runFailuresForOperationRun[] = [ 'code' => 'policy.not_found', 'message' => "Policy {$policyId} not found.", ]; continue; } if ($policy->ignored_at) { $operationRunService->incrementSummaryCounts($this->operationRun, [ 'processed' => 1, 'skipped' => 1, ]); continue; } try { $captureResult = $captureOrchestrator->capture( policy: $policy, tenant: $tenant, includeAssignments: $includeAssignments, includeScopeTags: $includeScopeTags, createdBy: $initiator?->email ? Str::limit((string) $initiator->email, 255, '') : null, metadata: [ 'source' => 'backup', 'backup_set_id' => $backupSet->getKey(), ], ); } catch (Throwable $throwable) { $reason = RunFailureSanitizer::sanitizeMessage($throwable->getMessage()); $newBackupFailures[] = [ 'policy_id' => $policyId, 'reason' => $reason, 'status' => null, 'reason_code' => 'unknown', ]; $didMutateBackupSet = true; $operationRunService->incrementSummaryCounts($this->operationRun, [ 'processed' => 1, 'failed' => 1, ]); $runFailuresForOperationRun[] = [ 'code' => 'policy.capture_exception', 'message' => $reason, ]; continue; } if (isset($captureResult['failure']) && is_array($captureResult['failure'])) { $failure = $captureResult['failure']; $status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null; $reasonCode = $this->mapGraphFailureReasonCode($status); $reason = RunFailureSanitizer::sanitizeMessage((string) ($failure['reason'] ?? 'Graph capture failed.')); $newBackupFailures[] = [ 'policy_id' => $policyId, 'reason' => $reason, 'status' => $status, 'reason_code' => $reasonCode, ]; $didMutateBackupSet = true; $operationRunService->incrementSummaryCounts($this->operationRun, [ 'processed' => 1, 'failed' => 1, ]); $runFailuresForOperationRun[] = [ 'code' => "graph.{$reasonCode}", 'message' => $reason, ]; continue; } $version = $captureResult['version'] ?? null; $captured = $captureResult['captured'] ?? null; if (! $version || ! is_array($captured)) { $newBackupFailures[] = [ 'policy_id' => $policyId, 'reason' => RunFailureSanitizer::sanitizeMessage('Capture result missing version payload.'), 'status' => null, 'reason_code' => 'unknown', ]; $didMutateBackupSet = true; $operationRunService->incrementSummaryCounts($this->operationRun, [ 'processed' => 1, 'failed' => 1, ]); $runFailuresForOperationRun[] = [ 'code' => 'capture.missing_payload', 'message' => 'Capture result missing version payload.', ]; continue; } $payload = $captured['payload'] ?? []; $metadata = is_array($captured['metadata'] ?? null) ? $captured['metadata'] : []; $assignments = is_array($captured['assignments'] ?? null) ? $captured['assignments'] : null; $scopeTags = is_array($captured['scope_tags'] ?? null) ? $captured['scope_tags'] : null; if (! is_array($payload)) { $payload = []; } $validation = $snapshotValidator->validate($payload); $warnings = $validation['warnings'] ?? []; $odataWarning = BackupItem::odataTypeWarning($payload, $policy->policy_type, $policy->platform); if ($odataWarning) { $warnings[] = $odataWarning; } if (! empty($warnings)) { $existingWarnings = is_array($metadata['warnings'] ?? null) ? $metadata['warnings'] : []; $metadata['warnings'] = array_values(array_unique(array_merge($existingWarnings, $warnings))); } if (is_array($scopeTags)) { $metadata['scope_tag_ids'] = $scopeTags['ids'] ?? null; $metadata['scope_tag_names'] = $scopeTags['names'] ?? null; } try { BackupItem::create([ 'tenant_id' => $tenant->getKey(), 'backup_set_id' => $backupSet->getKey(), 'policy_id' => $policy->getKey(), 'policy_version_id' => $version->getKey(), 'policy_identifier' => $policy->external_id, 'policy_type' => $policy->policy_type, 'platform' => $policy->platform, 'payload' => $payload, 'metadata' => $metadata, 'assignments' => $assignments, ]); } catch (QueryException $exception) { if ((string) $exception->getCode() === '23505') { $operationRunService->incrementSummaryCounts($this->operationRun, [ 'processed' => 1, 'skipped' => 1, ]); continue; } throw $exception; } $activePolicyIdSet[$policyId] = true; $didMutateBackupSet = true; $backupSetItemMutations++; $operationRunService->incrementSummaryCounts($this->operationRun, [ 'processed' => 1, 'succeeded' => 1, 'created' => 1, ]); } if ($includeFoundations) { [$foundationOutcome, $foundationFailureEntries] = $this->captureFoundations( foundationSnapshots: $foundationSnapshots, tenant: $tenant, backupSet: $backupSet, ); if (($foundationOutcome['created'] ?? 0) > 0 || ($foundationOutcome['restored'] ?? 0) > 0) { $didMutateBackupSet = true; $foundationMutations = (int) $foundationOutcome['created'] + (int) $foundationOutcome['restored']; } if ($foundationFailureEntries !== []) { $didMutateBackupSet = true; $foundationFailures = count($foundationFailureEntries); $newBackupFailures = array_merge($newBackupFailures, $foundationFailureEntries); foreach ($foundationFailureEntries as $foundationFailure) { $runFailuresForOperationRun[] = [ 'code' => 'foundation.capture_failed', 'message' => (string) ($foundationFailure['reason'] ?? 'Foundation capture failed.'), ]; } } } if ($didMutateBackupSet) { $allFailures = array_merge($existingBackupFailures, $newBackupFailures); $mutations = $backupSetItemMutations + $foundationMutations; $backupSetStatus = match (true) { $mutations === 0 && count($allFailures) > 0 => 'failed', count($allFailures) > 0 => 'partial', default => 'completed', }; $backupSet->update([ 'status' => $backupSetStatus, 'item_count' => $backupSet->items()->count(), 'completed_at' => now(), 'metadata' => ['failures' => $allFailures], ]); } $this->operationRun->refresh(); $counts = is_array($this->operationRun->summary_counts) ? $this->operationRun->summary_counts : []; $failed = (int) ($counts['failed'] ?? 0); $succeeded = (int) ($counts['succeeded'] ?? 0); $skipped = (int) ($counts['skipped'] ?? 0); $outcome = 'succeeded'; if ($failed > 0 && $succeeded > 0) { $outcome = 'partially_succeeded'; } if ($failed > 0 && $succeeded === 0) { $outcome = 'failed'; } $operationRunService->updateRun( $this->operationRun, status: 'completed', outcome: $outcome, failures: $runFailuresForOperationRun, ); if (! $initiator instanceof User) { return; } $message = "Added {$succeeded} policies"; if ($skipped > 0) { $message .= " ({$skipped} skipped)"; } if ($failed > 0) { $message .= " ({$failed} failed)"; } if ($includeFoundations) { $message .= ". Foundations: {$foundationMutations} items"; if ($foundationFailures > 0) { $message .= " ({$foundationFailures} failed)"; } } $message .= '.'; $partial = $outcome === 'partially_succeeded' || $foundationFailures > 0; $notification = Notification::make() ->title($partial ? 'Add Policies Completed (partial)' : 'Add Policies Completed') ->body($message) ->actions([ \Filament\Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($this->operationRun, $tenant)), ]); if ($partial) { $notification->warning(); } else { $notification->success(); } $notification ->sendToDatabase($initiator) ->send(); } catch (Throwable $throwable) { $this->failRun( operationRunService: $operationRunService, tenant: $tenant instanceof Tenant ? $tenant : null, code: 'exception.unhandled', message: $throwable->getMessage(), initiator: $initiator, ); // TrackOperationRun will catch this throw throw $throwable; } } /** * @param array $policyIds * @return array */ private function normalizePolicyIds(array $policyIds): array { $policyIds = array_values(array_unique(array_map('intval', $policyIds))); $policyIds = array_values(array_filter($policyIds, fn (int $value): bool => $value > 0)); sort($policyIds); return $policyIds; } private function failRun( OperationRunService $operationRunService, ?Tenant $tenant, string $code, string $message, ?User $initiator = null, ): void { $safeMessage = RunFailureSanitizer::sanitizeMessage($message); $safeCode = RunFailureSanitizer::sanitizeCode($code); $operationRunService->updateRun( $this->operationRun, status: 'completed', outcome: 'failed', failures: [[ 'code' => $safeCode, 'message' => $safeMessage, ]], ); $this->notifyRunFailed($initiator, $tenant, $safeMessage); } private function notifyRunFailed(?User $initiator, ?Tenant $tenant, string $reason): void { if (! $initiator instanceof User) { return; } $notification = Notification::make() ->title('Add Policies Failed') ->body($reason); if ($tenant instanceof Tenant) { $notification->actions([ \Filament\Actions\Action::make('view_run') ->label('View run') ->url(OperationRunLinks::view($this->operationRun, $tenant)), ]); } $notification ->danger() ->sendToDatabase($initiator) ->send(); } private function mapGraphFailureReasonCode(?int $status): string { return match (true) { $status === 403 => 'graph_forbidden', in_array($status, [429, 503], true) => 'graph_throttled', in_array($status, [408, 500, 502, 504], true) => 'graph_transient', default => 'unknown', }; } /** * @return array{0:array{created:int,restored:int,failures:array},1:array} */ private function captureFoundations( FoundationSnapshotService $foundationSnapshots, Tenant $tenant, BackupSet $backupSet, ): array { $types = config('tenantpilot.foundation_types', []); $created = 0; $restored = 0; $failures = []; foreach ($types as $typeConfig) { $foundationType = $typeConfig['type'] ?? null; if (! is_string($foundationType) || $foundationType === '') { continue; } $result = $foundationSnapshots->fetchAll($tenant, $foundationType); foreach (($result['failures'] ?? []) as $failure) { if (! is_array($failure)) { continue; } $status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null; $reasonCode = $this->mapGraphFailureReasonCode($status); $reason = RunFailureSanitizer::sanitizeMessage((string) ($failure['reason'] ?? 'Foundation capture failed.')); $failures[] = [ 'foundation_type' => $foundationType, 'reason' => $reason, 'status' => $status, 'reason_code' => $reasonCode, ]; } foreach (($result['items'] ?? []) as $snapshot) { if (! is_array($snapshot)) { continue; } $sourceId = $snapshot['source_id'] ?? null; if (! is_string($sourceId) || $sourceId === '') { continue; } $existing = BackupItem::withTrashed() ->where('backup_set_id', $backupSet->getKey()) ->where('policy_type', $foundationType) ->where('policy_identifier', $sourceId) ->first(); if ($existing) { if ($existing->trashed()) { $existing->restore(); $restored++; } continue; } BackupItem::create([ 'tenant_id' => $tenant->getKey(), 'backup_set_id' => $backupSet->getKey(), 'policy_id' => null, 'policy_identifier' => $sourceId, 'policy_type' => $foundationType, 'platform' => $typeConfig['platform'] ?? null, 'payload' => $snapshot['payload'] ?? [], 'metadata' => $snapshot['metadata'] ?? [], ]); $created++; } } return [ [ 'created' => $created, 'restored' => $restored, 'failures' => $failures, ], $failures, ]; } }