TenantAtlas/apps/platform/app/Jobs/Operations/CrossTenantPromotionExecutionJob.php
Ahmed Darrazi 983abb18a1
Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 3m22s
chore: commit workspace changes (automated)
2026-05-02 16:36:21 +02:00

394 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Jobs\Operations;
use App\Jobs\Middleware\EnsureQueuedExecutionLegitimate;
use App\Jobs\Middleware\TrackOperationRun;
use App\Models\BackupItem;
use App\Models\BackupSet;
use App\Models\OperationRun;
use App\Models\Policy;
use App\Models\PolicyVersion;
use App\Models\RestoreRun;
use App\Models\Tenant;
use App\Services\Audit\WorkspaceAuditLogger;
use App\Services\Intune\RestoreService;
use App\Services\OperationRunService;
use App\Services\Operations\TargetScopeConcurrencyLimiter;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Carbon\CarbonImmutable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use RuntimeException;
use Throwable;
final class CrossTenantPromotionExecutionJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $timeout = 420;
public bool $failOnTimeout = true;
public ?OperationRun $operationRun = null;
public function __construct(OperationRun $operationRun)
{
$this->operationRun = $operationRun;
}
/**
* @return array<int, object>
*/
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<array<string, mixed>> $items
* @return array{0: ?BackupSet, 1: list<int>, 2: array<string, int>, 3: list<array{code: string, message: string}>}
*/
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<array<string, mixed>> $items
* @return array{0: array<string, int>, 1: list<array{code: string, message: string}>}
*/
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<string, int> $left
* @param array<string, int> $right
* @return array<string, int>
*/
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<string, int> $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<string, mixed> $item
*/
private function itemLabel(array $item): string
{
$displayName = (string) ($item['display_name'] ?? '');
if ($displayName !== '') {
return $displayName;
}
return (string) ($item['subject_key'] ?? 'unknown subject');
}
}