394 lines
14 KiB
PHP
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');
|
|
}
|
|
}
|