operationRun = $operationRun; } /** * @return array */ public function middleware(): array { return [ new EnsureQueuedExecutionLegitimate, new TrackOperationRun, ]; } public function handle( OperationRunService $operationRuns, RestoreService $restoreService, TargetScopeConcurrencyLimiter $limiter, WorkspaceAuditLogger $auditLogger, ): void { if (! $this->operationRun instanceof OperationRun) { throw new RuntimeException('OperationRun is required for promotion execution.'); } $this->operationRun->refresh(); if ($this->operationRun->status === OperationRunStatus::Completed->value) { return; } $tenant = $this->operationRun->tenant; if (! $tenant instanceof Tenant) { throw new RuntimeException('Promotion execution target tenant is missing.'); } $context = is_array($this->operationRun->context) ? $this->operationRun->context : []; $targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : []; $lock = $limiter->acquireSlot((int) $tenant->getKey(), $targetScope); if (! $lock) { $this->release(max(1, (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3))); return; } try { $plan = is_array(data_get($context, 'promotion_execution.plan')) ? data_get($context, 'promotion_execution.plan') : null; if (! is_array($plan)) { throw new RuntimeException('Promotion execution plan is missing from operation context.'); } $summary = [ 'total' => 0, 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0, 'created' => 0, 'updated' => 0, ]; $failures = []; $items = is_array($plan['items'] ?? null) ? array_values(array_filter($plan['items'], 'is_array')) : []; $summary['total'] = count($items); [$backupSet, $selectedItemIds, $preRestoreSummary, $preRestoreFailures] = $this->buildRestoreInputs( tenant: $tenant, operationRun: $this->operationRun, items: $items, ); $summary = array_replace($summary, $preRestoreSummary); $failures = array_merge($failures, $preRestoreFailures); $restoreRun = null; if ($selectedItemIds !== []) { $restoreRun = RestoreRun::withoutEvents(fn (): RestoreRun => $restoreService->execute( tenant: $tenant, backupSet: $backupSet, selectedItemIds: $selectedItemIds, dryRun: false, actorEmail: $this->operationRun->user?->email, actorName: $this->operationRun->initiator_name, providerConnectionId: is_numeric($context['provider_connection_id'] ?? null) ? (int) $context['provider_connection_id'] : null, )); RestoreRun::withoutEvents(function () use ($restoreRun): void { $restoreRun->forceFill(['operation_run_id' => (int) $this->operationRun?->getKey()])->save(); }); [$restoreSummary, $restoreFailures] = $this->summaryFromRestoreRun($restoreRun, $items); $summary = $this->mergeSummary($summary, $restoreSummary); $failures = array_merge($failures, $restoreFailures); $context['restore_run_id'] = (int) $restoreRun->getKey(); $context['backup_set_id'] = (int) $backupSet->getKey(); $this->operationRun->forceFill(['context' => $context])->save(); } else { $backupSet?->delete(); } $outcome = $this->outcome($summary); $updated = $operationRuns->updateRun( run: $this->operationRun, status: OperationRunStatus::Completed->value, outcome: $outcome, summaryCounts: $summary, failures: $failures, ); $auditLogger->logCrossTenantPromotionExecutionCompleted( operationRun: $updated, sourceTenantId: is_numeric($context['source_tenant_id'] ?? null) ? (int) $context['source_tenant_id'] : null, targetTenant: $tenant, summaryCounts: $summary, restoreRun: $restoreRun, ); } catch (Throwable $exception) { throw $exception; } finally { $lock->release(); } } public function getOperationRun(): ?OperationRun { return $this->operationRun; } /** * @param list> $items * @return array{0: ?BackupSet, 1: list, 2: array, 3: list} */ private function buildRestoreInputs(Tenant $tenant, OperationRun $operationRun, array $items): array { $summary = [ 'processed' => 0, 'succeeded' => 0, 'failed' => 0, 'skipped' => 0, 'created' => 0, 'updated' => 0, ]; $failures = []; $backupSet = BackupSet::query()->create([ 'tenant_id' => (int) $tenant->getKey(), 'name' => 'Cross-tenant promotion • Operation #'.$operationRun->getKey(), 'created_by' => $operationRun->user?->email, 'status' => 'completed', 'item_count' => 0, 'completed_at' => CarbonImmutable::now(), 'metadata' => [ 'source' => 'cross_tenant_promotion', 'operation_run_id' => (int) $operationRun->getKey(), ], ]); $selectedItemIds = []; foreach ($items as $item) { $action = (string) ($item['execution_action'] ?? ''); if ($action === 'skip_aligned') { $summary['processed']++; $summary['skipped']++; continue; } $versionId = data_get($item, 'source.policy_version_id'); $sourceTenantId = data_get($item, 'source.tenant_id'); $version = is_numeric($versionId) && is_numeric($sourceTenantId) ? PolicyVersion::query() ->with('policy') ->whereKey((int) $versionId) ->where('tenant_id', (int) $sourceTenantId) ->first() : null; if (! $version instanceof PolicyVersion || ! $version->policy instanceof Policy) { $summary['processed']++; $summary['failed']++; $failures[] = [ 'code' => 'promotion.source_version_missing', 'message' => 'Source policy version for '.$this->itemLabel($item).' was not found.', ]; continue; } $sourcePolicy = $version->policy; $targetExternalId = data_get($item, 'target.subject_external_id'); $sourceExternalId = data_get($item, 'source.subject_external_id'); $policyIdentifier = is_string($targetExternalId) && trim($targetExternalId) !== '' ? trim($targetExternalId) : (is_string($sourceExternalId) && trim($sourceExternalId) !== '' ? trim($sourceExternalId) : (string) $sourcePolicy->external_id); $targetPolicy = Policy::query() ->where('tenant_id', (int) $tenant->getKey()) ->where('policy_type', (string) $sourcePolicy->policy_type) ->where('external_id', $policyIdentifier) ->first(); $backupItem = BackupItem::query()->create([ 'tenant_id' => (int) $tenant->getKey(), 'backup_set_id' => (int) $backupSet->getKey(), 'policy_id' => $targetPolicy?->getKey(), 'policy_identifier' => $policyIdentifier, 'policy_type' => (string) $sourcePolicy->policy_type, 'platform' => (string) $sourcePolicy->platform, 'captured_at' => $version->captured_at ?? CarbonImmutable::now(), 'payload' => is_array($version->snapshot) ? $version->snapshot : [], 'metadata' => [ 'source' => 'cross_tenant_promotion', 'display_name' => (string) $sourcePolicy->display_name, 'operation_run_id' => (int) $operationRun->getKey(), 'source_tenant_id' => (int) $sourcePolicy->tenant_id, 'source_policy_id' => (int) $sourcePolicy->getKey(), 'source_policy_version_id' => (int) $version->getKey(), 'source_subject_key' => (string) ($item['subject_key'] ?? ''), 'execution_action' => $action, 'target_subject_external_id' => is_string($targetExternalId) ? $targetExternalId : null, ], 'assignments' => is_array($version->assignments) ? $version->assignments : [], ]); $selectedItemIds[] = (int) $backupItem->getKey(); } $backupSet->forceFill(['item_count' => count($selectedItemIds)])->save(); return [$backupSet, $selectedItemIds, $summary, $failures]; } /** * @param list> $items * @return array{0: array, 1: list} */ private function summaryFromRestoreRun(RestoreRun $restoreRun, array $items): array { $metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : []; $results = is_array($restoreRun->results) ? $restoreRun->results : []; $resultItems = is_array($results['items'] ?? null) ? $results['items'] : []; $succeeded = (int) ($metadata['succeeded'] ?? 0); $failed = (int) ($metadata['failed'] ?? 0) + (int) ($metadata['partial'] ?? 0); $skipped = (int) ($metadata['skipped'] ?? 0); $processed = $succeeded + $failed + $skipped; $created = 0; $updated = 0; $failures = []; foreach ($items as $item) { $action = (string) ($item['execution_action'] ?? ''); if ($action === 'create_missing') { $created++; } elseif ($action === 'update_existing') { $updated++; } } foreach ($resultItems as $result) { if (! is_array($result)) { continue; } $status = (string) ($result['status'] ?? ''); if (in_array($status, ['applied', 'dry_run'], true)) { continue; } $failures[] = [ 'code' => 'promotion.restore_item_not_applied', 'message' => (string) ($result['reason'] ?? 'Promotion restore item did not apply.'), ]; } return [[ 'processed' => $processed, 'succeeded' => $succeeded, 'failed' => $failed, 'skipped' => $skipped, 'created' => min($created, $succeeded), 'updated' => min($updated, max(0, $succeeded - min($created, $succeeded))), ], $failures]; } /** * @param array $left * @param array $right * @return array */ private function mergeSummary(array $left, array $right): array { foreach ($right as $key => $value) { $left[$key] = (int) ($left[$key] ?? 0) + (int) $value; } return $left; } /** * @param array $summary */ private function outcome(array $summary): string { $total = (int) ($summary['total'] ?? 0); $failed = (int) ($summary['failed'] ?? 0); $succeeded = (int) ($summary['succeeded'] ?? 0); $skipped = (int) ($summary['skipped'] ?? 0); if ($total > 0 && $failed >= $total) { return OperationRunOutcome::Failed->value; } if ($failed > 0) { return OperationRunOutcome::PartiallySucceeded->value; } if ($succeeded > 0 || $skipped > 0) { return OperationRunOutcome::Succeeded->value; } return OperationRunOutcome::Failed->value; } /** * @param array $item */ private function itemLabel(array $item): string { $displayName = (string) ($item['display_name'] ?? ''); if ($displayName !== '') { return $displayName; } return (string) ($item['subject_key'] ?? 'unknown subject'); } }