wip: feature 056 progress
This commit is contained in:
parent
bcdeeb5525
commit
5118497da9
116
app/Console/Commands/OpsReconcileAdapterRuns.php
Normal file
116
app/Console/Commands/OpsReconcileAdapterRuns.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\AdapterRunReconciler;
|
||||
use Illuminate\Console\Command;
|
||||
use Throwable;
|
||||
|
||||
class OpsReconcileAdapterRuns extends Command
|
||||
{
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'ops:reconcile-adapter-runs
|
||||
{--type= : Adapter run type (e.g. restore.execute)}
|
||||
{--tenant= : Tenant ID}
|
||||
{--older-than=60 : Only consider runs older than N minutes}
|
||||
{--dry-run=true : Preview only (true/false)}
|
||||
{--limit=50 : Max number of runs to inspect}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Reconcile stale adapter-backed operation runs from DB-only source-of-truth records.';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle()
|
||||
{
|
||||
try {
|
||||
/** @var AdapterRunReconciler $reconciler */
|
||||
$reconciler = app(AdapterRunReconciler::class);
|
||||
|
||||
$type = $this->option('type');
|
||||
$type = is_string($type) && trim($type) !== '' ? trim($type) : null;
|
||||
|
||||
$tenantId = $this->option('tenant');
|
||||
$tenantId = is_numeric($tenantId) ? (int) $tenantId : null;
|
||||
|
||||
$olderThanMinutes = $this->option('older-than');
|
||||
$olderThanMinutes = is_numeric($olderThanMinutes) ? (int) $olderThanMinutes : 60;
|
||||
$olderThanMinutes = max(1, $olderThanMinutes);
|
||||
|
||||
$limit = $this->option('limit');
|
||||
$limit = is_numeric($limit) ? (int) $limit : 50;
|
||||
$limit = max(1, $limit);
|
||||
|
||||
$dryRun = $this->option('dry-run');
|
||||
$dryRun = filter_var($dryRun, FILTER_VALIDATE_BOOL, FILTER_NULL_ON_FAILURE);
|
||||
$dryRun = $dryRun ?? true;
|
||||
|
||||
$result = $reconciler->reconcile([
|
||||
'type' => $type,
|
||||
'tenant_id' => $tenantId,
|
||||
'older_than_minutes' => $olderThanMinutes,
|
||||
'limit' => $limit,
|
||||
'dry_run' => $dryRun,
|
||||
]);
|
||||
|
||||
$changes = $result['changes'] ?? [];
|
||||
|
||||
usort($changes, static fn (array $a, array $b): int => ((int) ($a['operation_run_id'] ?? 0)) <=> ((int) ($b['operation_run_id'] ?? 0)));
|
||||
|
||||
$this->info('Adapter run reconciliation');
|
||||
$this->line('dry_run: '.($dryRun ? 'true' : 'false'));
|
||||
$this->line('type: '.($type ?? '(all supported)'));
|
||||
$this->line('tenant: '.($tenantId ? (string) $tenantId : '(all)'));
|
||||
$this->line('older_than_minutes: '.$olderThanMinutes);
|
||||
$this->line('limit: '.$limit);
|
||||
$this->newLine();
|
||||
|
||||
$this->line('candidates: '.(int) ($result['candidates'] ?? 0));
|
||||
$this->line('reconciled: '.(int) ($result['reconciled'] ?? 0));
|
||||
$this->line('skipped: '.(int) ($result['skipped'] ?? 0));
|
||||
$this->newLine();
|
||||
|
||||
if ($changes === []) {
|
||||
$this->info('No changes.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$rows = [];
|
||||
|
||||
foreach ($changes as $change) {
|
||||
$before = is_array($change['before'] ?? null) ? $change['before'] : [];
|
||||
$after = is_array($change['after'] ?? null) ? $change['after'] : [];
|
||||
|
||||
$rows[] = [
|
||||
'applied' => ($change['applied'] ?? false) ? 'yes' : 'no',
|
||||
'operation_run_id' => (int) ($change['operation_run_id'] ?? 0),
|
||||
'type' => (string) ($change['type'] ?? ''),
|
||||
'source_id' => (int) ($change['restore_run_id'] ?? 0),
|
||||
'before' => (string) (($before['status'] ?? '').'/'.($before['outcome'] ?? '')),
|
||||
'after' => (string) (($after['status'] ?? '').'/'.($after['outcome'] ?? '')),
|
||||
];
|
||||
}
|
||||
|
||||
$this->table(
|
||||
['applied', 'operation_run_id', 'type', 'source_id', 'before', 'after'],
|
||||
$rows,
|
||||
);
|
||||
|
||||
return self::SUCCESS;
|
||||
} catch (Throwable $e) {
|
||||
$this->error('Reconciliation failed: '.$e->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -7,7 +7,7 @@
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\RestoreRun;
|
||||
@ -88,7 +88,7 @@ public function handle(): int
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
BulkOperationRun::query()
|
||||
OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
@ -152,7 +152,7 @@ private function countsForTenant(Tenant $tenant): array
|
||||
return [
|
||||
'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'bulk_operation_runs' => BulkOperationRun::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'restore_runs' => RestoreRun::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||
|
||||
@ -5,8 +5,8 @@
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||
@ -18,7 +18,7 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||
|
||||
protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.';
|
||||
|
||||
public function handle(OperationRunService $operationRunService, BulkOperationService $bulkOperationService): int
|
||||
public function handle(OperationRunService $operationRunService): int
|
||||
{
|
||||
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
||||
@ -70,8 +70,8 @@ public function handle(OperationRunService $operationRunService, BulkOperationSe
|
||||
outcome: 'failed',
|
||||
failures: [
|
||||
[
|
||||
'code' => 'RUN_NOT_FOUND',
|
||||
'message' => $bulkOperationService->sanitizeFailureReason('Backup schedule run not found.'),
|
||||
'code' => 'backup_schedule_run.not_found',
|
||||
'message' => RunFailureSanitizer::sanitizeMessage('Backup schedule run not found.'),
|
||||
],
|
||||
],
|
||||
);
|
||||
@ -99,31 +99,45 @@ public function handle(OperationRunService $operationRunService, BulkOperationSe
|
||||
$outcome = match ($scheduleRun->status) {
|
||||
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
|
||||
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
|
||||
BackupScheduleRun::STATUS_SKIPPED,
|
||||
BackupScheduleRun::STATUS_CANCELED => 'cancelled',
|
||||
BackupScheduleRun::STATUS_SKIPPED => 'succeeded',
|
||||
BackupScheduleRun::STATUS_CANCELED => 'failed',
|
||||
default => 'failed',
|
||||
};
|
||||
|
||||
$summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : [];
|
||||
$syncFailures = $summary['sync_failures'] ?? [];
|
||||
|
||||
$summaryCounts = [
|
||||
'backup_schedule_id' => (int) $scheduleRun->backup_schedule_id,
|
||||
'backup_schedule_run_id' => (int) $scheduleRun->getKey(),
|
||||
'backup_set_id' => $scheduleRun->backup_set_id ? (int) $scheduleRun->backup_set_id : null,
|
||||
'policies_total' => (int) ($summary['policies_total'] ?? 0),
|
||||
'policies_backed_up' => (int) ($summary['policies_backed_up'] ?? 0),
|
||||
'sync_failures' => is_array($syncFailures) ? count($syncFailures) : 0,
|
||||
];
|
||||
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
||||
$policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0);
|
||||
$syncFailuresCount = is_array($syncFailures) ? count($syncFailures) : 0;
|
||||
|
||||
$summaryCounts = array_filter($summaryCounts, fn (mixed $value): bool => $value !== null);
|
||||
$processed = $policiesBackedUp + $syncFailuresCount;
|
||||
if ($policiesTotal > 0) {
|
||||
$processed = min($policiesTotal, $processed);
|
||||
}
|
||||
|
||||
$summaryCounts = array_filter([
|
||||
'total' => $policiesTotal,
|
||||
'processed' => $processed,
|
||||
'succeeded' => $policiesBackedUp,
|
||||
'failed' => $syncFailuresCount,
|
||||
'skipped' => $scheduleRun->status === BackupScheduleRun::STATUS_SKIPPED ? 1 : 0,
|
||||
'items' => $policiesTotal,
|
||||
], fn (mixed $value): bool => is_int($value) && $value !== 0);
|
||||
|
||||
$failures = [];
|
||||
|
||||
if ($scheduleRun->status === BackupScheduleRun::STATUS_CANCELED) {
|
||||
$failures[] = [
|
||||
'code' => 'backup_schedule_run.cancelled',
|
||||
'message' => 'Backup schedule run was cancelled.',
|
||||
];
|
||||
}
|
||||
|
||||
if (filled($scheduleRun->error_message) || filled($scheduleRun->error_code)) {
|
||||
$failures[] = [
|
||||
'code' => (string) ($scheduleRun->error_code ?: 'BACKUP_SCHEDULE_ERROR'),
|
||||
'message' => $bulkOperationService->sanitizeFailureReason((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')),
|
||||
'code' => (string) ($scheduleRun->error_code ?: 'backup_schedule_run.error'),
|
||||
'message' => RunFailureSanitizer::sanitizeMessage((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')),
|
||||
];
|
||||
}
|
||||
|
||||
@ -151,8 +165,8 @@ public function handle(OperationRunService $operationRunService, BulkOperationSe
|
||||
}
|
||||
|
||||
$failures[] = [
|
||||
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR',
|
||||
'message' => $bulkOperationService->sanitizeFailureReason($message),
|
||||
'code' => $status !== null ? "graph.http_{$status}" : 'graph.error',
|
||||
'message' => RunFailureSanitizer::sanitizeMessage($message),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,19 +5,17 @@
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\InventorySyncRunResource;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Drift\DriftRunSelector;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\RunIdempotency;
|
||||
use BackedEnum;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -50,8 +48,6 @@ class DriftLanding extends Page
|
||||
|
||||
public ?int $operationRunId = null;
|
||||
|
||||
public ?int $bulkOperationRunId = null;
|
||||
|
||||
/** @var array<string, int>|null */
|
||||
public ?array $statusCounts = null;
|
||||
|
||||
@ -118,17 +114,6 @@ public function mount(): void
|
||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
||||
}
|
||||
|
||||
$idempotencyKey = RunIdempotency::buildKey(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
operationType: 'drift.generate',
|
||||
targetId: $scopeKey,
|
||||
context: [
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => (int) $baseline->getKey(),
|
||||
'current_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
);
|
||||
|
||||
$exists = Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
@ -153,48 +138,39 @@ public function mount(): void
|
||||
return;
|
||||
}
|
||||
|
||||
$latestRun = BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->latest('id')
|
||||
->first();
|
||||
$existingOperationRun?->refresh();
|
||||
|
||||
$activeRun = RunIdempotency::findActiveBulkOperationRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
if ($activeRun instanceof BulkOperationRun) {
|
||||
if ($existingOperationRun instanceof OperationRun
|
||||
&& in_array($existingOperationRun->status, ['queued', 'running'], true)
|
||||
) {
|
||||
$this->state = 'generating';
|
||||
$this->bulkOperationRunId = (int) $activeRun->getKey();
|
||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($latestRun instanceof BulkOperationRun && $latestRun->status === 'completed') {
|
||||
$this->state = 'ready';
|
||||
$this->bulkOperationRunId = (int) $latestRun->getKey();
|
||||
if ($existingOperationRun instanceof OperationRun
|
||||
&& $existingOperationRun->status === 'completed'
|
||||
) {
|
||||
$counts = is_array($existingOperationRun->summary_counts ?? null) ? $existingOperationRun->summary_counts : [];
|
||||
$created = (int) ($counts['created'] ?? 0);
|
||||
|
||||
$newCount = (int) Finding::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('scope_key', $scopeKey)
|
||||
->where('baseline_run_id', $baseline->getKey())
|
||||
->where('current_run_id', $current->getKey())
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->count();
|
||||
if ($existingOperationRun->outcome === 'failed') {
|
||||
$this->state = 'error';
|
||||
$this->message = 'Drift generation failed for this comparison. See the run for details.';
|
||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
||||
|
||||
$this->statusCounts = [Finding::STATUS_NEW => $newCount];
|
||||
|
||||
if ($newCount === 0) {
|
||||
$this->message = 'No drift findings for this comparison. If you changed settings after the current run, run Inventory Sync again to capture a newer snapshot.';
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if ($created === 0) {
|
||||
$this->state = 'ready';
|
||||
$this->statusCounts = [Finding::STATUS_NEW => 0];
|
||||
$this->message = 'No drift findings for this comparison. If you changed settings after the current run, run Inventory Sync again to capture a newer snapshot.';
|
||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
||||
|
||||
if ($latestRun instanceof BulkOperationRun && in_array($latestRun->status, ['failed', 'aborted'], true)) {
|
||||
$this->state = 'error';
|
||||
$this->message = 'Drift generation failed for this comparison. See the run for details.';
|
||||
$this->bulkOperationRunId = (int) $latestRun->getKey();
|
||||
|
||||
return;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (! $user->canSyncTenant($tenant)) {
|
||||
@ -204,73 +180,60 @@ public function mount(): void
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Phase 3: Canonical Operation Run Start ---
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromQuery([
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => (int) $baseline->getKey(),
|
||||
'current_run_id' => (int) $current->getKey(),
|
||||
]);
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
|
||||
$opRun = $opService->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'drift.generate',
|
||||
inputs: [
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $baseline, $current, $scopeKey): void {
|
||||
GenerateDriftFindingsJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
baselineRunId: (int) $baseline->getKey(),
|
||||
currentRunId: (int) $current->getKey(),
|
||||
scopeKey: $scopeKey,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => (int) $baseline->getKey(),
|
||||
'current_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
initiator: $user
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
$this->operationRunId = (int) $opRun->getKey();
|
||||
$this->state = 'generating';
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
||||
$this->state = 'generating'; // Reflect generating state in UI if idempotency hit
|
||||
// Optionally, we could find the related BulkOpRun to link, but the UI might just need state.
|
||||
|
||||
if (! $opRun->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->title('Drift generation already active')
|
||||
->body('This operation is already queued or running.')
|
||||
->warning()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View Run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
// ----------------------------------------------
|
||||
|
||||
$bulkOperationService = app(BulkOperationService::class);
|
||||
$run = $bulkOperationService->createRun(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
resource: 'drift',
|
||||
action: 'generate',
|
||||
itemIds: [
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => (int) $baseline->getKey(),
|
||||
'current_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
totalItems: 1,
|
||||
);
|
||||
|
||||
$run->update(['idempotency_key' => $idempotencyKey]);
|
||||
|
||||
$this->state = 'generating';
|
||||
$this->bulkOperationRunId = (int) $run->getKey();
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $baseline, $current, $scopeKey, $run, $opRun): void {
|
||||
GenerateDriftFindingsJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
baselineRunId: (int) $baseline->getKey(),
|
||||
currentRunId: (int) $current->getKey(),
|
||||
scopeKey: $scopeKey,
|
||||
bulkOperationRunId: (int) $run->getKey(),
|
||||
operationRun: $opRun
|
||||
);
|
||||
});
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Inventory\InventorySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
@ -112,7 +111,7 @@ protected function getHeaderActions(): array
|
||||
|
||||
return $user->canSyncTenant(Tenant::current());
|
||||
})
|
||||
->action(function (array $data, self $livewire, BulkOperationService $bulkOperationService, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
||||
->action(function (array $data, self $livewire, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void {
|
||||
$tenant = Tenant::current();
|
||||
|
||||
$user = auth()->user();
|
||||
@ -202,22 +201,12 @@ protected function getHeaderActions(): array
|
||||
$policyTypes = [];
|
||||
}
|
||||
|
||||
$bulkRun = $bulkOperationService->createRun(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
resource: 'inventory',
|
||||
action: 'sync',
|
||||
itemIds: $policyTypes,
|
||||
totalItems: count($policyTypes),
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'inventory.sync.dispatched',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'inventory_sync_run_id' => $run->id,
|
||||
'bulk_run_id' => $bulkRun->id,
|
||||
'selection_hash' => $run->selection_hash,
|
||||
],
|
||||
],
|
||||
@ -228,12 +217,11 @@ protected function getHeaderActions(): array
|
||||
resourceId: (string) $run->id,
|
||||
);
|
||||
|
||||
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $bulkRun, $opRun): void {
|
||||
$opService->dispatchOrFail($opRun, function () use ($tenant, $user, $run, $opRun): void {
|
||||
RunInventorySyncJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
inventorySyncRunId: (int) $run->id,
|
||||
bulkRunId: (int) $bulkRun->getKey(),
|
||||
operationRun: $opRun
|
||||
);
|
||||
});
|
||||
|
||||
@ -13,7 +13,6 @@
|
||||
use App\Rules\SupportedPolicyTypesRule;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
@ -397,23 +396,8 @@ public static function table(Table $table): Table
|
||||
],
|
||||
);
|
||||
|
||||
$bulkRunId = null;
|
||||
|
||||
if ($userModel instanceof User) {
|
||||
$bulkRunId = app(BulkOperationService::class)
|
||||
->createRun(
|
||||
tenant: $tenant,
|
||||
user: $userModel,
|
||||
resource: 'backup_schedule',
|
||||
action: 'run',
|
||||
itemIds: [(string) $record->id],
|
||||
totalItems: 1,
|
||||
)
|
||||
->id;
|
||||
}
|
||||
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRunId, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId, $operationRun));
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
||||
});
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
@ -536,23 +520,8 @@ public static function table(Table $table): Table
|
||||
],
|
||||
);
|
||||
|
||||
$bulkRunId = null;
|
||||
|
||||
if ($userModel instanceof User) {
|
||||
$bulkRunId = app(BulkOperationService::class)
|
||||
->createRun(
|
||||
tenant: $tenant,
|
||||
user: $userModel,
|
||||
resource: 'backup_schedule',
|
||||
action: 'retry',
|
||||
itemIds: [(string) $record->id],
|
||||
totalItems: 1,
|
||||
)
|
||||
->id;
|
||||
}
|
||||
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRunId, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRunId, $operationRun));
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
||||
});
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
@ -591,16 +560,6 @@ public static function table(Table $table): Table
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
|
||||
$bulkRun = null;
|
||||
if ($user) {
|
||||
$bulkRun = app(\App\Services\BulkOperationService::class)->createRun(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
resource: 'backup_schedule',
|
||||
action: 'run',
|
||||
itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(),
|
||||
totalItems: $records->count(),
|
||||
);
|
||||
}
|
||||
|
||||
$createdRunIds = [];
|
||||
|
||||
@ -678,13 +637,12 @@ public static function table(Table $table): Table
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'trigger' => 'bulk_run_now',
|
||||
'bulk_run_id' => $bulkRun?->id,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun));
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
||||
}, emitQueuedNotification: false);
|
||||
}
|
||||
|
||||
@ -731,16 +689,6 @@ public static function table(Table $table): Table
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
|
||||
$bulkRun = null;
|
||||
if ($user) {
|
||||
$bulkRun = app(\App\Services\BulkOperationService::class)->createRun(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
resource: 'backup_schedule',
|
||||
action: 'retry',
|
||||
itemIds: $records->pluck('id')->map(fn (mixed $id): int => (int) $id)->values()->all(),
|
||||
totalItems: $records->count(),
|
||||
);
|
||||
}
|
||||
|
||||
$createdRunIds = [];
|
||||
|
||||
@ -818,13 +766,12 @@ public static function table(Table $table): Table
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
'trigger' => 'bulk_retry',
|
||||
'bulk_run_id' => $bulkRun?->id,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun));
|
||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
||||
}, emitQueuedNotification: false);
|
||||
}
|
||||
|
||||
|
||||
@ -9,9 +9,12 @@
|
||||
use App\Jobs\BulkBackupSetRestoreJob;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
@ -198,22 +201,48 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'backup_set', 'delete', $ids, $count);
|
||||
|
||||
if ($count >= 10) {
|
||||
OperationUxPresenter::queuedToast('backup_set.delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
BulkBackupSetDeleteJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkBackupSetDeleteJob::dispatchSync($run->id);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
BulkBackupSetDeleteJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
backupSetIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'backup_set_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast('backup_set.delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
@ -238,22 +267,48 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'backup_set', 'restore', $ids, $count);
|
||||
|
||||
if ($count >= 10) {
|
||||
OperationUxPresenter::queuedToast('backup_set.restore')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
BulkBackupSetRestoreJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkBackupSetRestoreJob::dispatchSync($run->id);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.restore',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
BulkBackupSetRestoreJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
backupSetIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'backup_set_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast('backup_set.restore')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
@ -293,22 +348,48 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'backup_set', 'force_delete', $ids, $count);
|
||||
|
||||
if ($count >= 10) {
|
||||
OperationUxPresenter::queuedToast('backup_set.force_delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
BulkBackupSetForceDeleteJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkBackupSetForceDeleteJob::dispatchSync($run->id);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.force_delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
BulkBackupSetForceDeleteJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
backupSetIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'backup_set_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast('backup_set.force_delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
]),
|
||||
|
||||
@ -1,388 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\BulkOperationRunResource\Pages;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Forms\Components\DatePicker;
|
||||
use Filament\Infolists\Components\TextEntry;
|
||||
use Filament\Infolists\Components\ViewEntry;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use UnitEnum;
|
||||
|
||||
class BulkOperationRunResource extends Resource
|
||||
{
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = BulkOperationRun::class;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Monitoring';
|
||||
|
||||
protected static ?string $navigationLabel = 'Operations';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return $schema;
|
||||
}
|
||||
|
||||
public static function infolist(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->schema([
|
||||
Section::make('Legacy run view')
|
||||
->description('Canonical monitoring is now available in Monitoring → Operations.')
|
||||
->schema([
|
||||
TextEntry::make('canonical_view')
|
||||
->label('Canonical view')
|
||||
->state('View in Operations')
|
||||
->url(fn (BulkOperationRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
|
||||
->badge()
|
||||
->color('primary'),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Run')
|
||||
->schema([
|
||||
TextEntry::make('user.name')
|
||||
->label('Initiator')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('resource')->badge(),
|
||||
TextEntry::make('action')->badge(),
|
||||
TextEntry::make('status')
|
||||
->label('Outcome')
|
||||
->badge()
|
||||
->state(fn (BulkOperationRun $record): string => $record->statusBucket())
|
||||
->color(fn (BulkOperationRun $record): string => static::statusBucketColor($record->statusBucket())),
|
||||
TextEntry::make('total_items')->label('Total')->numeric(),
|
||||
TextEntry::make('processed_items')->label('Processed')->numeric(),
|
||||
TextEntry::make('succeeded')->numeric(),
|
||||
TextEntry::make('failed')->numeric(),
|
||||
TextEntry::make('skipped')->numeric(),
|
||||
TextEntry::make('created_at')->dateTime(),
|
||||
TextEntry::make('updated_at')->dateTime(),
|
||||
TextEntry::make('idempotency_key')->label('Idempotency key')->copyable()->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Related')
|
||||
->schema([
|
||||
TextEntry::make('related_backup_set')
|
||||
->label('Backup set')
|
||||
->state(function (BulkOperationRun $record): ?string {
|
||||
$backupSetId = static::backupSetIdFromItemIds($record);
|
||||
|
||||
if (! $backupSetId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return "#{$backupSetId}";
|
||||
})
|
||||
->url(function (BulkOperationRun $record): ?string {
|
||||
$backupSetId = static::backupSetIdFromItemIds($record);
|
||||
|
||||
if (! $backupSetId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return BackupSetResource::getUrl('view', ['record' => $backupSetId], tenant: Tenant::current());
|
||||
})
|
||||
->visible(fn (BulkOperationRun $record): bool => static::backupSetIdFromItemIds($record) !== null)
|
||||
->placeholder('—')
|
||||
->columnSpanFull(),
|
||||
TextEntry::make('related_drift_findings')
|
||||
->label('Drift findings')
|
||||
->state('View')
|
||||
->url(function (BulkOperationRun $record): ?string {
|
||||
if ($record->runType() !== 'drift.generate') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $record->item_ids ?? [];
|
||||
if (! is_array($payload)) {
|
||||
return FindingResource::getUrl('index', tenant: Tenant::current());
|
||||
}
|
||||
|
||||
$scopeKey = null;
|
||||
$baselineRunId = null;
|
||||
$currentRunId = null;
|
||||
|
||||
if (array_is_list($payload) && isset($payload[0]) && is_string($payload[0])) {
|
||||
$scopeKey = $payload[0];
|
||||
} else {
|
||||
$scopeKey = is_string($payload['scope_key'] ?? null) ? $payload['scope_key'] : null;
|
||||
|
||||
if (is_numeric($payload['baseline_run_id'] ?? null)) {
|
||||
$baselineRunId = (int) $payload['baseline_run_id'];
|
||||
}
|
||||
|
||||
if (is_numeric($payload['current_run_id'] ?? null)) {
|
||||
$currentRunId = (int) $payload['current_run_id'];
|
||||
}
|
||||
}
|
||||
|
||||
$tableFilters = [];
|
||||
|
||||
if (is_string($scopeKey) && $scopeKey !== '') {
|
||||
$tableFilters['scope_key'] = ['scope_key' => $scopeKey];
|
||||
}
|
||||
|
||||
if (is_int($baselineRunId) || is_int($currentRunId)) {
|
||||
$tableFilters['run_ids'] = [
|
||||
'baseline_run_id' => $baselineRunId,
|
||||
'current_run_id' => $currentRunId,
|
||||
];
|
||||
}
|
||||
|
||||
$parameters = $tableFilters !== [] ? ['tableFilters' => $tableFilters] : [];
|
||||
|
||||
return FindingResource::getUrl('index', $parameters, tenant: Tenant::current());
|
||||
})
|
||||
->visible(fn (BulkOperationRun $record): bool => $record->runType() === 'drift.generate')
|
||||
->placeholder('—')
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->visible(fn (BulkOperationRun $record): bool => in_array($record->runType(), ['backup_set.add_policies', 'drift.generate'], true))
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Items')
|
||||
->schema([
|
||||
ViewEntry::make('item_ids')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (BulkOperationRun $record) => $record->item_ids ?? [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Failures')
|
||||
->schema([
|
||||
ViewEntry::make('failures')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (BulkOperationRun $record) => $record->failures ?? [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->modifyQueryUsing(function (Builder $query): Builder {
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
return $query->when($tenantId, fn (Builder $q) => $q->where('tenant_id', $tenantId));
|
||||
})
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
->label('Initiator')
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('resource')->badge(),
|
||||
Tables\Columns\TextColumn::make('action')->badge(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->label('Outcome')
|
||||
->badge()
|
||||
->formatStateUsing(fn (BulkOperationRun $record): string => $record->statusBucket())
|
||||
->color(fn (BulkOperationRun $record): string => static::statusBucketColor($record->statusBucket())),
|
||||
Tables\Columns\TextColumn::make('created_at')->since(),
|
||||
Tables\Columns\TextColumn::make('total_items')->label('Total')->numeric(),
|
||||
Tables\Columns\TextColumn::make('processed_items')->label('Processed')->numeric(),
|
||||
Tables\Columns\TextColumn::make('failed')->numeric(),
|
||||
])
|
||||
->filters([
|
||||
Tables\Filters\SelectFilter::make('run_type')
|
||||
->label('Run type')
|
||||
->options(fn (): array => static::runTypeOptions())
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if (! is_string($value) || $value === '' || ! str_contains($value, '.')) {
|
||||
return $query;
|
||||
}
|
||||
|
||||
[$resource, $action] = explode('.', $value, 2);
|
||||
|
||||
if ($resource === '' || $action === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
return $query
|
||||
->where('resource', $resource)
|
||||
->where('action', $action);
|
||||
}),
|
||||
Tables\Filters\SelectFilter::make('status_bucket')
|
||||
->label('Status')
|
||||
->options([
|
||||
'queued' => 'Queued',
|
||||
'running' => 'Running',
|
||||
'succeeded' => 'Succeeded',
|
||||
'partially succeeded' => 'Partially succeeded',
|
||||
'failed' => 'Failed',
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$value = $data['value'] ?? null;
|
||||
|
||||
if (! is_string($value) || $value === '') {
|
||||
return $query;
|
||||
}
|
||||
|
||||
$nonSkippedFailureSql = "EXISTS (SELECT 1 FROM jsonb_array_elements(COALESCE(failures, '[]'::jsonb)) AS elem WHERE (elem->>'type' IS NULL OR elem->>'type' <> 'skipped'))";
|
||||
|
||||
return match ($value) {
|
||||
'queued' => $query->where('status', 'pending'),
|
||||
'running' => $query->where('status', 'running'),
|
||||
'succeeded' => $query
|
||||
->whereIn('status', ['completed', 'completed_with_errors'])
|
||||
->where('failed', 0)
|
||||
->whereRaw("NOT {$nonSkippedFailureSql}"),
|
||||
'partially succeeded' => $query
|
||||
->whereNotIn('status', ['pending', 'running'])
|
||||
->where('succeeded', '>', 0)
|
||||
->where(function (Builder $q) use ($nonSkippedFailureSql): void {
|
||||
$q->where('failed', '>', 0)->orWhereRaw($nonSkippedFailureSql);
|
||||
}),
|
||||
'failed' => $query
|
||||
->whereNotIn('status', ['pending', 'running'])
|
||||
->where(function (Builder $q) use ($nonSkippedFailureSql): void {
|
||||
$q->where(function (Builder $q) use ($nonSkippedFailureSql): void {
|
||||
$q->where('succeeded', 0)
|
||||
->where(function (Builder $q) use ($nonSkippedFailureSql): void {
|
||||
$q->where('failed', '>', 0)->orWhereRaw($nonSkippedFailureSql);
|
||||
});
|
||||
})->orWhere(function (Builder $q) use ($nonSkippedFailureSql): void {
|
||||
$q->whereIn('status', ['failed', 'aborted'])
|
||||
->whereNot(function (Builder $q) use ($nonSkippedFailureSql): void {
|
||||
$q->where('succeeded', '>', 0)
|
||||
->where(function (Builder $q) use ($nonSkippedFailureSql): void {
|
||||
$q->where('failed', '>', 0)->orWhereRaw($nonSkippedFailureSql);
|
||||
});
|
||||
});
|
||||
});
|
||||
}),
|
||||
default => $query,
|
||||
};
|
||||
}),
|
||||
Tables\Filters\Filter::make('created_at')
|
||||
->label('Created')
|
||||
->form([
|
||||
DatePicker::make('created_from')
|
||||
->label('From')
|
||||
->default(fn () => now()->subDays(30)),
|
||||
DatePicker::make('created_until')
|
||||
->label('Until')
|
||||
->default(fn () => now()),
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$from = $data['created_from'] ?? null;
|
||||
if ($from) {
|
||||
$query->whereDate('created_at', '>=', $from);
|
||||
}
|
||||
|
||||
$until = $data['created_until'] ?? null;
|
||||
if ($until) {
|
||||
$query->whereDate('created_at', '<=', $until);
|
||||
}
|
||||
|
||||
return $query;
|
||||
}),
|
||||
])
|
||||
->actions([
|
||||
Actions\ViewAction::make(),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
->with('user')
|
||||
->latest('id');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListBulkOperationRuns::route('/'),
|
||||
'view' => Pages\ViewBulkOperationRun::route('/{record}'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private static function runTypeOptions(): array
|
||||
{
|
||||
$tenantId = Tenant::current()->getKey();
|
||||
|
||||
$knownTypes = [
|
||||
'drift.generate' => 'drift.generate',
|
||||
'backup_set.add_policies' => 'backup_set.add_policies',
|
||||
];
|
||||
|
||||
$storedTypes = BulkOperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->select(['resource', 'action'])
|
||||
->distinct()
|
||||
->orderBy('resource')
|
||||
->orderBy('action')
|
||||
->get()
|
||||
->mapWithKeys(function (BulkOperationRun $run): array {
|
||||
$type = "{$run->resource}.{$run->action}";
|
||||
|
||||
return [$type => $type];
|
||||
})
|
||||
->all();
|
||||
|
||||
return array_replace($storedTypes, $knownTypes);
|
||||
}
|
||||
|
||||
private static function statusBucketColor(string $statusBucket): string
|
||||
{
|
||||
return match ($statusBucket) {
|
||||
'succeeded' => 'success',
|
||||
'partially succeeded' => 'warning',
|
||||
'failed' => 'danger',
|
||||
'running' => 'info',
|
||||
'queued' => 'gray',
|
||||
default => 'gray',
|
||||
};
|
||||
}
|
||||
|
||||
private static function backupSetIdFromItemIds(BulkOperationRun $record): ?int
|
||||
{
|
||||
if ($record->runType() !== 'backup_set.add_policies') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$payload = $record->item_ids ?? [];
|
||||
if (! is_array($payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$backupSetId = $payload['backup_set_id'] ?? null;
|
||||
if (! is_numeric($backupSetId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$backupSetId = (int) $backupSetId;
|
||||
|
||||
return $backupSetId > 0 ? $backupSetId : null;
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BulkOperationRunResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BulkOperationRunResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListBulkOperationRuns extends ListRecords
|
||||
{
|
||||
protected static string $resource = BulkOperationRunResource::class;
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BulkOperationRunResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BulkOperationRunResource;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewBulkOperationRun extends ViewRecord
|
||||
{
|
||||
protected static string $resource = BulkOperationRunResource::class;
|
||||
}
|
||||
@ -11,9 +11,9 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\PolicyNormalizer;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -458,10 +458,51 @@ public static function table(Table $table): Table
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'policy', 'export', [$record->id], 1);
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']);
|
||||
$ids = [(int) $record->getKey()];
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy.export',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data): void {
|
||||
BulkPolicyExportJob::dispatchSync(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $ids,
|
||||
backupName: (string) $data['backup_name'],
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'backup_name' => (string) $data['backup_name'],
|
||||
'policy_count' => 1,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
}),
|
||||
])->icon('heroicon-o-ellipsis-vertical'),
|
||||
])
|
||||
@ -499,24 +540,54 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'policy', 'delete', $ids, $count);
|
||||
|
||||
if ($count >= 20) {
|
||||
Notification::make()
|
||||
->title('Bulk delete started')
|
||||
->body("Deleting {$count} policies in the background. Check the progress bar in the bottom right corner.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
|
||||
BulkPolicyDeleteJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkPolicyDeleteJob::dispatchSync($run->id);
|
||||
if (! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy.delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids): void {
|
||||
BulkPolicyDeleteJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'policy_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title('Policy delete queued')
|
||||
->body("Queued deletion for {$count} policies.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
@ -537,8 +608,50 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'policy', 'unignore', $ids, $count);
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy.unignore',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $count): void {
|
||||
if ($count >= 20) {
|
||||
BulkPolicyUnignoreJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
BulkPolicyUnignoreJob::dispatchSync(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'policy_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
if ($count >= 20) {
|
||||
Notification::make()
|
||||
@ -550,11 +663,17 @@ public static function table(Table $table): Table
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
|
||||
BulkPolicyUnignoreJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkPolicyUnignoreJob::dispatchSync($run->id);
|
||||
}
|
||||
|
||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
@ -660,8 +779,53 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'policy', 'export', $ids, $count);
|
||||
if (! $user instanceof User) {
|
||||
abort(403);
|
||||
}
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy.export',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $ids, $data, $count): void {
|
||||
if ($count >= 20) {
|
||||
BulkPolicyExportJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $ids,
|
||||
backupName: (string) $data['backup_name'],
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
BulkPolicyExportJob::dispatchSync(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $ids,
|
||||
backupName: (string) $data['backup_name'],
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'backup_name' => (string) $data['backup_name'],
|
||||
'policy_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
if ($count >= 20) {
|
||||
Notification::make()
|
||||
@ -673,11 +837,15 @@ public static function table(Table $table): Table
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
|
||||
BulkPolicyExportJob::dispatch($run->id, $data['backup_name']);
|
||||
} else {
|
||||
BulkPolicyExportJob::dispatchSync($run->id, $data['backup_name']);
|
||||
}
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
]),
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
|
||||
namespace App\Filament\Resources\PolicyResource\Pages;
|
||||
|
||||
use App\Filament\Resources\BulkOperationRunResource;
|
||||
use App\Filament\Resources\PolicyResource;
|
||||
use App\Jobs\CapturePolicySnapshotJob;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\RunIdempotency;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Forms;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -54,64 +54,67 @@ protected function getActions(): array
|
||||
return;
|
||||
}
|
||||
|
||||
$idempotencyKey = RunIdempotency::buildKey(
|
||||
tenantId: $tenant->getKey(),
|
||||
operationType: 'policy.capture_snapshot',
|
||||
targetId: $policy->getKey()
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds([(string) $policy->getKey()]);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy.capture_snapshot',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $policy, $user, $data): void {
|
||||
CapturePolicySnapshotJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyId: (int) $policy->getKey(),
|
||||
includeAssignments: (bool) ($data['include_assignments'] ?? false),
|
||||
includeScopeTags: (bool) ($data['include_scope_tags'] ?? false),
|
||||
createdBy: $user->email ? Str::limit($user->email, 255, '') : null,
|
||||
operationRun: $operationRun,
|
||||
context: [],
|
||||
);
|
||||
},
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'policy_id' => (int) $policy->getKey(),
|
||||
'include_assignments' => (bool) ($data['include_assignments'] ?? false),
|
||||
'include_scope_tags' => (bool) ($data['include_scope_tags'] ?? false),
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
$existingRun = RunIdempotency::findActiveBulkOperationRun(
|
||||
tenantId: $tenant->getKey(),
|
||||
idempotencyKey: $idempotencyKey
|
||||
);
|
||||
|
||||
if ($existingRun) {
|
||||
if (! $opRun->wasRecentlyCreated) {
|
||||
Notification::make()
|
||||
->title('Snapshot already in progress')
|
||||
->body('An active run already exists for this policy. Opening run details.')
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)),
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->info()
|
||||
->send();
|
||||
|
||||
$this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant));
|
||||
$this->redirect(OperationRunLinks::view($opRun, $tenant));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$bulkOperationService = app(BulkOperationService::class);
|
||||
|
||||
$run = $bulkOperationService->createRun(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
resource: 'policies',
|
||||
action: 'capture_snapshot',
|
||||
itemIds: [(string) $policy->getKey()],
|
||||
totalItems: 1
|
||||
);
|
||||
|
||||
$run->update(['idempotency_key' => $idempotencyKey]);
|
||||
|
||||
CapturePolicySnapshotJob::dispatch(
|
||||
bulkOperationRunId: $run->getKey(),
|
||||
policyId: $policy->getKey(),
|
||||
includeAssignments: (bool) ($data['include_assignments'] ?? false),
|
||||
includeScopeTags: (bool) ($data['include_scope_tags'] ?? false),
|
||||
createdBy: $user->email ? Str::limit($user->email, 255, '') : null
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast('policy.capture_snapshot')
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
$this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant));
|
||||
$this->redirect(OperationRunLinks::view($opRun, $tenant));
|
||||
})
|
||||
->color('primary'),
|
||||
];
|
||||
|
||||
@ -10,10 +10,13 @@
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\PolicyNormalizer;
|
||||
use App\Services\Intune\VersionDiff;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use BackedEnum;
|
||||
use Carbon\CarbonImmutable;
|
||||
@ -406,21 +409,58 @@ public static function table(Table $table): Table
|
||||
|
||||
$retentionDays = (int) ($data['retention_days'] ?? 90);
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'policy_version', 'prune', $ids, $count);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($count >= 20) {
|
||||
OperationUxPresenter::queuedToast('policy_version.prune')
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy_version.prune',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids, $retentionDays): void {
|
||||
BulkPolicyVersionPruneJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
policyVersionIds: $ids,
|
||||
retentionDays: $retentionDays,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'policy_version_count' => $count,
|
||||
'retention_days' => $retentionDays,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
if ($initiator instanceof User) {
|
||||
Notification::make()
|
||||
->title('Policy version prune queued')
|
||||
->body("Queued prune for {$count} policy versions.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->duration(8000)
|
||||
->sendToDatabase($initiator)
|
||||
->send();
|
||||
|
||||
BulkPolicyVersionPruneJob::dispatch($run->id, $retentionDays);
|
||||
} else {
|
||||
BulkPolicyVersionPruneJob::dispatchSync($run->id, $retentionDays);
|
||||
}
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
@ -446,22 +486,48 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'policy_version', 'restore', $ids, $count);
|
||||
|
||||
if ($count >= 20) {
|
||||
OperationUxPresenter::queuedToast('policy_version.restore')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
||||
])
|
||||
->send();
|
||||
|
||||
BulkPolicyVersionRestoreJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkPolicyVersionRestoreJob::dispatchSync($run->id);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy_version.restore',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
BulkPolicyVersionRestoreJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
policyVersionIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'policy_version_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast('policy_version.restore')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
@ -495,21 +561,56 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', $ids, $count);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($count >= 20) {
|
||||
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'policy_version.force_delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
BulkPolicyVersionForceDeleteJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
policyVersionIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'policy_version_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
if ($initiator instanceof User) {
|
||||
Notification::make()
|
||||
->title('Policy version force delete queued')
|
||||
->body("Queued force delete for {$count} policy versions.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->duration(8000)
|
||||
->sendToDatabase($initiator)
|
||||
->send();
|
||||
|
||||
BulkPolicyVersionForceDeleteJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkPolicyVersionForceDeleteJob::dispatchSync($run->id);
|
||||
}
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
@ -12,17 +12,20 @@
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Rules\SkipOrUuidRule;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\RestoreDiffGenerator;
|
||||
use App\Services\Intune\RestoreRiskChecker;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\RestoreRunIdempotency;
|
||||
use App\Support\RestoreRunStatus;
|
||||
use App\Support\RunIdempotency;
|
||||
use BackedEnum;
|
||||
use Filament\Actions;
|
||||
use Filament\Actions\ActionGroup;
|
||||
@ -780,14 +783,14 @@ public static function table(Table $table): Table
|
||||
'rerun_of_restore_run_id' => $record->id,
|
||||
];
|
||||
|
||||
$idempotencyKey = RunIdempotency::restoreExecuteKey(
|
||||
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
backupSetId: (int) $backupSet->getKey(),
|
||||
selectedItemIds: $selectedItemIds,
|
||||
groupMapping: $groupMapping,
|
||||
);
|
||||
|
||||
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
|
||||
if ($existing) {
|
||||
Notification::make()
|
||||
@ -813,7 +816,7 @@ public static function table(Table $table): Table
|
||||
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
||||
]);
|
||||
} catch (QueryException $exception) {
|
||||
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
|
||||
if ($existing) {
|
||||
Notification::make()
|
||||
@ -1032,24 +1035,48 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'restore_run', 'delete', $ids, $count);
|
||||
|
||||
if ($count >= 20) {
|
||||
Notification::make()
|
||||
->title('Bulk delete started')
|
||||
->body("Deleting {$count} restore runs in the background. Check the progress bar in the bottom right corner.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
|
||||
BulkRestoreRunDeleteJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkRestoreRunDeleteJob::dispatchSync($run->id);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'restore_run.delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $initiator, $ids): void {
|
||||
BulkRestoreRunDeleteJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
restoreRunIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'restore_run_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast('restore_run.delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
@ -1074,24 +1101,59 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'restore_run', 'restore', $ids, $count);
|
||||
|
||||
if ($count >= 20) {
|
||||
Notification::make()
|
||||
->title('Bulk restore started')
|
||||
->body("Restoring {$count} restore runs in the background. Check the progress bar in the bottom right corner.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
|
||||
BulkRestoreRunRestoreJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkRestoreRunRestoreJob::dispatchSync($run->id);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'restore_run.restore',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void {
|
||||
if ($count >= 20) {
|
||||
BulkRestoreRunRestoreJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
restoreRunIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
BulkRestoreRunRestoreJob::dispatchSync(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
restoreRunIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'restore_run_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast('restore_run.restore')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
|
||||
@ -1125,24 +1187,59 @@ public static function table(Table $table): Table
|
||||
$count = $records->count();
|
||||
$ids = $records->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'restore_run', 'force_delete', $ids, $count);
|
||||
|
||||
if ($count >= 20) {
|
||||
Notification::make()
|
||||
->title('Bulk force delete started')
|
||||
->body("Force deleting {$count} restore runs in the background. Check the progress bar in the bottom right corner.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->info()
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
|
||||
BulkRestoreRunForceDeleteJob::dispatch($run->id);
|
||||
} else {
|
||||
BulkRestoreRunForceDeleteJob::dispatchSync($run->id);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$initiator = $user instanceof User ? $user : null;
|
||||
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'restore_run.force_delete',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($count, $tenant, $initiator, $ids): void {
|
||||
if ($count >= 20) {
|
||||
BulkRestoreRunForceDeleteJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
restoreRunIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
BulkRestoreRunForceDeleteJob::dispatchSync(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) ($initiator?->getKey() ?? 0),
|
||||
restoreRunIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $initiator,
|
||||
extraContext: [
|
||||
'restore_run_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
OperationUxPresenter::queuedToast('restore_run.force_delete')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->send();
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
]),
|
||||
@ -1484,14 +1581,14 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
$metadata['preview_ran_at'] = $previewRanAt;
|
||||
}
|
||||
|
||||
$idempotencyKey = RunIdempotency::restoreExecuteKey(
|
||||
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
backupSetId: (int) $backupSet->getKey(),
|
||||
selectedItemIds: $selectedItemIds,
|
||||
groupMapping: $groupMapping,
|
||||
);
|
||||
|
||||
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
|
||||
if ($existing) {
|
||||
Notification::make()
|
||||
@ -1517,7 +1614,7 @@ public static function createRestoreRun(array $data): RestoreRun
|
||||
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
||||
]);
|
||||
} catch (QueryException $exception) {
|
||||
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||
|
||||
if ($existing) {
|
||||
Notification::make()
|
||||
|
||||
@ -8,7 +8,6 @@
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
@ -17,6 +16,7 @@
|
||||
use App\Services\Intune\TenantConfigService;
|
||||
use App\Services\Intune\TenantPermissionService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
@ -448,49 +448,42 @@ public static function table(Table $table): Table
|
||||
$ids = $eligible->pluck('id')->toArray();
|
||||
$count = $eligible->count();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenantContext, $user, 'tenant', 'sync', $ids, $count);
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($ids);
|
||||
|
||||
foreach ($eligible as $tenant) {
|
||||
// Note: We might want canonical runs for bulk syncs too, but spec Phase 1 mentions tenant-scoped operations.
|
||||
// Bulk operation across tenants is a higher level concept.
|
||||
// Keeping it as is for now or migrating individually.
|
||||
// If we want each tenant sync to show in its Monitoring, we should create opRun for each.
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'policy.sync',
|
||||
inputs: ['scope' => 'full', 'bulk_run_id' => $run->id],
|
||||
initiator: $user
|
||||
);
|
||||
$opRun = $runs->enqueueBulkOperation(
|
||||
tenant: $tenantContext,
|
||||
type: 'tenant.sync',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenantContext->tenant_id ?? $tenantContext->external_id),
|
||||
],
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenantContext, $user, $ids): void {
|
||||
BulkTenantSyncJob::dispatch(
|
||||
tenantId: (int) $tenantContext->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
tenantIds: $ids,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'tenant_count' => $count,
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
|
||||
SyncPoliciesJob::dispatch($tenant->getKey(), null, null, $opRun);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'tenant.sync_dispatched',
|
||||
resourceType: 'tenant',
|
||||
resourceId: (string) $tenant->id,
|
||||
status: 'success',
|
||||
context: ['metadata' => ['tenant_id' => $tenant->tenant_id]],
|
||||
);
|
||||
}
|
||||
|
||||
$count = $eligible->count();
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk sync started')
|
||||
->body("Syncing {$count} tenant(s) in the background. Check the progress bar in the bottom right corner.")
|
||||
->icon('heroicon-o-arrow-path')
|
||||
->iconColor('warning')
|
||||
->success()
|
||||
->duration(8000)
|
||||
->sendToDatabase($user)
|
||||
OperationUxPresenter::queuedToast('tenant.sync')
|
||||
->actions([
|
||||
Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenantContext)),
|
||||
])
|
||||
->send();
|
||||
|
||||
BulkTenantSyncJob::dispatch($run->id);
|
||||
})
|
||||
->deselectRecordsAfterCompletion(),
|
||||
])
|
||||
|
||||
@ -2,20 +2,19 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Filament\Resources\BulkOperationRunResource;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\FoundationSnapshotService;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\Intune\SnapshotValidator;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@ -34,12 +33,17 @@ class AddPoliciesToBackupSetJob implements ShouldQueue
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<int, int> $policyIds
|
||||
* @param array{include_assignments?: bool, include_scope_tags?: bool, include_foundations?: bool} $options
|
||||
*/
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $backupSetId,
|
||||
public bool $includeAssignments,
|
||||
public bool $includeScopeTags,
|
||||
public bool $includeFoundations,
|
||||
public array $policyIds,
|
||||
public array $options,
|
||||
public string $idempotencyKey,
|
||||
?OperationRun $operationRun = null
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
@ -51,39 +55,31 @@ public function middleware(): array
|
||||
}
|
||||
|
||||
public function handle(
|
||||
BulkOperationService $bulkOperationService,
|
||||
OperationRunService $operationRunService,
|
||||
PolicyCaptureOrchestrator $captureOrchestrator,
|
||||
FoundationSnapshotService $foundationSnapshots,
|
||||
SnapshotValidator $snapshotValidator,
|
||||
): void {
|
||||
$run = BulkOperationRun::with(['tenant', 'user'])->find($this->bulkRunId);
|
||||
|
||||
if (! $run) {
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
$started = BulkOperationRun::query()
|
||||
->whereKey($run->getKey())
|
||||
->where('status', 'pending')
|
||||
->update(['status' => 'running']);
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
$initiator = User::query()->find($this->userId);
|
||||
|
||||
if ($started === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$run->refresh();
|
||||
|
||||
$tenant = $run->tenant ?? Tenant::query()->find($run->tenant_id);
|
||||
$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->markRunFailed(
|
||||
bulkOperationService: $bulkOperationService,
|
||||
run: $run,
|
||||
$this->failRun(
|
||||
operationRunService: $operationRunService,
|
||||
tenant: null,
|
||||
itemId: (string) $this->backupSetId,
|
||||
reasonCode: 'unknown',
|
||||
reason: 'Tenant not found for run.',
|
||||
code: 'tenant.not_found',
|
||||
message: 'Tenant not found for run.',
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
return;
|
||||
@ -95,49 +91,64 @@ public function handle(
|
||||
->first();
|
||||
|
||||
if (! $backupSet) {
|
||||
$this->markRunFailed(
|
||||
bulkOperationService: $bulkOperationService,
|
||||
run: $run,
|
||||
$this->failRun(
|
||||
operationRunService: $operationRunService,
|
||||
tenant: $tenant,
|
||||
itemId: (string) $this->backupSetId,
|
||||
reasonCode: 'backup_set_not_found',
|
||||
reason: 'Backup set not found.',
|
||||
code: 'backup_set.not_found',
|
||||
message: 'Backup set not found.',
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($backupSet->trashed()) {
|
||||
$this->markRunFailed(
|
||||
bulkOperationService: $bulkOperationService,
|
||||
run: $run,
|
||||
$this->failRun(
|
||||
operationRunService: $operationRunService,
|
||||
tenant: $tenant,
|
||||
itemId: (string) $backupSet->getKey(),
|
||||
reasonCode: 'backup_set_archived',
|
||||
reason: 'Backup set is archived.',
|
||||
code: 'backup_set.archived',
|
||||
message: 'Backup set is archived.',
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$policyIds = $this->extractPolicyIds($run);
|
||||
$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 === []) {
|
||||
$bulkOperationService->complete($run);
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: 'completed',
|
||||
outcome: 'failed',
|
||||
failures: [[
|
||||
'code' => 'selection.empty',
|
||||
'message' => 'No policies selected.',
|
||||
]],
|
||||
);
|
||||
|
||||
if ($this->operationRun) {
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opService->updateRun($this->operationRun, 'completed', 'succeeded', ['policy_ids' => 0]);
|
||||
}
|
||||
$this->notifyRunFailed($initiator, $tenant, 'No policies selected.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int) $run->total_items !== count($policyIds)) {
|
||||
$run->update(['total_items' => count($policyIds)]);
|
||||
}
|
||||
|
||||
$existingBackupFailures = (array) Arr::get($backupSet->metadata ?? [], 'failures', []);
|
||||
$newBackupFailures = [];
|
||||
|
||||
@ -172,14 +183,14 @@ public function handle(
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$runFailuresForOperationRun = [];
|
||||
|
||||
foreach ($policyIds as $policyId) {
|
||||
if (isset($activePolicyIdSet[$policyId])) {
|
||||
$bulkOperationService->recordSkippedWithReason(
|
||||
run: $run,
|
||||
itemId: (string) $policyId,
|
||||
reason: 'Already in backup set',
|
||||
reasonCode: 'already_in_backup_set',
|
||||
);
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -193,7 +204,11 @@ public function handle(
|
||||
$didMutateBackupSet = true;
|
||||
$backupSetItemMutations++;
|
||||
|
||||
$bulkOperationService->recordSuccess($run);
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'updated' => 1,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -203,29 +218,30 @@ public function handle(
|
||||
if (! $policy instanceof Policy) {
|
||||
$newBackupFailures[] = [
|
||||
'policy_id' => $policyId,
|
||||
'reason' => $bulkOperationService->sanitizeFailureReason('Policy not found.'),
|
||||
'reason' => RunFailureSanitizer::sanitizeMessage('Policy not found.'),
|
||||
'status' => null,
|
||||
'reason_code' => 'policy_not_found',
|
||||
];
|
||||
$didMutateBackupSet = true;
|
||||
|
||||
$bulkOperationService->recordFailure(
|
||||
run: $run,
|
||||
itemId: (string) $policyId,
|
||||
reason: 'Policy not found.',
|
||||
reasonCode: 'policy_not_found',
|
||||
);
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runFailuresForOperationRun[] = [
|
||||
'code' => 'policy.not_found',
|
||||
'message' => "Policy {$policyId} not found.",
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($policy->ignored_at) {
|
||||
$bulkOperationService->recordSkippedWithReason(
|
||||
run: $run,
|
||||
itemId: (string) $policyId,
|
||||
reason: 'Policy is ignored locally',
|
||||
reasonCode: 'policy_ignored',
|
||||
);
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -234,16 +250,16 @@ public function handle(
|
||||
$captureResult = $captureOrchestrator->capture(
|
||||
policy: $policy,
|
||||
tenant: $tenant,
|
||||
includeAssignments: $this->includeAssignments,
|
||||
includeScopeTags: $this->includeScopeTags,
|
||||
createdBy: $run->user?->email ? Str::limit($run->user->email, 255, '') : null,
|
||||
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 = $bulkOperationService->sanitizeFailureReason($throwable->getMessage());
|
||||
$reason = RunFailureSanitizer::sanitizeMessage($throwable->getMessage());
|
||||
|
||||
$newBackupFailures[] = [
|
||||
'policy_id' => $policyId,
|
||||
@ -253,12 +269,15 @@ public function handle(
|
||||
];
|
||||
$didMutateBackupSet = true;
|
||||
|
||||
$bulkOperationService->recordFailure(
|
||||
run: $run,
|
||||
itemId: (string) $policyId,
|
||||
reason: $reason,
|
||||
reasonCode: 'unknown',
|
||||
);
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runFailuresForOperationRun[] = [
|
||||
'code' => 'policy.capture_exception',
|
||||
'message' => $reason,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -267,7 +286,7 @@ public function handle(
|
||||
$failure = $captureResult['failure'];
|
||||
$status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null;
|
||||
$reasonCode = $this->mapGraphFailureReasonCode($status);
|
||||
$reason = $bulkOperationService->sanitizeFailureReason((string) ($failure['reason'] ?? 'Graph capture failed.'));
|
||||
$reason = RunFailureSanitizer::sanitizeMessage((string) ($failure['reason'] ?? 'Graph capture failed.'));
|
||||
|
||||
$newBackupFailures[] = [
|
||||
'policy_id' => $policyId,
|
||||
@ -277,12 +296,15 @@ public function handle(
|
||||
];
|
||||
$didMutateBackupSet = true;
|
||||
|
||||
$bulkOperationService->recordFailure(
|
||||
run: $run,
|
||||
itemId: (string) $policyId,
|
||||
reason: $reason,
|
||||
reasonCode: $reasonCode,
|
||||
);
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runFailuresForOperationRun[] = [
|
||||
'code' => "graph.{$reasonCode}",
|
||||
'message' => $reason,
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -293,18 +315,21 @@ public function handle(
|
||||
if (! $version || ! is_array($captured)) {
|
||||
$newBackupFailures[] = [
|
||||
'policy_id' => $policyId,
|
||||
'reason' => $bulkOperationService->sanitizeFailureReason('Capture result missing version payload.'),
|
||||
'reason' => RunFailureSanitizer::sanitizeMessage('Capture result missing version payload.'),
|
||||
'status' => null,
|
||||
'reason_code' => 'unknown',
|
||||
];
|
||||
$didMutateBackupSet = true;
|
||||
|
||||
$bulkOperationService->recordFailure(
|
||||
run: $run,
|
||||
itemId: (string) $policyId,
|
||||
reason: 'Capture result missing version payload.',
|
||||
reasonCode: 'unknown',
|
||||
);
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runFailuresForOperationRun[] = [
|
||||
'code' => 'capture.missing_payload',
|
||||
'message' => 'Capture result missing version payload.',
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -352,12 +377,10 @@ public function handle(
|
||||
]);
|
||||
} catch (QueryException $exception) {
|
||||
if ((string) $exception->getCode() === '23505') {
|
||||
$bulkOperationService->recordSkippedWithReason(
|
||||
run: $run,
|
||||
itemId: (string) $policyId,
|
||||
reason: 'Already in backup set',
|
||||
reasonCode: 'already_in_backup_set',
|
||||
);
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
@ -369,12 +392,15 @@ public function handle(
|
||||
$didMutateBackupSet = true;
|
||||
$backupSetItemMutations++;
|
||||
|
||||
$bulkOperationService->recordSuccess($run);
|
||||
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'created' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($this->includeFoundations) {
|
||||
if ($includeFoundations) {
|
||||
[$foundationOutcome, $foundationFailureEntries] = $this->captureFoundations(
|
||||
bulkOperationService: $bulkOperationService,
|
||||
foundationSnapshots: $foundationSnapshots,
|
||||
tenant: $tenant,
|
||||
backupSet: $backupSet,
|
||||
@ -391,13 +417,10 @@ public function handle(
|
||||
$newBackupFailures = array_merge($newBackupFailures, $foundationFailureEntries);
|
||||
|
||||
foreach ($foundationFailureEntries as $foundationFailure) {
|
||||
$this->appendRunFailure($run, [
|
||||
'type' => 'foundation',
|
||||
'item_id' => (string) ($foundationFailure['foundation_type'] ?? 'foundation'),
|
||||
'reason_code' => (string) ($foundationFailure['reason_code'] ?? 'unknown'),
|
||||
'reason' => (string) ($foundationFailure['reason'] ?? 'Foundation capture failed.'),
|
||||
'status' => $foundationFailure['status'] ?? null,
|
||||
]);
|
||||
$runFailuresForOperationRun[] = [
|
||||
'code' => 'foundation.capture_failed',
|
||||
'message' => (string) ($foundationFailure['reason'] ?? 'Foundation capture failed.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -420,45 +443,41 @@ public function handle(
|
||||
]);
|
||||
}
|
||||
|
||||
$bulkOperationService->complete($run);
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun) {
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$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);
|
||||
|
||||
$opOutcome = match (true) {
|
||||
$run->status === 'completed' => 'succeeded',
|
||||
$run->status === 'completed_with_errors' => 'partially_succeeded',
|
||||
$run->status === 'failed' => 'failed',
|
||||
default => 'failed'
|
||||
};
|
||||
|
||||
$opService->updateRun(
|
||||
$this->operationRun,
|
||||
'completed',
|
||||
$opOutcome,
|
||||
[
|
||||
'policies_added' => $backupSetItemMutations,
|
||||
'foundations_added' => $foundationMutations,
|
||||
'failures' => count($newBackupFailures),
|
||||
],
|
||||
$newBackupFailures
|
||||
);
|
||||
$outcome = 'succeeded';
|
||||
if ($failed > 0 && $succeeded > 0) {
|
||||
$outcome = 'partially_succeeded';
|
||||
}
|
||||
if ($failed > 0 && $succeeded === 0) {
|
||||
$outcome = 'failed';
|
||||
}
|
||||
|
||||
if (! $run->user) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: 'completed',
|
||||
outcome: $outcome,
|
||||
failures: $runFailuresForOperationRun,
|
||||
);
|
||||
|
||||
if (! $initiator instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = "Added {$run->succeeded} policies";
|
||||
if ($run->skipped > 0) {
|
||||
$message .= " ({$run->skipped} skipped)";
|
||||
$message = "Added {$succeeded} policies";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($run->failed > 0) {
|
||||
$message .= " ({$run->failed} failed)";
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if ($this->includeFoundations) {
|
||||
if ($includeFoundations) {
|
||||
$message .= ". Foundations: {$foundationMutations} items";
|
||||
|
||||
if ($foundationFailures > 0) {
|
||||
@ -468,7 +487,7 @@ public function handle(
|
||||
|
||||
$message .= '.';
|
||||
|
||||
$partial = $run->status === 'completed_with_errors' || $foundationFailures > 0;
|
||||
$partial = $outcome === 'partially_succeeded' || $foundationFailures > 0;
|
||||
|
||||
$notification = Notification::make()
|
||||
->title($partial ? 'Add Policies Completed (partial)' : 'Add Policies Completed')
|
||||
@ -476,7 +495,7 @@ public function handle(
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $tenant) : BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
]);
|
||||
|
||||
if ($partial) {
|
||||
@ -486,22 +505,15 @@ public function handle(
|
||||
}
|
||||
|
||||
$notification
|
||||
->sendToDatabase($run->user)
|
||||
->sendToDatabase($initiator)
|
||||
->send();
|
||||
} catch (Throwable $throwable) {
|
||||
$run->refresh();
|
||||
|
||||
if (in_array($run->status, ['completed', 'completed_with_errors'], true)) {
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$this->markRunFailed(
|
||||
bulkOperationService: $bulkOperationService,
|
||||
run: $run,
|
||||
$this->failRun(
|
||||
operationRunService: $operationRunService,
|
||||
tenant: $tenant instanceof Tenant ? $tenant : null,
|
||||
itemId: (string) $this->backupSetId,
|
||||
reasonCode: 'unknown',
|
||||
reason: $throwable->getMessage(),
|
||||
code: 'exception.unhandled',
|
||||
message: $throwable->getMessage(),
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
// TrackOperationRun will catch this throw
|
||||
@ -510,20 +522,11 @@ public function handle(
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, int> $policyIds
|
||||
* @return array<int>
|
||||
*/
|
||||
private function extractPolicyIds(BulkOperationRun $run): array
|
||||
private function normalizePolicyIds(array $policyIds): array
|
||||
{
|
||||
$itemIds = $run->item_ids ?? [];
|
||||
|
||||
$policyIds = [];
|
||||
|
||||
if (is_array($itemIds) && array_key_exists('policy_ids', $itemIds) && is_array($itemIds['policy_ids'])) {
|
||||
$policyIds = $itemIds['policy_ids'];
|
||||
} elseif (is_array($itemIds)) {
|
||||
$policyIds = $itemIds;
|
||||
}
|
||||
|
||||
$policyIds = array_values(array_unique(array_map('intval', $policyIds)));
|
||||
$policyIds = array_values(array_filter($policyIds, fn (int $value): bool => $value > 0));
|
||||
sort($policyIds);
|
||||
@ -531,61 +534,32 @@ private function extractPolicyIds(BulkOperationRun $run): array
|
||||
return $policyIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $entry
|
||||
*/
|
||||
private function appendRunFailure(BulkOperationRun $run, array $entry): void
|
||||
{
|
||||
$failures = $run->failures ?? [];
|
||||
|
||||
$failures[] = array_merge([
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
], $entry);
|
||||
|
||||
$run->update(['failures' => $failures]);
|
||||
}
|
||||
|
||||
private function markRunFailed(
|
||||
BulkOperationService $bulkOperationService,
|
||||
BulkOperationRun $run,
|
||||
private function failRun(
|
||||
OperationRunService $operationRunService,
|
||||
?Tenant $tenant,
|
||||
string $itemId,
|
||||
string $reasonCode,
|
||||
string $reason,
|
||||
string $code,
|
||||
string $message,
|
||||
?User $initiator = null,
|
||||
): void {
|
||||
$reason = $bulkOperationService->sanitizeFailureReason($reason);
|
||||
$safeMessage = RunFailureSanitizer::sanitizeMessage($message);
|
||||
$safeCode = RunFailureSanitizer::sanitizeCode($code);
|
||||
|
||||
$this->appendRunFailure($run, [
|
||||
'type' => 'run',
|
||||
'item_id' => $itemId,
|
||||
'reason_code' => $reasonCode,
|
||||
'reason' => $reason,
|
||||
]);
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: 'completed',
|
||||
outcome: 'failed',
|
||||
failures: [[
|
||||
'code' => $safeCode,
|
||||
'message' => $safeMessage,
|
||||
]],
|
||||
);
|
||||
|
||||
try {
|
||||
$bulkOperationService->fail($run, $reason);
|
||||
} catch (Throwable) {
|
||||
$run->update(['status' => 'failed']);
|
||||
}
|
||||
|
||||
if ($this->operationRun) {
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opService->updateRun(
|
||||
$this->operationRun,
|
||||
'completed',
|
||||
'failed',
|
||||
['failure_reason' => $reason],
|
||||
[['code' => $reasonCode, 'message' => $reason]]
|
||||
);
|
||||
}
|
||||
|
||||
$this->notifyRunFailed($run, $tenant, $reason);
|
||||
$this->notifyRunFailed($initiator, $tenant, $safeMessage);
|
||||
}
|
||||
|
||||
private function notifyRunFailed(BulkOperationRun $run, ?Tenant $tenant, string $reason): void
|
||||
private function notifyRunFailed(?User $initiator, ?Tenant $tenant, string $reason): void
|
||||
{
|
||||
if (! $run->user) {
|
||||
if (! $initiator instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -597,13 +571,13 @@ private function notifyRunFailed(BulkOperationRun $run, ?Tenant $tenant, string
|
||||
$notification->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $tenant) : BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
]);
|
||||
}
|
||||
|
||||
$notification
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->sendToDatabase($initiator)
|
||||
->send();
|
||||
}
|
||||
|
||||
@ -621,7 +595,6 @@ private function mapGraphFailureReasonCode(?int $status): string
|
||||
* @return array{0:array{created:int,restored:int,failures:array<int,array{foundation_type:string,reason:string,status:int|string|null,reason_code:string}>},1:array<int,array{foundation_type:string,reason:string,status:int|string|null,reason_code:string}>}
|
||||
*/
|
||||
private function captureFoundations(
|
||||
BulkOperationService $bulkOperationService,
|
||||
FoundationSnapshotService $foundationSnapshots,
|
||||
Tenant $tenant,
|
||||
BackupSet $backupSet,
|
||||
@ -647,7 +620,7 @@ private function captureFoundations(
|
||||
|
||||
$status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null;
|
||||
$reasonCode = $this->mapGraphFailureReasonCode($status);
|
||||
$reason = $bulkOperationService->sanitizeFailureReason((string) ($failure['reason'] ?? 'Foundation capture failed.'));
|
||||
$reason = RunFailureSanitizer::sanitizeMessage((string) ($failure['reason'] ?? 'Foundation capture failed.'));
|
||||
|
||||
$failures[] = [
|
||||
'foundation_type' => $foundationType,
|
||||
|
||||
@ -2,149 +2,93 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Services\BulkOperationService;
|
||||
use Filament\Notifications\Notification;
|
||||
use App\Jobs\Operations\BackupSetDeleteWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
use RuntimeException;
|
||||
|
||||
class BulkBackupSetDeleteJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $backupSetIds
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
) {}
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public array $backupSetIds,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
public function handle(OperationRunService $runs): void
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for BulkBackupSetDeleteJob.');
|
||||
}
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
$runs->updateRun($this->operationRun, 'running');
|
||||
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
$ids = $this->normalizeIds($this->backupSetIds);
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $backupSetId) {
|
||||
$itemCount++;
|
||||
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||
$chunkSize = max(1, $chunkSize);
|
||||
|
||||
try {
|
||||
/** @var BackupSet|null $backupSet */
|
||||
$backupSet = BackupSet::withTrashed()
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->whereKey($backupSetId)
|
||||
->first();
|
||||
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||
foreach ($chunk as $backupSetId) {
|
||||
dispatch(new BackupSetDeleteWorkerJob(
|
||||
tenantId: $this->tenantId,
|
||||
userId: $this->userId,
|
||||
backupSetId: $backupSetId,
|
||||
operationRun: $this->operationRun,
|
||||
context: $this->context,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $backupSet) {
|
||||
$service->recordFailure($run, (string) $backupSetId, 'Backup set not found');
|
||||
$failed++;
|
||||
/**
|
||||
* @param array<int, mixed> $ids
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
foreach ($ids as $id) {
|
||||
if (is_int($id)) {
|
||||
$normalized[] = $id;
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Archive Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($backupSet->trashed()) {
|
||||
$service->recordSkippedWithReason($run, (string) $backupSet->id, 'Already archived');
|
||||
$skipped++;
|
||||
$skipReasons['Already archived'] = ($skipReasons['Already archived'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$backupSet->delete();
|
||||
$service->recordSuccess($run);
|
||||
$succeeded++;
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $backupSetId, $e->getMessage());
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Archive Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
if (is_numeric($id)) {
|
||||
$normalized[] = (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
if (! $run->user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = "Archived {$succeeded} backup sets";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if (! empty($skipReasons)) {
|
||||
$summary = collect($skipReasons)
|
||||
->sortDesc()
|
||||
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||
->take(3)
|
||||
->implode(', ');
|
||||
|
||||
if ($summary !== '') {
|
||||
$message .= " Skip reasons: {$summary}.";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Archive Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,159 +2,93 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Services\BulkOperationService;
|
||||
use Filament\Notifications\Notification;
|
||||
use App\Jobs\Operations\BackupSetForceDeleteWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
use RuntimeException;
|
||||
|
||||
class BulkBackupSetForceDeleteJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $backupSetIds
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
) {}
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public array $backupSetIds,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
public function handle(OperationRunService $runs): void
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for BulkBackupSetForceDeleteJob.');
|
||||
}
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
$runs->updateRun($this->operationRun, 'running');
|
||||
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
$ids = $this->normalizeIds($this->backupSetIds);
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $backupSetId) {
|
||||
$itemCount++;
|
||||
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||
$chunkSize = max(1, $chunkSize);
|
||||
|
||||
try {
|
||||
/** @var BackupSet|null $backupSet */
|
||||
$backupSet = BackupSet::withTrashed()
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->whereKey($backupSetId)
|
||||
->first();
|
||||
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||
foreach ($chunk as $backupSetId) {
|
||||
dispatch(new BackupSetForceDeleteWorkerJob(
|
||||
tenantId: $this->tenantId,
|
||||
userId: $this->userId,
|
||||
backupSetId: $backupSetId,
|
||||
operationRun: $this->operationRun,
|
||||
context: $this->context,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $backupSet) {
|
||||
$service->recordFailure($run, (string) $backupSetId, 'Backup set not found');
|
||||
$failed++;
|
||||
/**
|
||||
* @param array<int, mixed> $ids
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
foreach ($ids as $id) {
|
||||
if (is_int($id)) {
|
||||
$normalized[] = $id;
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $backupSet->trashed()) {
|
||||
$service->recordSkippedWithReason($run, (string) $backupSet->id, 'Not archived');
|
||||
$skipped++;
|
||||
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($backupSet->restoreRuns()->withTrashed()->exists()) {
|
||||
$service->recordSkippedWithReason($run, (string) $backupSet->id, 'Referenced by restore runs');
|
||||
$skipped++;
|
||||
$skipReasons['Referenced by restore runs'] = ($skipReasons['Referenced by restore runs'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$backupSet->items()->withTrashed()->forceDelete();
|
||||
$backupSet->forceDelete();
|
||||
|
||||
$service->recordSuccess($run);
|
||||
$succeeded++;
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $backupSetId, $e->getMessage());
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
if (is_numeric($id)) {
|
||||
$normalized[] = (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
if (! $run->user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = "Force deleted {$succeeded} backup sets";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if (! empty($skipReasons)) {
|
||||
$summary = collect($skipReasons)
|
||||
->sortDesc()
|
||||
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||
->take(3)
|
||||
->implode(', ');
|
||||
|
||||
if ($summary !== '') {
|
||||
$message .= " Skip reasons: {$summary}.";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,151 +2,93 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Services\BulkOperationService;
|
||||
use Filament\Notifications\Notification;
|
||||
use App\Jobs\Operations\BackupSetRestoreWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
use RuntimeException;
|
||||
|
||||
class BulkBackupSetRestoreJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $backupSetIds
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
) {}
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public array $backupSetIds,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
public function handle(OperationRunService $runs): void
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for BulkBackupSetRestoreJob.');
|
||||
}
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
$runs->updateRun($this->operationRun, 'running');
|
||||
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
$ids = $this->normalizeIds($this->backupSetIds);
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $backupSetId) {
|
||||
$itemCount++;
|
||||
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||
$chunkSize = max(1, $chunkSize);
|
||||
|
||||
try {
|
||||
/** @var BackupSet|null $backupSet */
|
||||
$backupSet = BackupSet::withTrashed()
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->whereKey($backupSetId)
|
||||
->first();
|
||||
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||
foreach ($chunk as $backupSetId) {
|
||||
dispatch(new BackupSetRestoreWorkerJob(
|
||||
tenantId: $this->tenantId,
|
||||
userId: $this->userId,
|
||||
backupSetId: $backupSetId,
|
||||
operationRun: $this->operationRun,
|
||||
context: $this->context,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (! $backupSet) {
|
||||
$service->recordFailure($run, (string) $backupSetId, 'Backup set not found');
|
||||
$failed++;
|
||||
/**
|
||||
* @param array<int, mixed> $ids
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
foreach ($ids as $id) {
|
||||
if (is_int($id)) {
|
||||
$normalized[] = $id;
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Restore Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $backupSet->trashed()) {
|
||||
$service->recordSkippedWithReason($run, (string) $backupSet->id, 'Not archived');
|
||||
$skipped++;
|
||||
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$backupSet->restore();
|
||||
$backupSet->items()->withTrashed()->restore();
|
||||
|
||||
$service->recordSuccess($run);
|
||||
$succeeded++;
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $backupSetId, $e->getMessage());
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Restore Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
if (is_numeric($id)) {
|
||||
$normalized[] = (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
if (! $run->user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$message = "Restored {$succeeded} backup sets";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if (! empty($skipReasons)) {
|
||||
$summary = collect($skipReasons)
|
||||
->sortDesc()
|
||||
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||
->take(3)
|
||||
->implode(', ');
|
||||
|
||||
if ($summary !== '') {
|
||||
$message .= " Skip reasons: {$summary}.";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Restore Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,184 +2,93 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Services\BulkOperationService;
|
||||
use Filament\Notifications\Notification;
|
||||
use App\Jobs\Operations\PolicyBulkDeleteWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
use RuntimeException;
|
||||
|
||||
class BulkPolicyDeleteJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $policyIds
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public array $policyIds,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public int $bulkRunId
|
||||
|
||||
) {}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
public function handle(OperationRunService $runs): void
|
||||
{
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for BulkPolicyDeleteJob.');
|
||||
}
|
||||
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
$runs->updateRun($this->operationRun, 'running');
|
||||
|
||||
try {
|
||||
$ids = $this->normalizeIds($this->policyIds);
|
||||
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$failures = [];
|
||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||
$chunkSize = max(1, $chunkSize);
|
||||
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||
foreach ($chunk as $policyId) {
|
||||
dispatch(new PolicyBulkDeleteWorkerJob(
|
||||
tenantId: $this->tenantId,
|
||||
userId: $this->userId,
|
||||
policyId: $policyId,
|
||||
operationRun: $this->operationRun,
|
||||
context: $this->context,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($run->item_ids as $policyId) {
|
||||
/**
|
||||
* @param array<int, mixed> $ids
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
$itemCount++;
|
||||
|
||||
try {
|
||||
|
||||
$policy = Policy::find($policyId);
|
||||
|
||||
if (! $policy) {
|
||||
|
||||
$service->recordFailure($run, (string) $policyId, 'Policy not found');
|
||||
$failed++;
|
||||
$failures[] = [
|
||||
'item_id' => (string) $policyId,
|
||||
'reason' => 'Policy not found',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
|
||||
}
|
||||
|
||||
if ($policy->ignored_at) {
|
||||
|
||||
$service->recordSkipped($run);
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
|
||||
}
|
||||
|
||||
$policy->ignore();
|
||||
|
||||
$service->recordSuccess($run);
|
||||
$succeeded++;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
|
||||
$service->recordFailure($run, (string) $policyId, $e->getMessage());
|
||||
$failed++;
|
||||
$failures[] = [
|
||||
'item_id' => (string) $policyId,
|
||||
'reason' => $e->getMessage(),
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Refresh the run from database every $chunkSize items to avoid stale data
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
|
||||
$run->refresh();
|
||||
|
||||
}
|
||||
foreach ($ids as $id) {
|
||||
if (is_int($id)) {
|
||||
$normalized[] = $id;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
|
||||
if ($succeeded > 0 || $failed > 0 || $skipped > 0) {
|
||||
$message = "Successfully deleted {$succeeded} policies";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Delete Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
if (is_numeric($id)) {
|
||||
$normalized[] = (int) $id;
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
|
||||
$service->fail($run, $e->getMessage());
|
||||
|
||||
// Reload run with user relationship
|
||||
$run->refresh();
|
||||
$run->load('user');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Delete Failed')
|
||||
->body($e->getMessage())
|
||||
->icon('heroicon-o-x-circle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,11 +2,17 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@ -19,31 +25,53 @@ class BulkPolicyExportJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
/** @var array<int, int|string> */
|
||||
public array $policyIds,
|
||||
public string $backupName,
|
||||
public ?string $backupDescription = null
|
||||
) {}
|
||||
public ?string $backupDescription = null,
|
||||
?OperationRun $operationRun = null,
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
public function middleware(): array
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
return [new TrackOperationRun];
|
||||
}
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
return;
|
||||
public function handle(OperationRunService $operationRunService): void
|
||||
{
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new \RuntimeException('Tenant not found.');
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
$user = User::query()->find($this->userId);
|
||||
if (! $user instanceof User) {
|
||||
throw new \RuntimeException('User not found.');
|
||||
}
|
||||
|
||||
$ids = collect($this->policyIds)
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
try {
|
||||
// Create Backup Set
|
||||
$backupSet = BackupSet::create([
|
||||
'tenant_id' => $run->tenant_id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'name' => $this->backupName,
|
||||
// 'description' => $this->backupDescription, // Not in schema
|
||||
'status' => 'completed',
|
||||
'created_by' => $run->user?->name ?? (string) $run->user_id, // Schema has created_by string
|
||||
'item_count' => count($run->item_ids),
|
||||
'created_by' => $user->name,
|
||||
'item_count' => count($ids),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
@ -51,37 +79,55 @@ public function handle(BulkOperationService $service): void
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$failures = [];
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$totalItems = count($ids);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
|
||||
foreach ($run->item_ids as $policyId) {
|
||||
foreach ($ids as $policyId) {
|
||||
$itemCount++;
|
||||
|
||||
try {
|
||||
$policy = Policy::find($policyId);
|
||||
$policy = Policy::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->find($policyId);
|
||||
|
||||
if (! $policy) {
|
||||
$service->recordFailure($run, (string) $policyId, 'Policy not found');
|
||||
$failed++;
|
||||
$failures[] = [
|
||||
'item_id' => (string) $policyId,
|
||||
'reason' => 'Policy not found',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
$failures[] = ['code' => 'policy.not_found', 'message' => "Policy {$policyId} not found."];
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$backupSet->update(['status' => 'failed']);
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: [
|
||||
'total' => $totalItems,
|
||||
'processed' => $itemCount,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'created' => $succeeded,
|
||||
],
|
||||
failures: array_merge($failures, [
|
||||
['code' => 'export.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Export Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
@ -95,25 +141,42 @@ public function handle(BulkOperationService $service): void
|
||||
$latestVersion = $policy->versions()->orderByDesc('captured_at')->first();
|
||||
|
||||
if (! $latestVersion) {
|
||||
$service->recordFailure($run, (string) $policyId, 'No versions available for policy');
|
||||
$failed++;
|
||||
$failures[] = [
|
||||
'item_id' => (string) $policyId,
|
||||
'reason' => 'No versions available for policy',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
$failures[] = ['code' => 'policy.no_versions', 'message' => "No versions available for policy {$policyId}."];
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$backupSet->update(['status' => 'failed']);
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: [
|
||||
'total' => $totalItems,
|
||||
'processed' => $itemCount,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'created' => $succeeded,
|
||||
],
|
||||
failures: array_merge($failures, [
|
||||
['code' => 'export.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Export Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
@ -125,7 +188,7 @@ public function handle(BulkOperationService $service): void
|
||||
|
||||
// Create Backup Item
|
||||
BackupItem::create([
|
||||
'tenant_id' => $run->tenant_id,
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_id' => $policy->id,
|
||||
'policy_identifier' => $policy->external_id, // Added
|
||||
@ -139,46 +202,81 @@ public function handle(BulkOperationService $service): void
|
||||
],
|
||||
]);
|
||||
|
||||
$service->recordSuccess($run);
|
||||
$succeeded++;
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $policyId, $e->getMessage());
|
||||
$failed++;
|
||||
$failures[] = [
|
||||
'item_id' => (string) $policyId,
|
||||
'reason' => $e->getMessage(),
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
$failures[] = ['code' => 'policy.export.failed', 'message' => $e->getMessage()];
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$backupSet->update(['status' => 'failed']);
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: [
|
||||
'total' => $totalItems,
|
||||
'processed' => $itemCount,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'created' => $succeeded,
|
||||
],
|
||||
failures: array_merge($failures, [
|
||||
['code' => 'export.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Export Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the run from database every 10 items to avoid stale data
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
// Update BackupSet item count (if denormalized) or just leave it
|
||||
// Assuming BackupSet might need an item count or status update
|
||||
|
||||
$service->complete($run);
|
||||
$outcome = OperationRunOutcome::Succeeded->value;
|
||||
|
||||
if ($failed > 0 && $failed < $totalItems) {
|
||||
$outcome = OperationRunOutcome::PartiallySucceeded->value;
|
||||
}
|
||||
|
||||
if ($failed >= $totalItems && $totalItems > 0) {
|
||||
$outcome = OperationRunOutcome::Failed->value;
|
||||
}
|
||||
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: $outcome,
|
||||
summaryCounts: [
|
||||
'total' => $totalItems,
|
||||
'processed' => $totalItems,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'created' => $succeeded,
|
||||
],
|
||||
failures: $failures,
|
||||
);
|
||||
}
|
||||
|
||||
if ($succeeded > 0 || $failed > 0) {
|
||||
$message = "Successfully exported {$succeeded} policies to backup '{$this->backupName}'";
|
||||
@ -192,24 +290,39 @@ public function handle(BulkOperationService $service): void
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
} catch (Throwable $e) {
|
||||
$service->fail($run, $e->getMessage());
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
['code' => 'exception.unhandled', 'message' => $e->getMessage()],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// Reload run with user relationship
|
||||
$run->refresh();
|
||||
$run->load('user');
|
||||
|
||||
if ($run->user) {
|
||||
if (isset($user) && $user instanceof User) {
|
||||
Notification::make()
|
||||
->title('Bulk Export Failed')
|
||||
->body($e->getMessage())
|
||||
->icon('heroicon-o-x-circle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
|
||||
@ -2,17 +2,14 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use Filament\Notifications\Notification;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
|
||||
class BulkPolicySyncJob implements ShouldQueue
|
||||
{
|
||||
@ -20,128 +17,24 @@ class BulkPolicySyncJob implements ShouldQueue
|
||||
|
||||
public function __construct(public int $bulkRunId) {}
|
||||
|
||||
public function handle(BulkOperationService $service, PolicySyncService $syncService): void
|
||||
public function handle(PolicySyncService $syncService, OperationRunService $runs): void
|
||||
{
|
||||
$run = BulkOperationRun::with(['tenant', 'user'])->find($this->bulkRunId);
|
||||
$run = OperationRun::query()->find($this->bulkRunId);
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
if (! $run instanceof OperationRun) {
|
||||
return;
|
||||
}
|
||||
$policyIds = data_get($run->context ?? [], 'policy_ids');
|
||||
|
||||
if (! is_array($policyIds)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
|
||||
try {
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$itemCount = 0;
|
||||
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $policyId) {
|
||||
$itemCount++;
|
||||
|
||||
try {
|
||||
$policy = Policy::query()
|
||||
->whereKey($policyId)
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->first();
|
||||
|
||||
if (! $policy) {
|
||||
$service->recordFailure($run, (string) $policyId, 'Policy not found');
|
||||
|
||||
if ($run->failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Sync Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($policy->ignored_at) {
|
||||
$service->recordSkippedWithReason($run, (string) $policyId, 'Policy is ignored locally');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$syncService->syncPolicy($run->tenant, $policy);
|
||||
|
||||
$service->recordSuccess($run);
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $policyId, $e->getMessage());
|
||||
|
||||
if ($run->failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Sync Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
|
||||
if ($run->user) {
|
||||
$message = "Synced {$run->succeeded} policies";
|
||||
|
||||
if ($run->skipped > 0) {
|
||||
$message .= " ({$run->skipped} skipped)";
|
||||
}
|
||||
|
||||
if ($run->failed > 0) {
|
||||
$message .= " ({$run->failed} failed)";
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Sync Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$service->fail($run, $e->getMessage());
|
||||
|
||||
$run->refresh();
|
||||
$run->load('user');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Sync Failed')
|
||||
->body($e->getMessage())
|
||||
->icon('heroicon-o-x-circle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
(new SyncPoliciesJob(
|
||||
tenantId: (int) $run->tenant_id,
|
||||
types: null,
|
||||
policyIds: $policyIds,
|
||||
operationRun: $run,
|
||||
))->handle($syncService, $runs);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,9 +2,15 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@ -17,63 +23,113 @@ class BulkPolicyUnignoreJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public int $bulkRunId) {}
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
/**
|
||||
* @param array<int, int|string> $policyIds
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public array $policyIds,
|
||||
?OperationRun $operationRun = null,
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function middleware(): array
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
return [new TrackOperationRun];
|
||||
}
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
return;
|
||||
public function handle(OperationRunService $operationRunService): void
|
||||
{
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new \RuntimeException('Tenant not found.');
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
$user = User::query()->find($this->userId);
|
||||
if (! $user instanceof User) {
|
||||
throw new \RuntimeException('User not found.');
|
||||
}
|
||||
|
||||
try {
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$failures = [];
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$ids = collect($this->policyIds)
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
foreach ($run->item_ids as $policyId) {
|
||||
$itemCount++;
|
||||
foreach ($ids as $policyId) {
|
||||
|
||||
try {
|
||||
$policy = Policy::find($policyId);
|
||||
$policy = Policy::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->find($policyId);
|
||||
|
||||
if (! $policy) {
|
||||
$service->recordFailure($run, (string) $policyId, 'Policy not found');
|
||||
$failed++;
|
||||
$failures[] = [
|
||||
'code' => 'policy.not_found',
|
||||
'message' => "Policy {$policyId} not found.",
|
||||
];
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $policy->ignored_at) {
|
||||
$service->recordSkipped($run);
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$policy->unignore();
|
||||
|
||||
$service->recordSuccess($run);
|
||||
$succeeded++;
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $policyId, $e->getMessage());
|
||||
$failed++;
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
$failures[] = [
|
||||
'code' => 'policy.unignore.failed',
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
$total = count($ids);
|
||||
|
||||
if ($run->user) {
|
||||
$outcome = OperationRunOutcome::Succeeded->value;
|
||||
|
||||
if ($failed > 0 && $failed < $total) {
|
||||
$outcome = OperationRunOutcome::PartiallySucceeded->value;
|
||||
}
|
||||
|
||||
if ($failed >= $total && $total > 0) {
|
||||
$outcome = OperationRunOutcome::Failed->value;
|
||||
}
|
||||
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: $outcome,
|
||||
summaryCounts: [
|
||||
'total' => $total,
|
||||
'processed' => $total,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'skipped' => $skipped,
|
||||
],
|
||||
failures: $failures,
|
||||
);
|
||||
}
|
||||
|
||||
if ($user) {
|
||||
$message = "Restored {$succeeded} policies";
|
||||
|
||||
if ($skipped > 0) {
|
||||
@ -91,22 +147,38 @@ public function handle(BulkOperationService $service): void
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$service->fail($run, $e->getMessage());
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
['code' => 'exception.unhandled', 'message' => $e->getMessage()],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$run->refresh();
|
||||
$run->load('user');
|
||||
|
||||
if ($run->user) {
|
||||
if (isset($user) && $user instanceof User) {
|
||||
Notification::make()
|
||||
->title('Bulk Restore Failed')
|
||||
->body($e->getMessage())
|
||||
->icon('heroicon-o-x-circle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
|
||||
@ -2,147 +2,93 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\BulkOperationService;
|
||||
use Filament\Notifications\Notification;
|
||||
use App\Jobs\Operations\PolicyVersionForceDeleteWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
use RuntimeException;
|
||||
|
||||
class BulkPolicyVersionForceDeleteJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $policyVersionIds
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
) {}
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public array $policyVersionIds,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
public function handle(OperationRunService $runs): void
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for BulkPolicyVersionForceDeleteJob.');
|
||||
}
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
$runs->updateRun($this->operationRun, 'running');
|
||||
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
$ids = $this->normalizeIds($this->policyVersionIds);
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $versionId) {
|
||||
$itemCount++;
|
||||
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||
$chunkSize = max(1, $chunkSize);
|
||||
|
||||
try {
|
||||
/** @var PolicyVersion|null $version */
|
||||
$version = PolicyVersion::withTrashed()
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->whereKey($versionId)
|
||||
->first();
|
||||
|
||||
if (! $version) {
|
||||
$service->recordFailure($run, (string) $versionId, 'Policy version not found');
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $version->trashed()) {
|
||||
$service->recordSkippedWithReason($run, (string) $version->id, 'Not archived');
|
||||
$skipped++;
|
||||
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$version->forceDelete();
|
||||
$service->recordSuccess($run);
|
||||
$succeeded++;
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $versionId, $e->getMessage());
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||
foreach ($chunk as $policyVersionId) {
|
||||
dispatch(new PolicyVersionForceDeleteWorkerJob(
|
||||
tenantId: $this->tenantId,
|
||||
userId: $this->userId,
|
||||
policyVersionId: $policyVersionId,
|
||||
operationRun: $this->operationRun,
|
||||
context: $this->context,
|
||||
));
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
|
||||
if ($run->user) {
|
||||
$message = "Force deleted {$succeeded} policy versions";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if (! empty($skipReasons)) {
|
||||
$summary = collect($skipReasons)
|
||||
->sortDesc()
|
||||
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||
->take(3)
|
||||
->implode(', ');
|
||||
|
||||
if ($summary !== '') {
|
||||
$message .= " Skip reasons: {$summary}.";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $ids
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (is_int($id)) {
|
||||
$normalized[] = $id;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_numeric($id)) {
|
||||
$normalized[] = (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,177 +2,95 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\BulkOperationService;
|
||||
use Filament\Notifications\Notification;
|
||||
use App\Jobs\Operations\PolicyVersionPruneWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
use RuntimeException;
|
||||
|
||||
class BulkPolicyVersionPruneJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $policyVersionIds
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public array $policyVersionIds,
|
||||
public int $retentionDays = 90,
|
||||
) {}
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
public function handle(OperationRunService $runs): void
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for BulkPolicyVersionPruneJob.');
|
||||
}
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
$runs->updateRun($this->operationRun, 'running');
|
||||
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
$ids = $this->normalizeIds($this->policyVersionIds);
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $versionId) {
|
||||
$itemCount++;
|
||||
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||
$chunkSize = max(1, $chunkSize);
|
||||
|
||||
try {
|
||||
/** @var PolicyVersion|null $version */
|
||||
$version = PolicyVersion::withTrashed()
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->whereKey($versionId)
|
||||
->first();
|
||||
|
||||
if (! $version) {
|
||||
$service->recordFailure($run, (string) $versionId, 'Policy version not found');
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Prune Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($version->trashed()) {
|
||||
$service->recordSkippedWithReason($run, (string) $version->id, 'Already archived');
|
||||
$skipped++;
|
||||
$skipReasons['Already archived'] = ($skipReasons['Already archived'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$eligible = PolicyVersion::query()
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->whereKey($version->id)
|
||||
->pruneEligible($this->retentionDays)
|
||||
->exists();
|
||||
|
||||
if (! $eligible) {
|
||||
$capturedAt = $version->captured_at;
|
||||
$isTooRecent = $capturedAt && $capturedAt->gte(now()->subDays($this->retentionDays));
|
||||
|
||||
$latestVersionNumber = PolicyVersion::query()
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->where('policy_id', $version->policy_id)
|
||||
->whereNull('deleted_at')
|
||||
->max('version_number');
|
||||
|
||||
$isCurrent = $latestVersionNumber !== null && (int) $version->version_number === (int) $latestVersionNumber;
|
||||
|
||||
$reason = $isCurrent
|
||||
? 'Current version'
|
||||
: ($isTooRecent ? 'Too recent' : 'Not eligible');
|
||||
|
||||
$service->recordSkippedWithReason($run, (string) $version->id, $reason);
|
||||
$skipped++;
|
||||
$skipReasons[$reason] = ($skipReasons[$reason] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$version->delete();
|
||||
$service->recordSuccess($run);
|
||||
$succeeded++;
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $versionId, $e->getMessage());
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Prune Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||
foreach ($chunk as $policyVersionId) {
|
||||
dispatch(new PolicyVersionPruneWorkerJob(
|
||||
tenantId: $this->tenantId,
|
||||
userId: $this->userId,
|
||||
policyVersionId: $policyVersionId,
|
||||
retentionDays: $this->retentionDays,
|
||||
operationRun: $this->operationRun,
|
||||
context: $this->context,
|
||||
));
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
|
||||
if ($run->user) {
|
||||
$message = "Pruned {$succeeded} policy versions";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if (! empty($skipReasons)) {
|
||||
$summary = collect($skipReasons)
|
||||
->sortDesc()
|
||||
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||
->take(3)
|
||||
->implode(', ');
|
||||
|
||||
if ($summary !== '') {
|
||||
$message .= " Skip reasons: {$summary}.";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Prune Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $ids
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (is_int($id)) {
|
||||
$normalized[] = $id;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_numeric($id)) {
|
||||
$normalized[] = (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,147 +2,93 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\BulkOperationService;
|
||||
use Filament\Notifications\Notification;
|
||||
use App\Jobs\Operations\PolicyVersionRestoreWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
use RuntimeException;
|
||||
|
||||
class BulkPolicyVersionRestoreJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $policyVersionIds
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
) {}
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public array $policyVersionIds,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
public function handle(OperationRunService $runs): void
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for BulkPolicyVersionRestoreJob.');
|
||||
}
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
$runs->updateRun($this->operationRun, 'running');
|
||||
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
$ids = $this->normalizeIds($this->policyVersionIds);
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $versionId) {
|
||||
$itemCount++;
|
||||
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||
$chunkSize = max(1, $chunkSize);
|
||||
|
||||
try {
|
||||
/** @var PolicyVersion|null $version */
|
||||
$version = PolicyVersion::withTrashed()
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->whereKey($versionId)
|
||||
->first();
|
||||
|
||||
if (! $version) {
|
||||
$service->recordFailure($run, (string) $versionId, 'Policy version not found');
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Restore Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $version->trashed()) {
|
||||
$service->recordSkippedWithReason($run, (string) $version->id, 'Not archived');
|
||||
$skipped++;
|
||||
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$version->restore();
|
||||
$service->recordSuccess($run);
|
||||
$succeeded++;
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $versionId, $e->getMessage());
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Restore Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||
foreach ($chunk as $policyVersionId) {
|
||||
dispatch(new PolicyVersionRestoreWorkerJob(
|
||||
tenantId: $this->tenantId,
|
||||
userId: $this->userId,
|
||||
policyVersionId: $policyVersionId,
|
||||
operationRun: $this->operationRun,
|
||||
context: $this->context,
|
||||
));
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
|
||||
if ($run->user) {
|
||||
$message = "Restored {$succeeded} policy versions";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if (! empty($skipReasons)) {
|
||||
$summary = collect($skipReasons)
|
||||
->sortDesc()
|
||||
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||
->take(3)
|
||||
->implode(', ');
|
||||
|
||||
if ($summary !== '') {
|
||||
$message .= " Skip reasons: {$summary}.";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Restore Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $ids
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (is_int($id)) {
|
||||
$normalized[] = $id;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_numeric($id)) {
|
||||
$normalized[] = (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,176 +2,93 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Services\BulkOperationService;
|
||||
use Filament\Notifications\Notification;
|
||||
use App\Jobs\Operations\RestoreRunDeleteWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
use RuntimeException;
|
||||
|
||||
class BulkRestoreRunDeleteJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $restoreRunIds
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
) {}
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public array $restoreRunIds,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
public function handle(OperationRunService $runs): void
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for BulkRestoreRunDeleteJob.');
|
||||
}
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
$runs->updateRun($this->operationRun, 'running');
|
||||
|
||||
try {
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
$failed = 0;
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
$ids = $this->normalizeIds($this->restoreRunIds);
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $restoreRunId) {
|
||||
$itemCount++;
|
||||
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||
$chunkSize = max(1, $chunkSize);
|
||||
|
||||
try {
|
||||
/** @var RestoreRun|null $restoreRun */
|
||||
$restoreRun = RestoreRun::withTrashed()
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->whereKey($restoreRunId)
|
||||
->first();
|
||||
|
||||
if (! $restoreRun) {
|
||||
$service->recordFailure($run, (string) $restoreRunId, 'Restore run not found');
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($restoreRun->trashed()) {
|
||||
$service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Already archived');
|
||||
$skipped++;
|
||||
$skipReasons['Already archived'] = ($skipReasons['Already archived'] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $restoreRun->isDeletable()) {
|
||||
$reason = "Not deletable (status: {$restoreRun->status})";
|
||||
|
||||
$service->recordSkippedWithReason($run, (string) $restoreRun->id, $reason);
|
||||
$skipped++;
|
||||
$skipReasons[$reason] = ($skipReasons[$reason] ?? 0) + 1;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$restoreRun->delete();
|
||||
$service->recordSuccess($run);
|
||||
$succeeded++;
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $restoreRunId, $e->getMessage());
|
||||
$failed++;
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
}
|
||||
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||
foreach ($chunk as $restoreRunId) {
|
||||
dispatch(new RestoreRunDeleteWorkerJob(
|
||||
tenantId: $this->tenantId,
|
||||
userId: $this->userId,
|
||||
restoreRunId: $restoreRunId,
|
||||
operationRun: $this->operationRun,
|
||||
context: $this->context,
|
||||
));
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
|
||||
if ($run->user) {
|
||||
$message = "Deleted {$succeeded} restore runs";
|
||||
if ($skipped > 0) {
|
||||
$message .= " ({$skipped} skipped)";
|
||||
}
|
||||
if ($failed > 0) {
|
||||
$message .= " ({$failed} failed)";
|
||||
}
|
||||
|
||||
if (! empty($skipReasons)) {
|
||||
$summary = collect($skipReasons)
|
||||
->sortDesc()
|
||||
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
||||
->take(3)
|
||||
->implode(', ');
|
||||
|
||||
if ($summary !== '') {
|
||||
$message .= " Skip reasons: {$summary}.";
|
||||
}
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Delete Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$service->fail($run, $e->getMessage());
|
||||
|
||||
$run->refresh();
|
||||
$run->load('user');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Delete Failed')
|
||||
->body($e->getMessage())
|
||||
->icon('heroicon-o-x-circle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $ids
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (is_int($id)) {
|
||||
$normalized[] = $id;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_numeric($id)) {
|
||||
$normalized[] = (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,9 +2,15 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@ -17,19 +23,41 @@ class BulkRestoreRunForceDeleteJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
) {}
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
/** @var array<int, int|string> */
|
||||
public array $restoreRunIds,
|
||||
?OperationRun $operationRun = null,
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
public function middleware(): array
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
return [new TrackOperationRun];
|
||||
}
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
return;
|
||||
public function handle(OperationRunService $operationRunService): void
|
||||
{
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new \RuntimeException('Tenant not found.');
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
$user = User::query()->find($this->userId);
|
||||
if (! $user instanceof User) {
|
||||
throw new \RuntimeException('User not found.');
|
||||
}
|
||||
|
||||
$ids = collect($this->restoreRunIds)
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
@ -37,34 +65,57 @@ public function handle(BulkOperationService $service): void
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failures = [];
|
||||
|
||||
$totalItems = count($ids);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $restoreRunId) {
|
||||
foreach ($ids as $restoreRunId) {
|
||||
$itemCount++;
|
||||
|
||||
try {
|
||||
/** @var RestoreRun|null $restoreRun */
|
||||
$restoreRun = RestoreRun::withTrashed()
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->whereKey($restoreRunId)
|
||||
->first();
|
||||
|
||||
if (! $restoreRun) {
|
||||
$service->recordFailure($run, (string) $restoreRunId, 'Restore run not found');
|
||||
$failed++;
|
||||
$failures[] = ['code' => 'restore_run.not_found', 'message' => "Restore run {$restoreRunId} not found."];
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: [
|
||||
'total' => $totalItems,
|
||||
'processed' => $itemCount,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'skipped' => $skipped,
|
||||
'deleted' => $succeeded,
|
||||
],
|
||||
failures: array_merge($failures, [
|
||||
['code' => 'restore_run.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if ($run->user) {
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
@ -75,7 +126,6 @@ public function handle(BulkOperationService $service): void
|
||||
}
|
||||
|
||||
if (! $restoreRun->trashed()) {
|
||||
$service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Not archived');
|
||||
$skipped++;
|
||||
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
||||
|
||||
@ -83,38 +133,76 @@ public function handle(BulkOperationService $service): void
|
||||
}
|
||||
|
||||
$restoreRun->forceDelete();
|
||||
$service->recordSuccess($run);
|
||||
$succeeded++;
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $restoreRunId, $e->getMessage());
|
||||
$failed++;
|
||||
$failures[] = ['code' => 'restore_run.force_delete.failed', 'message' => $e->getMessage()];
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: [
|
||||
'total' => $totalItems,
|
||||
'processed' => $itemCount,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'skipped' => $skipped,
|
||||
'deleted' => $succeeded,
|
||||
],
|
||||
failures: array_merge($failures, [
|
||||
['code' => 'restore_run.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if ($run->user) {
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Force Delete Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
$outcome = OperationRunOutcome::Succeeded->value;
|
||||
|
||||
if (! $run->user) {
|
||||
return;
|
||||
if ($failed > 0 && $failed < $totalItems) {
|
||||
$outcome = OperationRunOutcome::PartiallySucceeded->value;
|
||||
}
|
||||
|
||||
if ($failed >= $totalItems && $totalItems > 0) {
|
||||
$outcome = OperationRunOutcome::Failed->value;
|
||||
}
|
||||
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: $outcome,
|
||||
summaryCounts: [
|
||||
'total' => $totalItems,
|
||||
'processed' => $totalItems,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'skipped' => $skipped,
|
||||
'deleted' => $succeeded,
|
||||
],
|
||||
failures: $failures,
|
||||
);
|
||||
}
|
||||
|
||||
$message = "Force deleted {$succeeded} restore runs";
|
||||
@ -144,7 +232,12 @@ public function handle(BulkOperationService $service): void
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,9 +2,15 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@ -17,19 +23,41 @@ class BulkRestoreRunRestoreJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function __construct(
|
||||
public int $bulkRunId,
|
||||
) {}
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
/** @var array<int, int|string> */
|
||||
public array $restoreRunIds,
|
||||
?OperationRun $operationRun = null,
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(BulkOperationService $service): void
|
||||
public function middleware(): array
|
||||
{
|
||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
||||
return [new TrackOperationRun];
|
||||
}
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
return;
|
||||
public function handle(OperationRunService $operationRunService): void
|
||||
{
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new \RuntimeException('Tenant not found.');
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
$user = User::query()->find($this->userId);
|
||||
if (! $user instanceof User) {
|
||||
throw new \RuntimeException('User not found.');
|
||||
}
|
||||
|
||||
$ids = collect($this->restoreRunIds)
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$itemCount = 0;
|
||||
$succeeded = 0;
|
||||
@ -37,34 +65,56 @@ public function handle(BulkOperationService $service): void
|
||||
$skipped = 0;
|
||||
$skipReasons = [];
|
||||
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failures = [];
|
||||
|
||||
$totalItems = count($ids);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $restoreRunId) {
|
||||
foreach ($ids as $restoreRunId) {
|
||||
$itemCount++;
|
||||
|
||||
try {
|
||||
/** @var RestoreRun|null $restoreRun */
|
||||
$restoreRun = RestoreRun::withTrashed()
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->whereKey($restoreRunId)
|
||||
->first();
|
||||
|
||||
if (! $restoreRun) {
|
||||
$service->recordFailure($run, (string) $restoreRunId, 'Restore run not found');
|
||||
$failed++;
|
||||
$failures[] = ['code' => 'restore_run.not_found', 'message' => "Restore run {$restoreRunId} not found."];
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: [
|
||||
'total' => $totalItems,
|
||||
'processed' => $itemCount,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'skipped' => $skipped,
|
||||
],
|
||||
failures: array_merge($failures, [
|
||||
['code' => 'restore_run.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if ($run->user) {
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Restore Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
@ -75,7 +125,6 @@ public function handle(BulkOperationService $service): void
|
||||
}
|
||||
|
||||
if (! $restoreRun->trashed()) {
|
||||
$service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Not archived');
|
||||
$skipped++;
|
||||
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
||||
|
||||
@ -83,38 +132,74 @@ public function handle(BulkOperationService $service): void
|
||||
}
|
||||
|
||||
$restoreRun->restore();
|
||||
$service->recordSuccess($run);
|
||||
$succeeded++;
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $restoreRunId, $e->getMessage());
|
||||
$failed++;
|
||||
$failures[] = ['code' => 'restore_run.restore.failed', 'message' => $e->getMessage()];
|
||||
|
||||
if ($failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: [
|
||||
'total' => $totalItems,
|
||||
'processed' => $itemCount,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'skipped' => $skipped,
|
||||
],
|
||||
failures: array_merge($failures, [
|
||||
['code' => 'restore_run.circuit_breaker', 'message' => 'Circuit breaker: more than 50% of items failed.'],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
if ($run->user) {
|
||||
if ($user) {
|
||||
Notification::make()
|
||||
->title('Bulk Restore Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
}
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
$outcome = OperationRunOutcome::Succeeded->value;
|
||||
|
||||
if (! $run->user) {
|
||||
return;
|
||||
if ($failed > 0 && $failed < $totalItems) {
|
||||
$outcome = OperationRunOutcome::PartiallySucceeded->value;
|
||||
}
|
||||
|
||||
if ($failed >= $totalItems && $totalItems > 0) {
|
||||
$outcome = OperationRunOutcome::Failed->value;
|
||||
}
|
||||
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: $outcome,
|
||||
summaryCounts: [
|
||||
'total' => $totalItems,
|
||||
'processed' => $totalItems,
|
||||
'succeeded' => $succeeded,
|
||||
'failed' => $failed,
|
||||
'skipped' => $skipped,
|
||||
],
|
||||
failures: $failures,
|
||||
);
|
||||
}
|
||||
|
||||
$message = "Restored {$succeeded} restore runs";
|
||||
@ -144,7 +229,12 @@ public function handle(BulkOperationService $service): void
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->actions($this->operationRun ? [
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($this->operationRun, $tenant)),
|
||||
] : [])
|
||||
->sendToDatabase($user)
|
||||
->send();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,151 +2,92 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use Filament\Notifications\Notification;
|
||||
use App\Jobs\Operations\TenantSyncWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
use RuntimeException;
|
||||
|
||||
class BulkTenantSyncJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public function __construct(public int $bulkRunId) {}
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
public function handle(BulkOperationService $service, PolicySyncService $syncService): void
|
||||
/**
|
||||
* @param array<int, mixed> $tenantIds
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public array $tenantIds,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(OperationRunService $runs): void
|
||||
{
|
||||
$run = BulkOperationRun::with(['tenant', 'user'])->find($this->bulkRunId);
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for BulkTenantSyncJob.');
|
||||
}
|
||||
|
||||
if (! $run || $run->status !== 'pending') {
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$service->start($run);
|
||||
$runs->updateRun($this->operationRun, 'running');
|
||||
|
||||
try {
|
||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
||||
$itemCount = 0;
|
||||
$ids = $this->normalizeIds($this->tenantIds);
|
||||
|
||||
$supported = config('tenantpilot.supported_policy_types');
|
||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||
|
||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
||||
$failureThreshold = (int) floor($totalItems / 2);
|
||||
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||
$chunkSize = max(1, $chunkSize);
|
||||
|
||||
foreach (($run->item_ids ?? []) as $tenantId) {
|
||||
$itemCount++;
|
||||
|
||||
try {
|
||||
$tenant = Tenant::query()->whereKey($tenantId)->first();
|
||||
|
||||
if (! $tenant) {
|
||||
$service->recordFailure($run, (string) $tenantId, 'Tenant not found');
|
||||
|
||||
if ($run->failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Sync Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $tenant->isActive()) {
|
||||
$service->recordSkippedWithReason($run, (string) $tenantId, 'Tenant is not active');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $run->user || ! $run->user->canSyncTenant($tenant)) {
|
||||
$service->recordSkippedWithReason($run, (string) $tenantId, 'Not authorized to sync tenant');
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$syncService->syncPolicies($tenant, $supported);
|
||||
|
||||
$service->recordSuccess($run);
|
||||
} catch (Throwable $e) {
|
||||
$service->recordFailure($run, (string) $tenantId, $e->getMessage());
|
||||
|
||||
if ($run->failed > $failureThreshold) {
|
||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Sync Aborted')
|
||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||
->icon('heroicon-o-exclamation-triangle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($itemCount % $chunkSize === 0) {
|
||||
$run->refresh();
|
||||
}
|
||||
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||
foreach ($chunk as $targetTenantId) {
|
||||
dispatch(new TenantSyncWorkerJob(
|
||||
tenantId: $targetTenantId,
|
||||
userId: $this->userId,
|
||||
operationRun: $this->operationRun,
|
||||
context: $this->context,
|
||||
));
|
||||
}
|
||||
|
||||
$service->complete($run);
|
||||
|
||||
if ($run->user) {
|
||||
$message = "Synced {$run->succeeded} tenant(s)";
|
||||
|
||||
if ($run->skipped > 0) {
|
||||
$message .= " ({$run->skipped} skipped)";
|
||||
}
|
||||
|
||||
if ($run->failed > 0) {
|
||||
$message .= " ({$run->failed} failed)";
|
||||
}
|
||||
|
||||
$message .= '.';
|
||||
|
||||
Notification::make()
|
||||
->title('Bulk Sync Completed')
|
||||
->body($message)
|
||||
->icon('heroicon-o-check-circle')
|
||||
->success()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$service->fail($run, $e->getMessage());
|
||||
|
||||
$run->refresh();
|
||||
$run->load('user');
|
||||
|
||||
if ($run->user) {
|
||||
Notification::make()
|
||||
->title('Bulk Sync Failed')
|
||||
->body($e->getMessage())
|
||||
->icon('heroicon-o-x-circle')
|
||||
->danger()
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $ids
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function normalizeIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (is_int($id)) {
|
||||
$normalized[] = $id;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (is_numeric($id)) {
|
||||
$normalized[] = (int) $id;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,17 +2,15 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Notifications\RunStatusChangedNotification;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\VersionService;
|
||||
use App\Jobs\Operations\CapturePolicySnapshotWorkerJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Throwable;
|
||||
use RuntimeException;
|
||||
|
||||
class CapturePolicySnapshotJob implements ShouldQueue
|
||||
{
|
||||
@ -21,87 +19,48 @@ class CapturePolicySnapshotJob implements ShouldQueue
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $bulkOperationRunId,
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $policyId,
|
||||
public bool $includeAssignments = true,
|
||||
public bool $includeScopeTags = true,
|
||||
public ?string $createdBy = null,
|
||||
) {}
|
||||
|
||||
public function handle(BulkOperationService $bulkOperationService, VersionService $versionService): void
|
||||
{
|
||||
$run = BulkOperationRun::query()->with(['tenant', 'user'])->find($this->bulkOperationRunId);
|
||||
|
||||
if (! $run) {
|
||||
return;
|
||||
}
|
||||
|
||||
$policy = Policy::query()->with('tenant')->find($this->policyId);
|
||||
|
||||
if (! $policy || ! $policy->tenant) {
|
||||
$bulkOperationService->abort($run, 'policy_not_found');
|
||||
$this->notifyStatus($run, 'failed');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notifyStatus($run, 'queued');
|
||||
$bulkOperationService->start($run);
|
||||
$this->notifyStatus($run, 'running');
|
||||
|
||||
try {
|
||||
$versionService->captureFromGraph(
|
||||
tenant: $policy->tenant,
|
||||
policy: $policy,
|
||||
createdBy: $this->createdBy,
|
||||
includeAssignments: $this->includeAssignments,
|
||||
includeScopeTags: $this->includeScopeTags,
|
||||
);
|
||||
|
||||
$bulkOperationService->recordSuccess($run);
|
||||
$bulkOperationService->complete($run);
|
||||
|
||||
$this->notifyStatus($run, $run->refresh()->status);
|
||||
} catch (Throwable $e) {
|
||||
$bulkOperationService->recordFailure(
|
||||
run: $run,
|
||||
itemId: (string) $policy->getKey(),
|
||||
reason: $bulkOperationService->sanitizeFailureReason($e->getMessage())
|
||||
);
|
||||
|
||||
$bulkOperationService->complete($run);
|
||||
|
||||
$this->notifyStatus($run->refresh(), $run->status);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
private function notifyStatus(BulkOperationRun $run, string $status): void
|
||||
public function handle(OperationRunService $runs): void
|
||||
{
|
||||
if (! $run->relationLoaded('user')) {
|
||||
$run->loadMissing('user');
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for CapturePolicySnapshotJob.');
|
||||
}
|
||||
|
||||
if (! $run->user) {
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$normalizedStatus = $status === 'pending' ? 'queued' : $status;
|
||||
$runs->updateRun($this->operationRun, 'running');
|
||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => 1]);
|
||||
|
||||
$run->user->notify(new RunStatusChangedNotification([
|
||||
'tenant_id' => (int) $run->tenant_id,
|
||||
'run_type' => 'bulk_operation',
|
||||
'run_id' => (int) $run->getKey(),
|
||||
'status' => (string) $normalizedStatus,
|
||||
'counts' => [
|
||||
'total' => (int) $run->total_items,
|
||||
'processed' => (int) $run->processed_items,
|
||||
'succeeded' => (int) $run->succeeded,
|
||||
'failed' => (int) $run->failed,
|
||||
'skipped' => (int) $run->skipped,
|
||||
],
|
||||
]));
|
||||
dispatch(new CapturePolicySnapshotWorkerJob(
|
||||
tenantId: $this->tenantId,
|
||||
userId: $this->userId,
|
||||
policyId: $this->policyId,
|
||||
includeAssignments: $this->includeAssignments,
|
||||
includeScopeTags: $this->includeScopeTags,
|
||||
createdBy: $this->createdBy,
|
||||
operationRun: $this->operationRun,
|
||||
context: $this->context,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -6,9 +6,9 @@
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\User;
|
||||
use App\Notifications\RunStatusChangedNotification;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\RestoreService;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use App\Support\RestoreRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Bus\Queueable;
|
||||
@ -28,7 +28,7 @@ public function __construct(
|
||||
public ?string $actorName = null,
|
||||
) {}
|
||||
|
||||
public function handle(RestoreService $restoreService, AuditLogger $auditLogger, BulkOperationService $bulkOperationService): void
|
||||
public function handle(RestoreService $restoreService, AuditLogger $auditLogger): void
|
||||
{
|
||||
$restoreRun = RestoreRun::with(['tenant', 'backupSet'])->find($this->restoreRunId);
|
||||
|
||||
@ -122,7 +122,7 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger,
|
||||
} catch (Throwable $throwable) {
|
||||
$restoreRun->refresh();
|
||||
|
||||
$safeReason = $bulkOperationService->sanitizeFailureReason($throwable->getMessage());
|
||||
$safeReason = RunFailureSanitizer::sanitizeMessage($throwable->getMessage());
|
||||
|
||||
if ($restoreRun->status === RestoreRunStatus::Running->value) {
|
||||
$restoreRun->update([
|
||||
|
||||
@ -2,17 +2,12 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Drift\DriftFindingGenerator;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -28,62 +23,78 @@ class GenerateDriftFindingsJob implements ShouldQueue
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $baselineRunId,
|
||||
public int $currentRunId,
|
||||
public string $scopeKey,
|
||||
public int $bulkOperationRunId,
|
||||
?OperationRun $operationRun = null
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function middleware(): array
|
||||
{
|
||||
return [new TrackOperationRun];
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(DriftFindingGenerator $generator, BulkOperationService $bulkOperationService): void
|
||||
{
|
||||
public function handle(
|
||||
DriftFindingGenerator $generator,
|
||||
OperationRunService $runs,
|
||||
TargetScopeConcurrencyLimiter $limiter,
|
||||
): void {
|
||||
Log::info('GenerateDriftFindingsJob: started', [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'baseline_run_id' => $this->baselineRunId,
|
||||
'current_run_id' => $this->currentRunId,
|
||||
'scope_key' => $this->scopeKey,
|
||||
'bulk_operation_run_id' => $this->bulkOperationRunId,
|
||||
]);
|
||||
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('Tenant not found.');
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for drift generation.');
|
||||
}
|
||||
|
||||
$baseline = InventorySyncRun::query()->find($this->baselineRunId);
|
||||
if (! $baseline instanceof InventorySyncRun) {
|
||||
throw new RuntimeException('Baseline run not found.');
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$current = InventorySyncRun::query()->find($this->currentRunId);
|
||||
if (! $current instanceof InventorySyncRun) {
|
||||
throw new RuntimeException('Current run not found.');
|
||||
$opContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$targetScope = is_array($opContext['target_scope'] ?? null) ? $opContext['target_scope'] : [];
|
||||
|
||||
$lock = $limiter->acquireSlot($this->tenantId, $targetScope);
|
||||
|
||||
if (! $lock) {
|
||||
$delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3);
|
||||
$this->release(max(1, $delay));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$run = BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->find($this->bulkOperationRunId);
|
||||
|
||||
if (! $run instanceof BulkOperationRun) {
|
||||
throw new RuntimeException('Bulk operation run not found.');
|
||||
}
|
||||
|
||||
$bulkOperationService->start($run);
|
||||
|
||||
try {
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
throw new RuntimeException('Tenant not found.');
|
||||
}
|
||||
|
||||
$baseline = InventorySyncRun::query()->find($this->baselineRunId);
|
||||
if (! $baseline instanceof InventorySyncRun) {
|
||||
throw new RuntimeException('Baseline run not found.');
|
||||
}
|
||||
|
||||
$current = InventorySyncRun::query()->find($this->currentRunId);
|
||||
if (! $current instanceof InventorySyncRun) {
|
||||
throw new RuntimeException('Current run not found.');
|
||||
}
|
||||
|
||||
$runs->updateRun($this->operationRun, 'running');
|
||||
|
||||
$counts = is_array($this->operationRun->summary_counts ?? null) ? $this->operationRun->summary_counts : [];
|
||||
if ((int) ($counts['total'] ?? 0) === 0) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => 1]);
|
||||
}
|
||||
|
||||
$created = $generator->generate(
|
||||
tenant: $tenant,
|
||||
baseline: $baseline,
|
||||
@ -96,118 +107,40 @@ public function handle(DriftFindingGenerator $generator, BulkOperationService $b
|
||||
'baseline_run_id' => $this->baselineRunId,
|
||||
'current_run_id' => $this->currentRunId,
|
||||
'scope_key' => $this->scopeKey,
|
||||
'bulk_operation_run_id' => $this->bulkOperationRunId,
|
||||
'created_findings_count' => $created,
|
||||
]);
|
||||
|
||||
$bulkOperationService->recordSuccess($run);
|
||||
$bulkOperationService->complete($run);
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'created' => $created,
|
||||
]);
|
||||
|
||||
if ($this->operationRun) {
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opService->updateRun(
|
||||
$this->operationRun,
|
||||
'completed',
|
||||
'succeeded',
|
||||
['findings_created' => $created]
|
||||
);
|
||||
}
|
||||
|
||||
$this->notifyStatus($run->refresh());
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('GenerateDriftFindingsJob: failed', [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'baseline_run_id' => $this->baselineRunId,
|
||||
'current_run_id' => $this->currentRunId,
|
||||
'scope_key' => $this->scopeKey,
|
||||
'bulk_operation_run_id' => $this->bulkOperationRunId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$bulkOperationService->recordFailure(
|
||||
run: $run,
|
||||
itemId: $this->scopeKey,
|
||||
reason: $e->getMessage(),
|
||||
reasonCode: 'unknown',
|
||||
);
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$bulkOperationService->fail($run, $e->getMessage());
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'drift.generate.failed',
|
||||
'message' => $e->getMessage(),
|
||||
]]);
|
||||
|
||||
// TrackOperationRun middleware might catch this, but explicit fail ensures structure
|
||||
if ($this->operationRun) {
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opService->failRun($this->operationRun, $e);
|
||||
}
|
||||
|
||||
$this->notifyStatus($run->refresh());
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
private function notifyStatus(BulkOperationRun $run): void
|
||||
{
|
||||
try {
|
||||
if (! $run->relationLoaded('user')) {
|
||||
$run->loadMissing('user');
|
||||
}
|
||||
|
||||
if (! $run->user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = Tenant::query()->find((int) $run->tenant_id);
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
return;
|
||||
}
|
||||
|
||||
$status = $run->statusBucket();
|
||||
|
||||
$title = match ($status) {
|
||||
'queued' => 'Drift generation queued',
|
||||
'running' => 'Drift generation started',
|
||||
'succeeded' => 'Drift generation completed',
|
||||
'partially succeeded' => 'Drift generation completed (partial)',
|
||||
default => 'Drift generation failed',
|
||||
};
|
||||
|
||||
$body = sprintf(
|
||||
'Total: %d, processed: %d, succeeded: %d, failed: %d, skipped: %d.',
|
||||
(int) $run->total_items,
|
||||
(int) $run->processed_items,
|
||||
(int) $run->succeeded,
|
||||
(int) $run->failed,
|
||||
(int) $run->skipped,
|
||||
);
|
||||
|
||||
$notification = Notification::make()
|
||||
->title($title)
|
||||
->body($body)
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $tenant) : OperationRunLinks::index($tenant)),
|
||||
]);
|
||||
|
||||
match ($status) {
|
||||
'succeeded' => $notification->success(),
|
||||
'partially succeeded' => $notification->warning(),
|
||||
'queued', 'running' => $notification->info(),
|
||||
default => $notification->danger(),
|
||||
};
|
||||
|
||||
$notification
|
||||
->sendToDatabase($run->user)
|
||||
->send();
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('GenerateDriftFindingsJob: status notification failed', [
|
||||
'tenant_id' => (int) $run->tenant_id,
|
||||
'bulk_operation_run_id' => (int) $run->getKey(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
120
app/Jobs/Operations/BackupSetDeleteWorkerJob.php
Normal file
120
app/Jobs/Operations/BackupSetDeleteWorkerJob.php
Normal file
@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Operations;
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
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;
|
||||
|
||||
class BackupSetDeleteWorkerJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $backupSetId,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void
|
||||
{
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for backup set bulk delete worker.');
|
||||
}
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : [];
|
||||
|
||||
$lock = $limiter->acquireSlot($this->tenantId, $targetScope);
|
||||
|
||||
if (! $lock) {
|
||||
$delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3);
|
||||
$this->release(max(1, $delay));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$backupSet = BackupSet::withTrashed()
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->whereKey($this->backupSetId)
|
||||
->first();
|
||||
|
||||
if (! $backupSet instanceof BackupSet) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'backup_set.not_found',
|
||||
'message' => 'Backup set '.$this->backupSetId.' not found.',
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($backupSet->trashed()) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$backupSet->delete();
|
||||
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'deleted' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
} catch (Throwable $e) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'backup_set.delete_failed',
|
||||
'message' => $e->getMessage(),
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
137
app/Jobs/Operations/BackupSetForceDeleteWorkerJob.php
Normal file
137
app/Jobs/Operations/BackupSetForceDeleteWorkerJob.php
Normal file
@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Operations;
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
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;
|
||||
|
||||
class BackupSetForceDeleteWorkerJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $backupSetId,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void
|
||||
{
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for backup set bulk force delete worker.');
|
||||
}
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : [];
|
||||
|
||||
$lock = $limiter->acquireSlot($this->tenantId, $targetScope);
|
||||
|
||||
if (! $lock) {
|
||||
$delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3);
|
||||
$this->release(max(1, $delay));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$backupSet = BackupSet::withTrashed()
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->whereKey($this->backupSetId)
|
||||
->first();
|
||||
|
||||
if (! $backupSet instanceof BackupSet) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'backup_set.not_found',
|
||||
'message' => 'Backup set '.$this->backupSetId.' not found.',
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $backupSet->trashed()) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($backupSet->restoreRuns()->withTrashed()->exists()) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'backup_set.referenced_by_restore_runs',
|
||||
'message' => 'Backup set '.$this->backupSetId.' is referenced by restore runs and cannot be force deleted.',
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$backupSet->items()->withTrashed()->forceDelete();
|
||||
$backupSet->forceDelete();
|
||||
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'deleted' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
} catch (Throwable $e) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'backup_set.force_delete_failed',
|
||||
'message' => $e->getMessage(),
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
121
app/Jobs/Operations/BackupSetRestoreWorkerJob.php
Normal file
121
app/Jobs/Operations/BackupSetRestoreWorkerJob.php
Normal file
@ -0,0 +1,121 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Operations;
|
||||
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
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;
|
||||
|
||||
class BackupSetRestoreWorkerJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $backupSetId,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void
|
||||
{
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for backup set bulk restore worker.');
|
||||
}
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : [];
|
||||
|
||||
$lock = $limiter->acquireSlot($this->tenantId, $targetScope);
|
||||
|
||||
if (! $lock) {
|
||||
$delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3);
|
||||
$this->release(max(1, $delay));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$backupSet = BackupSet::withTrashed()
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->whereKey($this->backupSetId)
|
||||
->first();
|
||||
|
||||
if (! $backupSet instanceof BackupSet) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'backup_set.not_found',
|
||||
'message' => 'Backup set '.$this->backupSetId.' not found.',
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $backupSet->trashed()) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$backupSet->restore();
|
||||
$backupSet->items()->withTrashed()->restore();
|
||||
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'updated' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
} catch (Throwable $e) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'backup_set.restore_failed',
|
||||
'message' => $e->getMessage(),
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
101
app/Jobs/Operations/BulkOperationOrchestratorJob.php
Normal file
101
app/Jobs/Operations/BulkOperationOrchestratorJob.php
Normal file
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use RuntimeException;
|
||||
|
||||
abstract class BulkOperationOrchestratorJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $itemIds
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public array $itemIds,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
$this->itemIds = $this->normalizeItemIds($itemIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function handle(OperationRunService $runs): void
|
||||
{
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for bulk orchestrator jobs.');
|
||||
}
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$runs->updateRun($this->operationRun, 'running');
|
||||
|
||||
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($this->itemIds)]);
|
||||
|
||||
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||
$chunkSize = max(1, $chunkSize);
|
||||
|
||||
foreach (array_chunk($this->itemIds, $chunkSize) as $chunk) {
|
||||
foreach ($chunk as $itemId) {
|
||||
dispatch($this->makeWorkerJob($itemId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract protected function makeWorkerJob(string $itemId): ShouldQueue;
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $itemIds
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function normalizeItemIds(array $itemIds): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($itemIds as $itemId) {
|
||||
if (is_int($itemId)) {
|
||||
$itemId = (string) $itemId;
|
||||
}
|
||||
|
||||
if (! is_string($itemId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$itemId = trim($itemId);
|
||||
if ($itemId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $itemId;
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
66
app/Jobs/Operations/BulkOperationWorkerJob.php
Normal file
66
app/Jobs/Operations/BulkOperationWorkerJob.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\OperationRunService;
|
||||
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;
|
||||
|
||||
abstract class BulkOperationWorkerJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public string $itemId,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
public function middleware(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
public function handle(OperationRunService $runs): void
|
||||
{
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for bulk worker jobs.');
|
||||
}
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->process($runs);
|
||||
} catch (Throwable $e) {
|
||||
$runs->failRun($this->operationRun, $e);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
abstract protected function process(OperationRunService $runs): void;
|
||||
}
|
||||
124
app/Jobs/Operations/CapturePolicySnapshotWorkerJob.php
Normal file
124
app/Jobs/Operations/CapturePolicySnapshotWorkerJob.php
Normal file
@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Services\Intune\VersionService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
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;
|
||||
|
||||
class CapturePolicySnapshotWorkerJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $policyId,
|
||||
public bool $includeAssignments = true,
|
||||
public bool $includeScopeTags = true,
|
||||
public ?string $createdBy = null,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(
|
||||
OperationRunService $runs,
|
||||
TargetScopeConcurrencyLimiter $limiter,
|
||||
VersionService $versionService,
|
||||
): void {
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for policy snapshot capture worker.');
|
||||
}
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : [];
|
||||
|
||||
$lock = $limiter->acquireSlot($this->tenantId, $targetScope);
|
||||
|
||||
if (! $lock) {
|
||||
$delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3);
|
||||
$this->release(max(1, $delay));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$policy = Policy::query()
|
||||
->with('tenant')
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->whereKey($this->policyId)
|
||||
->first();
|
||||
|
||||
if (! $policy instanceof Policy || ! $policy->tenant) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'policy.not_found',
|
||||
'message' => 'Policy '.$this->policyId.' not found.',
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$versionService->captureFromGraph(
|
||||
tenant: $policy->tenant,
|
||||
policy: $policy,
|
||||
createdBy: $this->createdBy,
|
||||
includeAssignments: $this->includeAssignments,
|
||||
includeScopeTags: $this->includeScopeTags,
|
||||
);
|
||||
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
} catch (Throwable $e) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'policy.capture_snapshot.failed',
|
||||
'message' => $e->getMessage(),
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
120
app/Jobs/Operations/PolicyBulkDeleteWorkerJob.php
Normal file
120
app/Jobs/Operations/PolicyBulkDeleteWorkerJob.php
Normal file
@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
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;
|
||||
|
||||
class PolicyBulkDeleteWorkerJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $policyId,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void
|
||||
{
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for policy bulk delete worker.');
|
||||
}
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : [];
|
||||
|
||||
$lock = $limiter->acquireSlot($this->tenantId, $targetScope);
|
||||
|
||||
if (! $lock) {
|
||||
$delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3);
|
||||
$this->release(max(1, $delay));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$policy = Policy::query()
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->whereKey($this->policyId)
|
||||
->first();
|
||||
|
||||
if (! $policy instanceof Policy) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'policy.not_found',
|
||||
'message' => 'Policy '.$this->policyId.' not found.',
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($policy->ignored_at) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$policy->ignore();
|
||||
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'deleted' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
} catch (Throwable $e) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'policy.delete_failed',
|
||||
'message' => $e->getMessage(),
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
120
app/Jobs/Operations/PolicyVersionForceDeleteWorkerJob.php
Normal file
120
app/Jobs/Operations/PolicyVersionForceDeleteWorkerJob.php
Normal file
@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
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;
|
||||
|
||||
class PolicyVersionForceDeleteWorkerJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $policyVersionId,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void
|
||||
{
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for policy version force delete worker.');
|
||||
}
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : [];
|
||||
|
||||
$lock = $limiter->acquireSlot($this->tenantId, $targetScope);
|
||||
|
||||
if (! $lock) {
|
||||
$delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3);
|
||||
$this->release(max(1, $delay));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$version = PolicyVersion::withTrashed()
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->whereKey($this->policyVersionId)
|
||||
->first();
|
||||
|
||||
if (! $version instanceof PolicyVersion) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'policy_version.not_found',
|
||||
'message' => 'Policy version '.$this->policyVersionId.' not found.',
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $version->trashed()) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$version->forceDelete();
|
||||
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'deleted' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
} catch (Throwable $e) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'policy_version.force_delete_failed',
|
||||
'message' => $e->getMessage(),
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
138
app/Jobs/Operations/PolicyVersionPruneWorkerJob.php
Normal file
138
app/Jobs/Operations/PolicyVersionPruneWorkerJob.php
Normal file
@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
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;
|
||||
|
||||
class PolicyVersionPruneWorkerJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $policyVersionId,
|
||||
public int $retentionDays = 90,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void
|
||||
{
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for policy version prune worker.');
|
||||
}
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : [];
|
||||
|
||||
$lock = $limiter->acquireSlot($this->tenantId, $targetScope);
|
||||
|
||||
if (! $lock) {
|
||||
$delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3);
|
||||
$this->release(max(1, $delay));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$version = PolicyVersion::withTrashed()
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->whereKey($this->policyVersionId)
|
||||
->first();
|
||||
|
||||
if (! $version instanceof PolicyVersion) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'policy_version.not_found',
|
||||
'message' => 'Policy version '.$this->policyVersionId.' not found.',
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($version->trashed()) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$eligible = PolicyVersion::query()
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->whereKey($version->id)
|
||||
->pruneEligible($this->retentionDays)
|
||||
->exists();
|
||||
|
||||
if (! $eligible) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$version->delete();
|
||||
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'deleted' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
} catch (Throwable $e) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'policy_version.prune_failed',
|
||||
'message' => $e->getMessage(),
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
120
app/Jobs/Operations/PolicyVersionRestoreWorkerJob.php
Normal file
120
app/Jobs/Operations/PolicyVersionRestoreWorkerJob.php
Normal file
@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
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;
|
||||
|
||||
class PolicyVersionRestoreWorkerJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $policyVersionId,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void
|
||||
{
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for policy version restore worker.');
|
||||
}
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : [];
|
||||
|
||||
$lock = $limiter->acquireSlot($this->tenantId, $targetScope);
|
||||
|
||||
if (! $lock) {
|
||||
$delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3);
|
||||
$this->release(max(1, $delay));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$version = PolicyVersion::withTrashed()
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->whereKey($this->policyVersionId)
|
||||
->first();
|
||||
|
||||
if (! $version instanceof PolicyVersion) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'policy_version.not_found',
|
||||
'message' => 'Policy version '.$this->policyVersionId.' not found.',
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $version->trashed()) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$version->restore();
|
||||
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'updated' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
} catch (Throwable $e) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'policy_version.restore_failed',
|
||||
'message' => $e->getMessage(),
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
131
app/Jobs/Operations/RestoreRunDeleteWorkerJob.php
Normal file
131
app/Jobs/Operations/RestoreRunDeleteWorkerJob.php
Normal file
@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
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;
|
||||
|
||||
class RestoreRunDeleteWorkerJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $restoreRunId,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(OperationRunService $runs, TargetScopeConcurrencyLimiter $limiter): void
|
||||
{
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for restore run delete worker.');
|
||||
}
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$targetScope = is_array($context['target_scope'] ?? null) ? $context['target_scope'] : [];
|
||||
|
||||
$lock = $limiter->acquireSlot($this->tenantId, $targetScope);
|
||||
|
||||
if (! $lock) {
|
||||
$delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3);
|
||||
$this->release(max(1, $delay));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$restoreRun = RestoreRun::withTrashed()
|
||||
->where('tenant_id', $this->tenantId)
|
||||
->whereKey($this->restoreRunId)
|
||||
->first();
|
||||
|
||||
if (! $restoreRun instanceof RestoreRun) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'restore_run.not_found',
|
||||
'message' => 'Restore run '.$this->restoreRunId.' not found.',
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($restoreRun->trashed()) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $restoreRun->isDeletable()) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$restoreRun->delete();
|
||||
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'deleted' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
} catch (Throwable $e) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'restore_run.delete_failed',
|
||||
'message' => $e->getMessage(),
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
136
app/Jobs/Operations/TenantSyncWorkerJob.php
Normal file
136
app/Jobs/Operations/TenantSyncWorkerJob.php
Normal file
@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs\Operations;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||
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;
|
||||
|
||||
class TenantSyncWorkerJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
?OperationRun $operationRun = null,
|
||||
public array $context = [],
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
}
|
||||
|
||||
public function handle(
|
||||
OperationRunService $runs,
|
||||
TargetScopeConcurrencyLimiter $limiter,
|
||||
PolicySyncService $syncService,
|
||||
): void {
|
||||
if (! $this->operationRun instanceof OperationRun) {
|
||||
throw new RuntimeException('OperationRun is required for tenant sync worker.');
|
||||
}
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
if ($this->operationRun->status === 'completed') {
|
||||
return;
|
||||
}
|
||||
|
||||
$lock = null;
|
||||
|
||||
try {
|
||||
$tenant = Tenant::query()->whereKey($this->tenantId)->first();
|
||||
|
||||
if (! $tenant instanceof Tenant) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'tenant.not_found',
|
||||
'message' => 'Tenant '.$this->tenantId.' not found.',
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$lock = $limiter->acquireSlot($tenant->getKey(), [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
]);
|
||||
|
||||
if (! $lock) {
|
||||
$delay = (int) config('tenantpilot.bulk_operations.poll_interval_seconds', 3);
|
||||
$this->release(max(1, $delay));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $tenant->isActive()) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$user = User::query()->whereKey($this->userId)->first();
|
||||
|
||||
if (! $user instanceof User || ! $user->canSyncTenant($tenant)) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'skipped' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$supported = config('tenantpilot.supported_policy_types', []);
|
||||
|
||||
$syncService->syncPolicies($tenant, $supported);
|
||||
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
} catch (Throwable $e) {
|
||||
$runs->incrementSummaryCounts($this->operationRun, [
|
||||
'processed' => 1,
|
||||
'failed' => 1,
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'tenant.sync_failed',
|
||||
'message' => $e->getMessage(),
|
||||
]]);
|
||||
|
||||
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||
|
||||
throw $e;
|
||||
} finally {
|
||||
$lock?->release();
|
||||
}
|
||||
}
|
||||
}
|
||||
51
app/Jobs/ReconcileAdapterRunsJob.php
Normal file
51
app/Jobs/ReconcileAdapterRunsJob.php
Normal file
@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Services\AdapterRunReconciler;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class ReconcileAdapterRunsJob implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
/**
|
||||
* Create a new job instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(): void
|
||||
{
|
||||
try {
|
||||
/** @var AdapterRunReconciler $reconciler */
|
||||
$reconciler = app(AdapterRunReconciler::class);
|
||||
|
||||
$result = $reconciler->reconcile([
|
||||
'older_than_minutes' => 60,
|
||||
'limit' => 50,
|
||||
'dry_run' => false,
|
||||
]);
|
||||
|
||||
Log::info('ReconcileAdapterRunsJob completed', [
|
||||
'candidates' => (int) ($result['candidates'] ?? 0),
|
||||
'reconciled' => (int) ($result['reconciled'] ?? 0),
|
||||
'skipped' => (int) ($result['skipped'] ?? 0),
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::warning('ReconcileAdapterRunsJob failed', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,10 +8,10 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@ -48,7 +48,6 @@ public function middleware(): array
|
||||
|
||||
public function handle(
|
||||
AuditLogger $auditLogger,
|
||||
BulkOperationService $bulkOperationService,
|
||||
): void {
|
||||
$backupSet = BackupSet::query()->with(['tenant'])->find($this->backupSetId);
|
||||
|
||||
@ -60,7 +59,7 @@ public function handle(
|
||||
$this->operationRun,
|
||||
'completed',
|
||||
'failed',
|
||||
['backup_set_id' => $this->backupSetId],
|
||||
['failed' => 1],
|
||||
[['code' => 'backup_set.not_found', 'message' => 'Backup set not found.']]
|
||||
);
|
||||
}
|
||||
@ -97,7 +96,7 @@ public function handle(
|
||||
foreach ($missingIds as $missingId) {
|
||||
$failures[] = [
|
||||
'code' => 'backup_item.not_found',
|
||||
'message' => $bulkOperationService->sanitizeFailureReason("Backup item {$missingId} not found (already removed?)."),
|
||||
'message' => RunFailureSanitizer::sanitizeMessage("Backup item {$missingId} not found (already removed?)."),
|
||||
];
|
||||
}
|
||||
|
||||
@ -148,6 +147,16 @@ public function handle(
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
$this->operationRun->update([
|
||||
'context' => array_merge($this->operationRun->context ?? [], [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'requested_count' => $requestedCount,
|
||||
'removed_count' => $removed,
|
||||
'missing_count' => count($missingIds),
|
||||
'remaining_count' => (int) $backupSet->item_count,
|
||||
]),
|
||||
]);
|
||||
|
||||
$outcome = 'succeeded';
|
||||
if ($removed === 0) {
|
||||
$outcome = 'failed';
|
||||
@ -160,11 +169,12 @@ public function handle(
|
||||
'completed',
|
||||
$outcome,
|
||||
[
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'requested' => $requestedCount,
|
||||
'removed' => $removed,
|
||||
'missing' => count($missingIds),
|
||||
'remaining' => (int) $backupSet->item_count,
|
||||
'total' => $requestedCount,
|
||||
'processed' => $requestedCount,
|
||||
'succeeded' => $removed,
|
||||
'failed' => count($missingIds),
|
||||
'deleted' => $removed,
|
||||
'items' => $requestedCount,
|
||||
],
|
||||
$failures,
|
||||
);
|
||||
@ -205,7 +215,7 @@ public function handle(
|
||||
$this->notifyFailed(
|
||||
initiator: $initiator,
|
||||
tenant: $tenant instanceof Tenant ? $tenant : null,
|
||||
reason: $bulkOperationService->sanitizeFailureReason($throwable->getMessage()),
|
||||
reason: RunFailureSanitizer::sanitizeMessage($throwable->getMessage()),
|
||||
);
|
||||
|
||||
throw $throwable;
|
||||
|
||||
@ -5,13 +5,11 @@
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\BackupScheduling\RunErrorMapper;
|
||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
@ -39,7 +37,6 @@ class RunBackupScheduleJob implements ShouldQueue
|
||||
|
||||
public function __construct(
|
||||
public int $backupScheduleRunId,
|
||||
public ?int $bulkRunId = null,
|
||||
?OperationRun $operationRun = null,
|
||||
) {
|
||||
$this->operationRun = $operationRun;
|
||||
@ -57,7 +54,6 @@ public function handle(
|
||||
ScheduleTimeService $scheduleTimeService,
|
||||
AuditLogger $auditLogger,
|
||||
RunErrorMapper $errorMapper,
|
||||
BulkOperationService $bulkOperationService,
|
||||
): void {
|
||||
$run = BackupScheduleRun::query()
|
||||
->with(['schedule', 'tenant', 'user'])
|
||||
@ -67,9 +63,8 @@ public function handle(
|
||||
if ($this->operationRun) {
|
||||
$this->markOperationRunFailed(
|
||||
run: $this->operationRun,
|
||||
bulkOperationService: $bulkOperationService,
|
||||
summaryCounts: [],
|
||||
reasonCode: 'RUN_NOT_FOUND',
|
||||
reasonCode: 'run_not_found',
|
||||
reason: 'Backup schedule run not found.',
|
||||
);
|
||||
}
|
||||
@ -99,21 +94,6 @@ public function handle(
|
||||
}
|
||||
}
|
||||
|
||||
$bulkRun = $this->bulkRunId
|
||||
? BulkOperationRun::query()->with(['tenant', 'user'])->find($this->bulkRunId)
|
||||
: null;
|
||||
|
||||
if (
|
||||
$bulkRun
|
||||
&& ($bulkRun->tenant_id !== $run->tenant_id || $bulkRun->user_id !== $run->user_id)
|
||||
) {
|
||||
$bulkRun = null;
|
||||
}
|
||||
|
||||
if ($bulkRun && $bulkRun->status === 'pending') {
|
||||
$bulkOperationService->start($bulkRun);
|
||||
}
|
||||
|
||||
$schedule = $run->schedule;
|
||||
|
||||
if (! $schedule instanceof BackupSchedule) {
|
||||
@ -127,12 +107,12 @@ public function handle(
|
||||
if ($this->operationRun) {
|
||||
$this->markOperationRunFailed(
|
||||
run: $this->operationRun,
|
||||
bulkOperationService: $bulkOperationService,
|
||||
summaryCounts: [
|
||||
'backup_schedule_id' => (int) $run->backup_schedule_id,
|
||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'failed' => 1,
|
||||
],
|
||||
reasonCode: 'SCHEDULE_NOT_FOUND',
|
||||
reasonCode: 'schedule_not_found',
|
||||
reason: 'Schedule not found.',
|
||||
);
|
||||
}
|
||||
@ -151,12 +131,12 @@ public function handle(
|
||||
if ($this->operationRun) {
|
||||
$this->markOperationRunFailed(
|
||||
run: $this->operationRun,
|
||||
bulkOperationService: $bulkOperationService,
|
||||
summaryCounts: [
|
||||
'backup_schedule_id' => (int) $run->backup_schedule_id,
|
||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'failed' => 1,
|
||||
],
|
||||
reasonCode: 'TENANT_NOT_FOUND',
|
||||
reasonCode: 'tenant_not_found',
|
||||
reason: 'Tenant not found.',
|
||||
);
|
||||
}
|
||||
@ -175,14 +155,12 @@ public function handle(
|
||||
errorMessage: 'Another run is already in progress for this schedule.',
|
||||
summary: ['reason' => 'concurrent_run'],
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
bulkRunId: $this->bulkRunId,
|
||||
);
|
||||
|
||||
$this->syncOperationRunFromRun(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
run: $run->refresh(),
|
||||
bulkOperationService: $bulkOperationService,
|
||||
);
|
||||
|
||||
return;
|
||||
@ -228,14 +206,12 @@ public function handle(
|
||||
'unknown_policy_types' => $unknownTypes,
|
||||
],
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
bulkRunId: $this->bulkRunId,
|
||||
);
|
||||
|
||||
$this->syncOperationRunFromRun(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
run: $run->refresh(),
|
||||
bulkOperationService: $bulkOperationService,
|
||||
);
|
||||
|
||||
return;
|
||||
@ -294,14 +270,12 @@ public function handle(
|
||||
summary: $summary,
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
backupSetId: (string) $backupSet->id,
|
||||
bulkRunId: $this->bulkRunId,
|
||||
);
|
||||
|
||||
$this->syncOperationRunFromRun(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
run: $run->refresh(),
|
||||
bulkOperationService: $bulkOperationService,
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
@ -346,14 +320,12 @@ public function handle(
|
||||
'attempt' => $attempt,
|
||||
],
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
bulkRunId: $this->bulkRunId,
|
||||
);
|
||||
|
||||
$this->syncOperationRunFromRun(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
run: $run->refresh(),
|
||||
bulkOperationService: $bulkOperationService,
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
@ -438,7 +410,6 @@ private function syncOperationRunFromRun(
|
||||
Tenant $tenant,
|
||||
BackupSchedule $schedule,
|
||||
BackupScheduleRun $run,
|
||||
BulkOperationService $bulkOperationService,
|
||||
): void {
|
||||
if (! $this->operationRun) {
|
||||
return;
|
||||
@ -457,23 +428,29 @@ private function syncOperationRunFromRun(
|
||||
$summary = is_array($run->summary) ? $run->summary : [];
|
||||
$syncFailures = $summary['sync_failures'] ?? [];
|
||||
|
||||
$summaryCounts = [
|
||||
'backup_schedule_id' => (int) $schedule->getKey(),
|
||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||
'backup_set_id' => $run->backup_set_id ? (int) $run->backup_set_id : null,
|
||||
'policies_total' => (int) ($summary['policies_total'] ?? 0),
|
||||
'policies_backed_up' => (int) ($summary['policies_backed_up'] ?? 0),
|
||||
'sync_failures' => is_array($syncFailures) ? count($syncFailures) : 0,
|
||||
];
|
||||
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
||||
$policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0);
|
||||
$syncFailureCount = is_array($syncFailures) ? count($syncFailures) : 0;
|
||||
|
||||
$summaryCounts = array_filter($summaryCounts, fn (mixed $value): bool => $value !== null);
|
||||
$failedCount = max(0, $policiesTotal - $policiesBackedUp);
|
||||
|
||||
$summaryCounts = [
|
||||
'total' => $policiesTotal,
|
||||
'processed' => $policiesTotal,
|
||||
'succeeded' => $policiesBackedUp,
|
||||
'failed' => $failedCount,
|
||||
'skipped' => 0,
|
||||
'created' => filled($run->backup_set_id) ? 1 : 0,
|
||||
'updated' => $policiesBackedUp,
|
||||
'items' => $policiesTotal,
|
||||
];
|
||||
|
||||
$failures = [];
|
||||
|
||||
if (filled($run->error_message) || filled($run->error_code)) {
|
||||
$failures[] = [
|
||||
'code' => (string) ($run->error_code ?: 'BACKUP_SCHEDULE_ERROR'),
|
||||
'message' => $bulkOperationService->sanitizeFailureReason((string) ($run->error_message ?: 'Backup schedule run failed.')),
|
||||
'code' => strtolower((string) ($run->error_code ?: 'backup_schedule_error')),
|
||||
'message' => (string) ($run->error_message ?: 'Backup schedule run failed.'),
|
||||
];
|
||||
}
|
||||
|
||||
@ -501,8 +478,8 @@ private function syncOperationRunFromRun(
|
||||
}
|
||||
|
||||
$failures[] = [
|
||||
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR',
|
||||
'message' => $bulkOperationService->sanitizeFailureReason($message),
|
||||
'code' => $status !== null ? 'graph_http_'.(string) $status : 'graph_error',
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -514,6 +491,7 @@ private function syncOperationRunFromRun(
|
||||
'context' => array_merge($this->operationRun->context ?? [], [
|
||||
'backup_schedule_id' => (int) $schedule->getKey(),
|
||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||
'backup_set_id' => $run->backup_set_id ? (int) $run->backup_set_id : null,
|
||||
]),
|
||||
]);
|
||||
|
||||
@ -528,7 +506,6 @@ private function syncOperationRunFromRun(
|
||||
|
||||
private function markOperationRunFailed(
|
||||
OperationRun $run,
|
||||
BulkOperationService $bulkOperationService,
|
||||
array $summaryCounts,
|
||||
string $reasonCode,
|
||||
string $reason,
|
||||
@ -544,7 +521,7 @@ private function markOperationRunFailed(
|
||||
failures: [
|
||||
[
|
||||
'code' => $reasonCode,
|
||||
'message' => $bulkOperationService->sanitizeFailureReason($reason),
|
||||
'message' => $reason,
|
||||
],
|
||||
],
|
||||
);
|
||||
@ -578,7 +555,6 @@ private function finishRun(
|
||||
array $summary,
|
||||
ScheduleTimeService $scheduleTimeService,
|
||||
?string $backupSetId = null,
|
||||
?int $bulkRunId = null,
|
||||
): void {
|
||||
$nowUtc = CarbonImmutable::now('UTC');
|
||||
|
||||
@ -599,48 +575,6 @@ private function finishRun(
|
||||
|
||||
$this->notifyRunFinished($run, $schedule);
|
||||
|
||||
if ($bulkRunId) {
|
||||
$bulkRun = BulkOperationRun::query()->with(['tenant', 'user'])->find($bulkRunId);
|
||||
|
||||
if (
|
||||
$bulkRun
|
||||
&& ($bulkRun->tenant_id === $run->tenant_id)
|
||||
&& ($bulkRun->user_id === $run->user_id)
|
||||
&& in_array($bulkRun->status, ['pending', 'running'], true)
|
||||
) {
|
||||
$service = app(BulkOperationService::class);
|
||||
|
||||
$itemId = (string) $run->backup_schedule_id;
|
||||
|
||||
match ($status) {
|
||||
BackupScheduleRun::STATUS_SUCCESS => $service->recordSuccess($bulkRun),
|
||||
BackupScheduleRun::STATUS_SKIPPED => $service->recordSkippedWithReason(
|
||||
$bulkRun,
|
||||
$itemId,
|
||||
$errorMessage ?: 'Skipped',
|
||||
),
|
||||
BackupScheduleRun::STATUS_PARTIAL => $service->recordFailure(
|
||||
$bulkRun,
|
||||
$itemId,
|
||||
$errorMessage ?: 'Completed partially',
|
||||
),
|
||||
default => $service->recordFailure(
|
||||
$bulkRun,
|
||||
$itemId,
|
||||
$errorMessage ?: ($errorCode ?: 'Failed'),
|
||||
),
|
||||
};
|
||||
|
||||
$bulkRun->refresh();
|
||||
if (
|
||||
in_array($bulkRun->status, ['pending', 'running'], true)
|
||||
&& $bulkRun->processed_items >= $bulkRun->total_items
|
||||
) {
|
||||
$service->complete($bulkRun);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) {
|
||||
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
|
||||
}
|
||||
|
||||
@ -3,16 +3,16 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Inventory\InventorySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Notifications\Notification;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
@ -33,7 +33,6 @@ class RunInventorySyncJob implements ShouldQueue
|
||||
public function __construct(
|
||||
public int $tenantId,
|
||||
public int $userId,
|
||||
public int $bulkRunId,
|
||||
public int $inventorySyncRunId,
|
||||
?OperationRun $operationRun = null
|
||||
) {
|
||||
@ -53,7 +52,7 @@ public function middleware(): array
|
||||
/**
|
||||
* Execute the job.
|
||||
*/
|
||||
public function handle(BulkOperationService $bulkOperationService, InventorySyncService $inventorySyncService, AuditLogger $auditLogger): void
|
||||
public function handle(InventorySyncService $inventorySyncService, AuditLogger $auditLogger, OperationRunService $operationRunService): void
|
||||
{
|
||||
$tenant = Tenant::query()->find($this->tenantId);
|
||||
if (! $tenant instanceof Tenant) {
|
||||
@ -65,19 +64,19 @@ public function handle(BulkOperationService $bulkOperationService, InventorySync
|
||||
throw new RuntimeException('User not found.');
|
||||
}
|
||||
|
||||
$bulkRun = BulkOperationRun::query()->find($this->bulkRunId);
|
||||
if (! $bulkRun instanceof BulkOperationRun) {
|
||||
throw new RuntimeException('BulkOperationRun not found.');
|
||||
}
|
||||
|
||||
$run = InventorySyncRun::query()->find($this->inventorySyncRunId);
|
||||
if (! $run instanceof InventorySyncRun) {
|
||||
throw new RuntimeException('InventorySyncRun not found.');
|
||||
}
|
||||
|
||||
$bulkOperationService->start($bulkRun);
|
||||
$policyTypes = is_array($run->selection_payload['policy_types'] ?? null) ? $run->selection_payload['policy_types'] : [];
|
||||
if (! is_array($policyTypes)) {
|
||||
$policyTypes = [];
|
||||
}
|
||||
|
||||
$processedPolicyTypes = [];
|
||||
$successCount = 0;
|
||||
$failedCount = 0;
|
||||
|
||||
// Note: The TrackOperationRun middleware will automatically set status to 'running' at start.
|
||||
// It will also handle success completion if no exceptions thrown.
|
||||
@ -87,48 +86,36 @@ public function handle(BulkOperationService $bulkOperationService, InventorySync
|
||||
$run = $inventorySyncService->executePendingRun(
|
||||
$run,
|
||||
$tenant,
|
||||
function (string $policyType, bool $success, ?string $errorCode) use ($bulkOperationService, $bulkRun, &$processedPolicyTypes): void {
|
||||
function (string $policyType, bool $success, ?string $errorCode) use (&$processedPolicyTypes, &$successCount, &$failedCount): void {
|
||||
$processedPolicyTypes[] = $policyType;
|
||||
|
||||
if ($success) {
|
||||
$bulkOperationService->recordSuccess($bulkRun);
|
||||
$successCount++;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$bulkOperationService->recordFailure($bulkRun, $policyType, $errorCode ?? 'failed');
|
||||
$failedCount++;
|
||||
},
|
||||
);
|
||||
|
||||
$policyTypes = is_array($bulkRun->item_ids ?? null) ? $bulkRun->item_ids : [];
|
||||
if ($policyTypes === []) {
|
||||
$policyTypes = is_array($run->selection_payload['policy_types'] ?? null) ? $run->selection_payload['policy_types'] : [];
|
||||
}
|
||||
|
||||
// --- Helper to update OperationRun with rich context ---
|
||||
$updateOpRun = function (string $outcome, array $counts = [], array $failures = []) {
|
||||
if ($run->status === InventorySyncRun::STATUS_SUCCESS) {
|
||||
if ($this->operationRun) {
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opService->updateRun(
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
'completed',
|
||||
$outcome,
|
||||
$counts,
|
||||
$failures
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Succeeded->value,
|
||||
summaryCounts: [
|
||||
'total' => count($policyTypes),
|
||||
'processed' => count($policyTypes),
|
||||
'succeeded' => count($policyTypes),
|
||||
'failed' => 0,
|
||||
// Reuse allowed keys for inventory item stats.
|
||||
'items' => (int) $run->items_observed_count,
|
||||
'updated' => (int) $run->items_upserted_count,
|
||||
],
|
||||
);
|
||||
}
|
||||
};
|
||||
// -----------------------------------------------------
|
||||
|
||||
if ($run->status === InventorySyncRun::STATUS_SUCCESS) {
|
||||
$bulkOperationService->complete($bulkRun);
|
||||
|
||||
// Update Operation Run explicitly to provide counts
|
||||
$updateOpRun('succeeded', [
|
||||
'observed' => $run->items_observed_count,
|
||||
'upserted' => $run->items_upserted_count,
|
||||
]);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
@ -136,7 +123,6 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
||||
context: [
|
||||
'metadata' => [
|
||||
'inventory_sync_run_id' => $run->id,
|
||||
'bulk_run_id' => $bulkRun->id,
|
||||
'selection_hash' => $run->selection_hash,
|
||||
'observed' => $run->items_observed_count,
|
||||
'upserted' => $run->items_upserted_count,
|
||||
@ -165,16 +151,24 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
||||
}
|
||||
|
||||
if ($run->status === InventorySyncRun::STATUS_PARTIAL) {
|
||||
$bulkOperationService->complete($bulkRun);
|
||||
|
||||
$updateOpRun('partially_succeeded', [
|
||||
'observed' => $run->items_observed_count,
|
||||
'upserted' => $run->items_upserted_count,
|
||||
'errors' => $run->errors_count,
|
||||
], [
|
||||
// Minimal error summary
|
||||
['code' => 'PARTIAL_SYNC', 'message' => "Errors: {$run->errors_count}"],
|
||||
]);
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::PartiallySucceeded->value,
|
||||
summaryCounts: [
|
||||
'total' => count($policyTypes),
|
||||
'processed' => count($policyTypes),
|
||||
'succeeded' => max(0, count($policyTypes) - (int) $run->errors_count),
|
||||
'failed' => (int) $run->errors_count,
|
||||
'items' => (int) $run->items_observed_count,
|
||||
'updated' => (int) $run->items_upserted_count,
|
||||
],
|
||||
failures: [
|
||||
['code' => 'inventory.partial', 'message' => "Errors: {$run->errors_count}"],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
@ -182,7 +176,6 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
||||
context: [
|
||||
'metadata' => [
|
||||
'inventory_sync_run_id' => $run->id,
|
||||
'bulk_run_id' => $bulkRun->id,
|
||||
'selection_hash' => $run->selection_hash,
|
||||
'observed' => $run->items_observed_count,
|
||||
'upserted' => $run->items_upserted_count,
|
||||
@ -215,12 +208,23 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
||||
if ($run->status === InventorySyncRun::STATUS_SKIPPED) {
|
||||
$reason = (string) (($run->error_codes ?? [])[0] ?? 'skipped');
|
||||
|
||||
foreach ($policyTypes as $policyType) {
|
||||
$bulkOperationService->recordSkippedWithReason($bulkRun, (string) $policyType, $reason);
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: [
|
||||
'total' => count($policyTypes),
|
||||
'processed' => count($policyTypes),
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => count($policyTypes),
|
||||
],
|
||||
failures: [
|
||||
['code' => 'inventory.skipped', 'message' => $reason],
|
||||
],
|
||||
);
|
||||
}
|
||||
$bulkOperationService->complete($bulkRun);
|
||||
|
||||
$updateOpRun('failed', [], [['code' => 'SKIPPED', 'message' => $reason]]);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
@ -228,7 +232,6 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
||||
context: [
|
||||
'metadata' => [
|
||||
'inventory_sync_run_id' => $run->id,
|
||||
'bulk_run_id' => $bulkRun->id,
|
||||
'selection_hash' => $run->selection_hash,
|
||||
'reason' => $reason,
|
||||
],
|
||||
@ -258,21 +261,30 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
||||
$reason = (string) (($run->error_codes ?? [])[0] ?? 'failed');
|
||||
|
||||
$missingPolicyTypes = array_values(array_diff($policyTypes, array_unique($processedPolicyTypes)));
|
||||
foreach ($missingPolicyTypes as $policyType) {
|
||||
$bulkOperationService->recordFailure($bulkRun, (string) $policyType, $reason);
|
||||
|
||||
if ($this->operationRun) {
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: [
|
||||
'total' => count($policyTypes),
|
||||
'processed' => count($policyTypes),
|
||||
'succeeded' => $successCount,
|
||||
'failed' => max($failedCount, count($missingPolicyTypes)),
|
||||
],
|
||||
failures: [
|
||||
['code' => 'inventory.failed', 'message' => $reason],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$bulkOperationService->complete($bulkRun);
|
||||
|
||||
$updateOpRun('failed', [], [['code' => 'FAILED', 'message' => $reason]]);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'inventory.sync.failed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'inventory_sync_run_id' => $run->id,
|
||||
'bulk_run_id' => $bulkRun->id,
|
||||
'selection_hash' => $run->selection_hash,
|
||||
'reason' => $reason,
|
||||
],
|
||||
|
||||
@ -6,9 +6,10 @@
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -39,7 +40,7 @@ public function middleware(): array
|
||||
return [new TrackOperationRun];
|
||||
}
|
||||
|
||||
public function handle(PolicySyncService $service, BulkOperationService $bulkOperationService): void
|
||||
public function handle(PolicySyncService $service, OperationRunService $operationRunService): void
|
||||
{
|
||||
$tenant = Tenant::findOrFail($this->tenantId);
|
||||
|
||||
@ -63,7 +64,7 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe
|
||||
if (! $policy) {
|
||||
$failureSummary[] = [
|
||||
'code' => 'policy.not_found',
|
||||
'message' => $bulkOperationService->sanitizeFailureReason("Policy {$policyId} not found"),
|
||||
'message' => "Policy {$policyId} not found",
|
||||
];
|
||||
|
||||
continue;
|
||||
@ -82,32 +83,31 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe
|
||||
} catch (\Throwable $e) {
|
||||
$failureSummary[] = [
|
||||
'code' => 'policy.sync_failed',
|
||||
'message' => $bulkOperationService->sanitizeFailureReason($e->getMessage()),
|
||||
'message' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$failureCount = count($failureSummary);
|
||||
$outcome = match (true) {
|
||||
$failureCount === 0 => 'succeeded',
|
||||
$syncedCount > 0 => 'partially_succeeded',
|
||||
default => 'failed',
|
||||
$failureCount === 0 => OperationRunOutcome::Succeeded->value,
|
||||
$syncedCount > 0 => OperationRunOutcome::PartiallySucceeded->value,
|
||||
default => OperationRunOutcome::Failed->value,
|
||||
};
|
||||
|
||||
if ($this->operationRun) {
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opService->updateRun(
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
'completed',
|
||||
$outcome,
|
||||
[
|
||||
'policies_total' => $ids->count(),
|
||||
'policies_synced' => $syncedCount,
|
||||
'policies_skipped' => $skippedCount,
|
||||
'policies_failed' => $failureCount,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: $outcome,
|
||||
summaryCounts: [
|
||||
'total' => $ids->count(),
|
||||
'processed' => $ids->count(),
|
||||
'succeeded' => $syncedCount,
|
||||
'failed' => $failureCount,
|
||||
'skipped' => $skippedCount,
|
||||
],
|
||||
$failureSummary
|
||||
failures: $failureSummary,
|
||||
);
|
||||
}
|
||||
|
||||
@ -126,9 +126,9 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe
|
||||
$failureCount = count($failures);
|
||||
|
||||
$outcome = match (true) {
|
||||
$failureCount === 0 => 'succeeded',
|
||||
$syncedCount > 0 => 'partially_succeeded',
|
||||
default => 'failed',
|
||||
$failureCount === 0 => OperationRunOutcome::Succeeded->value,
|
||||
$syncedCount > 0 => OperationRunOutcome::PartiallySucceeded->value,
|
||||
default => OperationRunOutcome::Failed->value,
|
||||
};
|
||||
|
||||
$failureSummary = [];
|
||||
@ -157,23 +157,24 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe
|
||||
|
||||
$failureSummary[] = [
|
||||
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR',
|
||||
'message' => $bulkOperationService->sanitizeFailureReason($message),
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
if ($this->operationRun) {
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opService->updateRun(
|
||||
$total = $syncedCount + $failureCount;
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
'completed',
|
||||
$outcome,
|
||||
[
|
||||
'policy_types_total' => count($supported),
|
||||
'policies_synced' => $syncedCount,
|
||||
'policy_types_failed' => $failureCount,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: $outcome,
|
||||
summaryCounts: [
|
||||
'total' => $total,
|
||||
'processed' => $total,
|
||||
'succeeded' => $syncedCount,
|
||||
'failed' => $failureCount,
|
||||
],
|
||||
$failureSummary
|
||||
failures: $failureSummary,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,11 +51,14 @@ public function handle(RestoreRun $restoreRun): void
|
||||
|
||||
[$opStatus, $opOutcome, $failures] = $this->mapStatus($status);
|
||||
|
||||
$summaryCounts = [
|
||||
'assignments_success' => $restoreRun->getSuccessfulAssignmentsCount(),
|
||||
'assignments_failed' => $restoreRun->getFailedAssignmentsCount(),
|
||||
'assignments_skipped' => $restoreRun->getSkippedAssignmentsCount(),
|
||||
];
|
||||
$summaryCounts = [];
|
||||
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
|
||||
|
||||
foreach (['total', 'succeeded', 'failed', 'skipped'] as $key) {
|
||||
if (array_key_exists($key, $metadata) && is_numeric($metadata[$key])) {
|
||||
$summaryCounts[$key] = (int) $metadata[$key];
|
||||
}
|
||||
}
|
||||
|
||||
$this->service->updateRun(
|
||||
$opRun,
|
||||
|
||||
@ -4,16 +4,14 @@
|
||||
|
||||
use App\Jobs\AddPoliciesToBackupSetJob;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\RunIdempotency;
|
||||
use Filament\Actions\BulkAction;
|
||||
use Filament\Notifications\Notification;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
@ -23,7 +21,6 @@
|
||||
use Filament\Tables\TableComponent;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
@ -203,7 +200,7 @@ public function table(Table $table): Table
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->exists();
|
||||
})
|
||||
->action(function (Collection $records, BulkOperationService $bulkOperationService): void {
|
||||
->action(function (Collection $records): void {
|
||||
$backupSet = BackupSet::query()->findOrFail($this->backupSetId);
|
||||
$tenant = null;
|
||||
|
||||
@ -269,131 +266,58 @@ public function table(Table $table): Table
|
||||
|
||||
sort($policyIds);
|
||||
|
||||
$idempotencyKey = RunIdempotency::buildKey(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
operationType: 'backup_set.add_policies',
|
||||
targetId: (string) $backupSet->getKey(),
|
||||
context: [
|
||||
'policy_ids' => $policyIds,
|
||||
'include_assignments' => (bool) $this->include_assignments,
|
||||
'include_scope_tags' => (bool) $this->include_scope_tags,
|
||||
'include_foundations' => (bool) $this->include_foundations,
|
||||
],
|
||||
);
|
||||
/** @var BulkSelectionIdentity $selection */
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromIds($policyIds);
|
||||
|
||||
// --- Phase 3: Canonical Operation Run Start ---
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
$opRun = $opService->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'backup_set.add_policies',
|
||||
inputs: [
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_ids' => $policyIds,
|
||||
'options' => [
|
||||
'include_assignments' => (bool) $this->include_assignments,
|
||||
'include_scope_tags' => (bool) $this->include_scope_tags,
|
||||
'include_foundations' => (bool) $this->include_foundations,
|
||||
],
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
initiator: $user
|
||||
);
|
||||
selectionIdentity: $selectionIdentity,
|
||||
dispatcher: function ($operationRun) use ($tenant, $user, $backupSet, $policyIds): void {
|
||||
$fingerprint = (string) data_get($operationRun?->context ?? [], 'idempotency.fingerprint', '');
|
||||
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
||||
Notification::make()
|
||||
->title('Add policies already queued')
|
||||
->body('A matching run is already queued or running. Open the run to monitor progress.')
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->info()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
// ----------------------------------------------
|
||||
|
||||
$existingRun = RunIdempotency::findActiveBulkOperationRun(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
idempotencyKey: $idempotencyKey,
|
||||
);
|
||||
|
||||
if ($existingRun instanceof BulkOperationRun) {
|
||||
Notification::make()
|
||||
->title('Add policies already queued')
|
||||
->body('A matching run is already queued or running. Open the run to monitor progress.')
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->info()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$selectionPayload = [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_ids' => $policyIds,
|
||||
'options' => [
|
||||
'include_assignments' => (bool) $this->include_assignments,
|
||||
'include_scope_tags' => (bool) $this->include_scope_tags,
|
||||
'include_foundations' => (bool) $this->include_foundations,
|
||||
],
|
||||
];
|
||||
|
||||
try {
|
||||
$run = $bulkOperationService->createRun(
|
||||
tenant: $tenant,
|
||||
user: $user,
|
||||
resource: 'backup_set',
|
||||
action: 'add_policies',
|
||||
itemIds: $selectionPayload,
|
||||
totalItems: count($policyIds),
|
||||
idempotencyKey: $idempotencyKey,
|
||||
);
|
||||
} catch (QueryException $exception) {
|
||||
if ((string) $exception->getCode() === '23505') {
|
||||
$existingRun = RunIdempotency::findActiveBulkOperationRun(
|
||||
AddPoliciesToBackupSetJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
idempotencyKey: $idempotencyKey,
|
||||
userId: (int) $user->getKey(),
|
||||
backupSetId: (int) $backupSet->getKey(),
|
||||
policyIds: $policyIds,
|
||||
options: [
|
||||
'include_assignments' => (bool) $this->include_assignments,
|
||||
'include_scope_tags' => (bool) $this->include_scope_tags,
|
||||
'include_foundations' => (bool) $this->include_foundations,
|
||||
],
|
||||
idempotencyKey: $fingerprint,
|
||||
operationRun: $operationRun,
|
||||
);
|
||||
},
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_count' => count($policyIds),
|
||||
],
|
||||
);
|
||||
|
||||
if ($existingRun instanceof BulkOperationRun) {
|
||||
Notification::make()
|
||||
->title('Add policies already queued')
|
||||
->body('A matching run is already queued or running. Open the run to monitor progress.')
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->info()
|
||||
->send();
|
||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||
Notification::make()
|
||||
->title('Add policies already queued')
|
||||
->body('A matching run is already queued or running. Open the run to monitor progress.')
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
->label('View run')
|
||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||
])
|
||||
->info()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
throw $exception;
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
$opService->dispatchOrFail($opRun, function () use ($run, $backupSet, $opRun): void {
|
||||
AddPoliciesToBackupSetJob::dispatch(
|
||||
bulkRunId: (int) $run->getKey(),
|
||||
backupSetId: (int) $backupSet->getKey(),
|
||||
includeAssignments: (bool) $this->include_assignments,
|
||||
includeScopeTags: (bool) $this->include_scope_tags,
|
||||
includeFoundations: (bool) $this->include_foundations,
|
||||
operationRun: $opRun
|
||||
);
|
||||
});
|
||||
|
||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||
->actions([
|
||||
\Filament\Actions\Action::make('view_run')
|
||||
|
||||
@ -1,104 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BulkOperationRun extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'user_id',
|
||||
'resource',
|
||||
'action',
|
||||
'idempotency_key',
|
||||
'status',
|
||||
'total_items',
|
||||
'processed_items',
|
||||
'succeeded',
|
||||
'failed',
|
||||
'skipped',
|
||||
'item_ids',
|
||||
'failures',
|
||||
'audit_log_id',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'item_ids' => 'array',
|
||||
'failures' => 'array',
|
||||
'processed_items' => 'integer',
|
||||
'total_items' => 'integer',
|
||||
'succeeded' => 'integer',
|
||||
'failed' => 'integer',
|
||||
'skipped' => 'integer',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function auditLog(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AuditLog::class);
|
||||
}
|
||||
|
||||
public function runType(): string
|
||||
{
|
||||
return "{$this->resource}.{$this->action}";
|
||||
}
|
||||
|
||||
public function statusBucket(): string
|
||||
{
|
||||
$status = $this->status;
|
||||
|
||||
if ($status === 'pending') {
|
||||
return 'queued';
|
||||
}
|
||||
|
||||
if ($status === 'running') {
|
||||
return 'running';
|
||||
}
|
||||
|
||||
$succeededCount = (int) ($this->succeeded ?? 0);
|
||||
$failedCount = (int) ($this->failed ?? 0);
|
||||
$failureEntries = $this->failures ?? [];
|
||||
$hasNonSkippedFailure = false;
|
||||
|
||||
foreach ($failureEntries as $entry) {
|
||||
if (! is_array($entry)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (($entry['type'] ?? 'failed') !== 'skipped') {
|
||||
$hasNonSkippedFailure = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$hasFailures = $failedCount > 0 || $hasNonSkippedFailure;
|
||||
|
||||
if ($succeededCount > 0 && $hasFailures) {
|
||||
return 'partially succeeded';
|
||||
}
|
||||
|
||||
if ($succeededCount === 0 && $hasFailures) {
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
return match ($status) {
|
||||
'completed', 'completed_with_errors' => 'succeeded',
|
||||
'failed', 'aborted' => 'failed',
|
||||
default => 'failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -121,7 +121,7 @@ public function getAssignmentRestoreOutcomes(): array
|
||||
return collect($results)
|
||||
->pluck('assignment_outcomes')
|
||||
->flatten(1)
|
||||
->filter()
|
||||
->filter(static fn (mixed $outcome): bool => is_array($outcome))
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
@ -130,7 +130,7 @@ public function getSuccessfulAssignmentsCount(): int
|
||||
{
|
||||
return count(array_filter(
|
||||
$this->getAssignmentRestoreOutcomes(),
|
||||
fn ($outcome) => $outcome['status'] === 'success'
|
||||
static fn (mixed $outcome): bool => is_array($outcome) && ($outcome['status'] ?? null) === 'success'
|
||||
));
|
||||
}
|
||||
|
||||
@ -138,7 +138,7 @@ public function getFailedAssignmentsCount(): int
|
||||
{
|
||||
return count(array_filter(
|
||||
$this->getAssignmentRestoreOutcomes(),
|
||||
fn ($outcome) => $outcome['status'] === 'failed'
|
||||
static fn (mixed $outcome): bool => is_array($outcome) && ($outcome['status'] ?? null) === 'failed'
|
||||
));
|
||||
}
|
||||
|
||||
@ -146,7 +146,7 @@ public function getSkippedAssignmentsCount(): int
|
||||
{
|
||||
return count(array_filter(
|
||||
$this->getAssignmentRestoreOutcomes(),
|
||||
fn ($outcome) => $outcome['status'] === 'skipped'
|
||||
static fn (mixed $outcome): bool => is_array($outcome) && ($outcome['status'] ?? null) === 'skipped'
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,10 +2,10 @@
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Filament\Resources\BulkOperationRunResource;
|
||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Illuminate\Notifications\Notification;
|
||||
|
||||
@ -66,7 +66,7 @@ public function toDatabase(object $notifiable): array
|
||||
|
||||
if ($tenant) {
|
||||
$url = match ($runType) {
|
||||
'bulk_operation' => BulkOperationRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
|
||||
'bulk_operation' => OperationRunLinks::view($runId, $tenant),
|
||||
'restore' => RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
|
||||
'directory_groups' => EntraGroupSyncRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
|
||||
default => null,
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class BulkOperationRunPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($tenant);
|
||||
}
|
||||
|
||||
public function view(User $user, BulkOperationRun $run): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $run->tenant_id === (int) $tenant->getKey();
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,6 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\Finding;
|
||||
@ -14,7 +13,6 @@
|
||||
use App\Models\UserTenantPreference;
|
||||
use App\Observers\RestoreRunObserver;
|
||||
use App\Policies\BackupSchedulePolicy;
|
||||
use App\Policies\BulkOperationRunPolicy;
|
||||
use App\Policies\EntraGroupPolicy;
|
||||
use App\Policies\EntraGroupSyncRunPolicy;
|
||||
use App\Policies\FindingPolicy;
|
||||
@ -121,7 +119,6 @@ public function boot(): void
|
||||
});
|
||||
|
||||
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
|
||||
Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class);
|
||||
Gate::policy(Finding::class, FindingPolicy::class);
|
||||
Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class);
|
||||
Gate::policy(EntraGroup::class, EntraGroupPolicy::class);
|
||||
|
||||
271
app/Services/AdapterRunReconciler.php
Normal file
271
app/Services/AdapterRunReconciler.php
Normal file
@ -0,0 +1,271 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\RestoreRunStatus;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
final class AdapterRunReconciler
|
||||
{
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function supportedTypes(): array
|
||||
{
|
||||
return [
|
||||
'restore.execute',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{type?: string|null, tenant_id?: int|null, older_than_minutes?: int, limit?: int, dry_run?: bool} $options
|
||||
* @return array{candidates:int,reconciled:int,skipped:int,changes:array<int,array<string,mixed>>}
|
||||
*/
|
||||
public function reconcile(array $options = []): array
|
||||
{
|
||||
$type = $options['type'] ?? null;
|
||||
$tenantId = $options['tenant_id'] ?? null;
|
||||
$olderThanMinutes = max(1, (int) ($options['older_than_minutes'] ?? 10));
|
||||
$limit = max(1, (int) ($options['limit'] ?? 50));
|
||||
$dryRun = (bool) ($options['dry_run'] ?? true);
|
||||
|
||||
if ($type !== null && ! in_array($type, $this->supportedTypes(), true)) {
|
||||
throw new \InvalidArgumentException('Unsupported adapter run type: '.$type);
|
||||
}
|
||||
|
||||
$cutoff = CarbonImmutable::now()->subMinutes($olderThanMinutes);
|
||||
|
||||
$query = OperationRun::query()
|
||||
->whereIn('type', $type ? [$type] : $this->supportedTypes())
|
||||
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
|
||||
->whereNotNull('context->restore_run_id')
|
||||
->where(function (Builder $q) use ($cutoff): void {
|
||||
$q
|
||||
->where(function (Builder $q2) use ($cutoff): void {
|
||||
$q2->whereNull('started_at')->where('created_at', '<', $cutoff);
|
||||
})
|
||||
->orWhere(function (Builder $q2) use ($cutoff): void {
|
||||
$q2->whereNotNull('started_at')->where('started_at', '<', $cutoff);
|
||||
});
|
||||
})
|
||||
->orderBy('id')
|
||||
->limit($limit);
|
||||
|
||||
if (is_int($tenantId) && $tenantId > 0) {
|
||||
$query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
$candidates = $query->get();
|
||||
|
||||
$changes = [];
|
||||
$reconciled = 0;
|
||||
$skipped = 0;
|
||||
|
||||
foreach ($candidates as $run) {
|
||||
$change = $this->reconcileOne($run, $dryRun);
|
||||
|
||||
if ($change === null) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$changes[] = $change;
|
||||
|
||||
if (($change['applied'] ?? false) === true) {
|
||||
$reconciled++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'candidates' => $candidates->count(),
|
||||
'reconciled' => $reconciled,
|
||||
'skipped' => $skipped,
|
||||
'changes' => $changes,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
private function reconcileOne(OperationRun $run, bool $dryRun): ?array
|
||||
{
|
||||
if ($run->type !== 'restore.execute') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
$restoreRunId = $context['restore_run_id'] ?? null;
|
||||
|
||||
if (! is_numeric($restoreRunId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$restoreRun = RestoreRun::query()
|
||||
->where('tenant_id', $run->tenant_id)
|
||||
->whereKey((int) $restoreRunId)
|
||||
->first();
|
||||
|
||||
if (! $restoreRun instanceof RestoreRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$restoreStatus = RestoreRunStatus::fromString($restoreRun->status);
|
||||
|
||||
if (! $this->isTerminalRestoreStatus($restoreStatus)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
[$opStatus, $opOutcome, $failures] = $this->mapRestoreToOperationRun($restoreRun, $restoreStatus);
|
||||
|
||||
$summaryCounts = $this->buildSummaryCounts($restoreRun);
|
||||
|
||||
$before = [
|
||||
'status' => (string) $run->status,
|
||||
'outcome' => (string) $run->outcome,
|
||||
];
|
||||
|
||||
$after = [
|
||||
'status' => $opStatus,
|
||||
'outcome' => $opOutcome,
|
||||
];
|
||||
|
||||
if ($dryRun) {
|
||||
return [
|
||||
'applied' => false,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'type' => (string) $run->type,
|
||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
];
|
||||
}
|
||||
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
$runs->updateRun(
|
||||
$run,
|
||||
status: $opStatus,
|
||||
outcome: $opOutcome,
|
||||
summaryCounts: $summaryCounts,
|
||||
failures: $failures,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
|
||||
$updatedContext = is_array($run->context) ? $run->context : [];
|
||||
$reconciliation = is_array($updatedContext['reconciliation'] ?? null) ? $updatedContext['reconciliation'] : [];
|
||||
$reconciliation['reconciled_at'] = CarbonImmutable::now()->toIso8601String();
|
||||
$reconciliation['reason'] = 'adapter_out_of_sync';
|
||||
|
||||
$updatedContext['reconciliation'] = $reconciliation;
|
||||
|
||||
$run->context = $updatedContext;
|
||||
|
||||
if ($run->started_at === null && $restoreRun->started_at !== null) {
|
||||
$run->started_at = $restoreRun->started_at;
|
||||
}
|
||||
|
||||
if ($run->completed_at === null && $restoreRun->completed_at !== null) {
|
||||
$run->completed_at = $restoreRun->completed_at;
|
||||
}
|
||||
|
||||
$run->save();
|
||||
|
||||
return [
|
||||
'applied' => true,
|
||||
'operation_run_id' => (int) $run->getKey(),
|
||||
'type' => (string) $run->type,
|
||||
'restore_run_id' => (int) $restoreRun->getKey(),
|
||||
'before' => $before,
|
||||
'after' => $after,
|
||||
];
|
||||
}
|
||||
|
||||
private function isTerminalRestoreStatus(?RestoreRunStatus $status): bool
|
||||
{
|
||||
if (! $status instanceof RestoreRunStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($status, [
|
||||
RestoreRunStatus::Completed,
|
||||
RestoreRunStatus::Partial,
|
||||
RestoreRunStatus::Failed,
|
||||
RestoreRunStatus::Cancelled,
|
||||
RestoreRunStatus::Aborted,
|
||||
RestoreRunStatus::CompletedWithErrors,
|
||||
], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{0:string,1:string,2:array<int,array{code:string,message:string}>}
|
||||
*/
|
||||
private function mapRestoreToOperationRun(RestoreRun $restoreRun, RestoreRunStatus $status): array
|
||||
{
|
||||
$failureReason = is_string($restoreRun->failure_reason ?? null) ? (string) $restoreRun->failure_reason : '';
|
||||
|
||||
return match ($status) {
|
||||
RestoreRunStatus::Completed => [OperationRunStatus::Completed->value, OperationRunOutcome::Succeeded->value, []],
|
||||
RestoreRunStatus::Partial, RestoreRunStatus::CompletedWithErrors => [
|
||||
OperationRunStatus::Completed->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
[[
|
||||
'code' => 'restore.completed_with_warnings',
|
||||
'message' => $failureReason !== '' ? $failureReason : 'Restore completed with warnings.',
|
||||
]],
|
||||
],
|
||||
RestoreRunStatus::Failed, RestoreRunStatus::Aborted => [
|
||||
OperationRunStatus::Completed->value,
|
||||
OperationRunOutcome::Failed->value,
|
||||
[[
|
||||
'code' => 'restore.failed',
|
||||
'message' => $failureReason !== '' ? $failureReason : 'Restore failed.',
|
||||
]],
|
||||
],
|
||||
RestoreRunStatus::Cancelled => [
|
||||
OperationRunStatus::Completed->value,
|
||||
OperationRunOutcome::Failed->value,
|
||||
[[
|
||||
'code' => 'restore.cancelled',
|
||||
'message' => 'Restore run was cancelled.',
|
||||
]],
|
||||
],
|
||||
default => [OperationRunStatus::Running->value, OperationRunOutcome::Pending->value, []],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int>
|
||||
*/
|
||||
private function buildSummaryCounts(RestoreRun $restoreRun): array
|
||||
{
|
||||
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
|
||||
|
||||
$counts = [];
|
||||
|
||||
foreach (['total', 'processed', 'succeeded', 'failed', 'skipped'] as $key) {
|
||||
if (array_key_exists($key, $metadata) && is_numeric($metadata[$key])) {
|
||||
$counts[$key] = (int) $metadata[$key];
|
||||
}
|
||||
}
|
||||
|
||||
if (! isset($counts['processed'])) {
|
||||
$processed = (int) ($counts['succeeded'] ?? 0) + (int) ($counts['failed'] ?? 0) + (int) ($counts['skipped'] ?? 0);
|
||||
|
||||
if ($processed > 0) {
|
||||
$counts['processed'] = $processed;
|
||||
}
|
||||
}
|
||||
|
||||
return $counts;
|
||||
}
|
||||
}
|
||||
@ -1,269 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
|
||||
class BulkOperationService
|
||||
{
|
||||
public function __construct(
|
||||
protected AuditLogger $auditLogger
|
||||
) {}
|
||||
|
||||
public function sanitizeFailureReason(string $reason): string
|
||||
{
|
||||
$reason = trim($reason);
|
||||
|
||||
if ($reason === '') {
|
||||
return 'error';
|
||||
}
|
||||
|
||||
$lower = mb_strtolower($reason);
|
||||
|
||||
if (
|
||||
str_contains($lower, 'bearer ') ||
|
||||
str_contains($lower, 'access_token') ||
|
||||
str_contains($lower, 'client_secret') ||
|
||||
str_contains($lower, 'authorization')
|
||||
) {
|
||||
return 'redacted';
|
||||
}
|
||||
|
||||
$reason = preg_replace("/\s+/u", ' ', $reason) ?? $reason;
|
||||
|
||||
return mb_substr($reason, 0, 200);
|
||||
}
|
||||
|
||||
public function createRun(
|
||||
Tenant $tenant,
|
||||
User $user,
|
||||
string $resource,
|
||||
string $action,
|
||||
array $itemIds,
|
||||
int $totalItems,
|
||||
?string $idempotencyKey = null
|
||||
): BulkOperationRun {
|
||||
$effectiveTotalItems = $totalItems;
|
||||
|
||||
if (array_is_list($itemIds)) {
|
||||
$effectiveTotalItems = max($totalItems, count($itemIds));
|
||||
}
|
||||
|
||||
$run = BulkOperationRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'resource' => $resource,
|
||||
'action' => $action,
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'status' => 'pending',
|
||||
'item_ids' => $itemIds,
|
||||
'total_items' => $effectiveTotalItems,
|
||||
'processed_items' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
'failures' => [],
|
||||
]);
|
||||
|
||||
$auditLog = $this->auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: "bulk.{$resource}.{$action}.created",
|
||||
context: [
|
||||
'metadata' => [
|
||||
'bulk_run_id' => $run->id,
|
||||
'total_items' => $effectiveTotalItems,
|
||||
],
|
||||
],
|
||||
actorId: $user->id,
|
||||
actorEmail: $user->email,
|
||||
actorName: $user->name,
|
||||
resourceType: 'bulk_operation_run',
|
||||
resourceId: (string) $run->id
|
||||
);
|
||||
|
||||
$run->update(['audit_log_id' => $auditLog->id]);
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
public function start(BulkOperationRun $run): void
|
||||
{
|
||||
$run->update(['status' => 'running']);
|
||||
}
|
||||
|
||||
public function recordSuccess(BulkOperationRun $run): void
|
||||
{
|
||||
$run->increment('processed_items');
|
||||
$run->increment('succeeded');
|
||||
}
|
||||
|
||||
public function recordFailure(BulkOperationRun $run, string $itemId, string $reason, ?string $reasonCode = null): void
|
||||
{
|
||||
$reason = $this->sanitizeFailureReason($reason);
|
||||
|
||||
$failures = $run->failures ?? [];
|
||||
$failureEntry = [
|
||||
'item_id' => $itemId,
|
||||
'reason' => $reason,
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
if (is_string($reasonCode) && $reasonCode !== '') {
|
||||
$failureEntry['reason_code'] = $reasonCode;
|
||||
}
|
||||
|
||||
$failures[] = $failureEntry;
|
||||
|
||||
$run->update([
|
||||
'failures' => $failures,
|
||||
'processed_items' => $run->processed_items + 1,
|
||||
'failed' => $run->failed + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function recordSkipped(BulkOperationRun $run): void
|
||||
{
|
||||
$run->increment('processed_items');
|
||||
$run->increment('skipped');
|
||||
}
|
||||
|
||||
public function recordSkippedWithReason(BulkOperationRun $run, string $itemId, string $reason, ?string $reasonCode = null): void
|
||||
{
|
||||
$reason = $this->sanitizeFailureReason($reason);
|
||||
|
||||
$failures = $run->failures ?? [];
|
||||
$failureEntry = [
|
||||
'item_id' => $itemId,
|
||||
'reason' => $reason,
|
||||
'type' => 'skipped',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
];
|
||||
|
||||
if (is_string($reasonCode) && $reasonCode !== '') {
|
||||
$failureEntry['reason_code'] = $reasonCode;
|
||||
}
|
||||
|
||||
$failures[] = $failureEntry;
|
||||
|
||||
$run->update([
|
||||
'failures' => $failures,
|
||||
'processed_items' => $run->processed_items + 1,
|
||||
'skipped' => $run->skipped + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
public function complete(BulkOperationRun $run): void
|
||||
{
|
||||
$run->refresh();
|
||||
|
||||
if ($run->processed_items > $run->total_items) {
|
||||
BulkOperationRun::query()
|
||||
->whereKey($run->id)
|
||||
->update(['total_items' => $run->processed_items]);
|
||||
|
||||
$run->refresh();
|
||||
}
|
||||
|
||||
if (! in_array($run->status, ['pending', 'running'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$failureEntries = collect($run->failures ?? []);
|
||||
$hasFailures = $run->failed > 0
|
||||
|| $failureEntries->contains(fn (array $entry): bool => ($entry['type'] ?? 'failed') !== 'skipped');
|
||||
|
||||
$status = $hasFailures ? 'completed_with_errors' : 'completed';
|
||||
|
||||
$updated = BulkOperationRun::query()
|
||||
->whereKey($run->id)
|
||||
->whereIn('status', ['pending', 'running'])
|
||||
->update(['status' => $status]);
|
||||
|
||||
if ($updated === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$run->refresh();
|
||||
|
||||
$failureEntries = collect($run->failures ?? []);
|
||||
$failedReasons = $failureEntries
|
||||
->filter(fn (array $entry) => ($entry['type'] ?? 'failed') !== 'skipped')
|
||||
->groupBy('reason')
|
||||
->map(fn ($group) => $group->count())
|
||||
->all();
|
||||
|
||||
$skippedReasons = $failureEntries
|
||||
->filter(fn (array $entry) => ($entry['type'] ?? null) === 'skipped')
|
||||
->groupBy('reason')
|
||||
->map(fn ($group) => $group->count())
|
||||
->all();
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $run->tenant,
|
||||
action: "bulk.{$run->resource}.{$run->action}.{$status}",
|
||||
context: [
|
||||
'metadata' => [
|
||||
'bulk_run_id' => $run->id,
|
||||
'succeeded' => $run->succeeded,
|
||||
'failed' => $run->failed,
|
||||
'skipped' => $run->skipped,
|
||||
'failed_reasons' => $failedReasons,
|
||||
'skipped_reasons' => $skippedReasons,
|
||||
],
|
||||
],
|
||||
actorId: $run->user_id,
|
||||
resourceType: 'bulk_operation_run',
|
||||
resourceId: (string) $run->id
|
||||
);
|
||||
}
|
||||
|
||||
public function fail(BulkOperationRun $run, string $reason): void
|
||||
{
|
||||
$run->update(['status' => 'failed']);
|
||||
|
||||
$reason = $this->sanitizeFailureReason($reason);
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $run->tenant,
|
||||
action: "bulk.{$run->resource}.{$run->action}.failed",
|
||||
context: [
|
||||
'reason' => $reason,
|
||||
'metadata' => [
|
||||
'bulk_run_id' => $run->id,
|
||||
],
|
||||
],
|
||||
actorId: $run->user_id,
|
||||
status: 'failure',
|
||||
resourceType: 'bulk_operation_run',
|
||||
resourceId: (string) $run->id
|
||||
);
|
||||
}
|
||||
|
||||
public function abort(BulkOperationRun $run, string $reason): void
|
||||
{
|
||||
$run->update(['status' => 'aborted']);
|
||||
|
||||
$reason = $this->sanitizeFailureReason($reason);
|
||||
|
||||
$this->auditLogger->log(
|
||||
tenant: $run->tenant,
|
||||
action: "bulk.{$run->resource}.{$run->action}.aborted",
|
||||
context: [
|
||||
'reason' => $reason,
|
||||
'metadata' => [
|
||||
'bulk_run_id' => $run->id,
|
||||
'succeeded' => $run->succeeded,
|
||||
'failed' => $run->failed,
|
||||
'skipped' => $run->skipped,
|
||||
],
|
||||
],
|
||||
actorId: $run->user_id,
|
||||
status: 'failure',
|
||||
resourceType: 'bulk_operation_run',
|
||||
resourceId: (string) $run->id
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -7,11 +7,17 @@
|
||||
use App\Models\User;
|
||||
use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification;
|
||||
use App\Notifications\OperationRunQueued as OperationRunQueuedNotification;
|
||||
use App\Services\Operations\BulkIdempotencyFingerprint;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\BulkRunContext;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||
use Illuminate\Database\QueryException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use InvalidArgumentException;
|
||||
use ReflectionFunction;
|
||||
use ReflectionMethod;
|
||||
use Throwable;
|
||||
|
||||
class OperationRunService
|
||||
@ -71,6 +77,124 @@ public function ensureRun(
|
||||
}
|
||||
}
|
||||
|
||||
public function ensureRunWithIdentity(
|
||||
Tenant $tenant,
|
||||
string $type,
|
||||
array $identityInputs,
|
||||
array $context,
|
||||
?User $initiator = null
|
||||
): OperationRun {
|
||||
$hash = $this->calculateHash($tenant->id, $type, $identityInputs);
|
||||
|
||||
// Idempotency Check (Fast Path)
|
||||
// We check specific status to match the partial unique index
|
||||
$existing = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('run_identity_hash', $hash)
|
||||
->whereIn('status', OperationRunStatus::values())
|
||||
->where('status', '!=', OperationRunStatus::Completed->value)
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
// Create new run (race-safe via partial unique index)
|
||||
try {
|
||||
return OperationRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $initiator?->id,
|
||||
'initiator_name' => $initiator?->name ?? 'System',
|
||||
'type' => $type,
|
||||
'status' => OperationRunStatus::Queued->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'run_identity_hash' => $hash,
|
||||
'context' => $context,
|
||||
]);
|
||||
} catch (QueryException $e) {
|
||||
// Unique violation (active-run dedupe):
|
||||
// - PostgreSQL: 23505
|
||||
// - SQLite (tests): 23000 (generic integrity violation; message indicates UNIQUE constraint failed)
|
||||
if (! in_array(($e->errorInfo[0] ?? null), ['23505', '23000'], true)) {
|
||||
throw $e;
|
||||
}
|
||||
|
||||
$existing = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('run_identity_hash', $hash)
|
||||
->whereIn('status', [OperationRunStatus::Queued->value, OperationRunStatus::Running->value])
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standardized enqueue helper for bulk operations.
|
||||
*
|
||||
* Builds the canonical bulk context contract and ensures active-run dedupe
|
||||
* is based on target scope + selection identity.
|
||||
*
|
||||
* @param array{entra_tenant_id?: mixed, directory_context_id?: mixed} $targetScope
|
||||
* @param array{kind: string, ids_hash?: string, query_hash?: string} $selectionIdentity
|
||||
* @param array<string, mixed> $extraContext
|
||||
*/
|
||||
public function enqueueBulkOperation(
|
||||
Tenant $tenant,
|
||||
string $type,
|
||||
array $targetScope,
|
||||
array $selectionIdentity,
|
||||
callable $dispatcher,
|
||||
?User $initiator = null,
|
||||
array $extraContext = [],
|
||||
bool $emitQueuedNotification = true
|
||||
): OperationRun {
|
||||
$targetScope = BulkRunContext::normalizeTargetScope($targetScope);
|
||||
|
||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||
|
||||
if (is_string($entraTenantId) && $entraTenantId !== '' && $entraTenantId !== (string) $tenant->graphTenantId()) {
|
||||
throw new InvalidArgumentException('Bulk enqueue target_scope entra_tenant_id must match the current tenant.');
|
||||
}
|
||||
|
||||
/** @var BulkIdempotencyFingerprint $fingerprints */
|
||||
$fingerprints = app(BulkIdempotencyFingerprint::class);
|
||||
|
||||
$fingerprint = $fingerprints->build($type, $targetScope, $selectionIdentity);
|
||||
|
||||
$context = array_merge($extraContext, [
|
||||
'operation' => [
|
||||
'type' => $type,
|
||||
],
|
||||
'target_scope' => $targetScope,
|
||||
'selection' => $selectionIdentity,
|
||||
'idempotency' => [
|
||||
'fingerprint' => $fingerprint,
|
||||
],
|
||||
]);
|
||||
|
||||
$run = $this->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: $type,
|
||||
identityInputs: [
|
||||
'target_scope' => $targetScope,
|
||||
'selection' => $selectionIdentity,
|
||||
],
|
||||
context: $context,
|
||||
initiator: $initiator,
|
||||
);
|
||||
|
||||
if ($run->wasRecentlyCreated) {
|
||||
$this->dispatchOrFail($run, $dispatcher, emitQueuedNotification: $emitQueuedNotification);
|
||||
}
|
||||
|
||||
return $run;
|
||||
}
|
||||
|
||||
public function updateRun(
|
||||
OperationRun $run,
|
||||
string $status,
|
||||
@ -137,6 +261,51 @@ public function updateRun(
|
||||
return $run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Increment whitelisted summary_counts keys for a run.
|
||||
*
|
||||
* Uses a transaction + row lock to prevent lost updates when multiple workers
|
||||
* update counts concurrently.
|
||||
*
|
||||
* @param array<string, mixed> $delta
|
||||
*/
|
||||
public function incrementSummaryCounts(OperationRun $run, array $delta): OperationRun
|
||||
{
|
||||
$delta = $this->sanitizeSummaryCounts($delta);
|
||||
|
||||
if ($delta === []) {
|
||||
return $run;
|
||||
}
|
||||
|
||||
/** @var OperationRun $updated */
|
||||
$updated = DB::transaction(function () use ($run, $delta): OperationRun {
|
||||
$locked = OperationRun::query()
|
||||
->whereKey($run->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $locked instanceof OperationRun) {
|
||||
return $run;
|
||||
}
|
||||
|
||||
$current = is_array($locked->summary_counts ?? null) ? $locked->summary_counts : [];
|
||||
$current = SummaryCountsNormalizer::normalize($current);
|
||||
|
||||
foreach ($delta as $key => $value) {
|
||||
$current[$key] = ($current[$key] ?? 0) + $value;
|
||||
}
|
||||
|
||||
$locked->summary_counts = $current;
|
||||
$locked->save();
|
||||
|
||||
return $locked;
|
||||
});
|
||||
|
||||
$updated->refresh();
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a queued operation safely.
|
||||
*
|
||||
@ -146,7 +315,7 @@ public function updateRun(
|
||||
public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = true): void
|
||||
{
|
||||
try {
|
||||
$dispatcher();
|
||||
$this->invokeDispatcher($dispatcher, $run);
|
||||
|
||||
if ($emitQueuedNotification && $run->wasRecentlyCreated && $run->user instanceof User) {
|
||||
$run->user->notify(new OperationRunQueuedNotification($run));
|
||||
@ -168,6 +337,107 @@ public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $em
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append failure entries to failure_summary (sanitized + bounded) without overwriting existing.
|
||||
*
|
||||
* @param array<int, array{code?: mixed, message?: mixed}> $failures
|
||||
*/
|
||||
public function appendFailures(OperationRun $run, array $failures): OperationRun
|
||||
{
|
||||
$failures = $this->sanitizeFailures($failures);
|
||||
|
||||
if ($failures === []) {
|
||||
return $run;
|
||||
}
|
||||
|
||||
/** @var OperationRun $updated */
|
||||
$updated = DB::transaction(function () use ($run, $failures): OperationRun {
|
||||
$locked = OperationRun::query()
|
||||
->whereKey($run->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $locked instanceof OperationRun) {
|
||||
return $run;
|
||||
}
|
||||
|
||||
$current = is_array($locked->failure_summary ?? null) ? $locked->failure_summary : [];
|
||||
$current = $this->sanitizeFailures($current);
|
||||
|
||||
$merged = array_merge($current, $failures);
|
||||
|
||||
// Prevent runaway payloads.
|
||||
$merged = array_slice($merged, 0, 50);
|
||||
|
||||
$locked->failure_summary = $merged;
|
||||
$locked->save();
|
||||
|
||||
return $locked;
|
||||
});
|
||||
|
||||
$updated->refresh();
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a bulk run as completed if summary_counts indicate all work is processed.
|
||||
*/
|
||||
public function maybeCompleteBulkRun(OperationRun $run): OperationRun
|
||||
{
|
||||
$run->refresh();
|
||||
|
||||
if ($run->status === OperationRunStatus::Completed->value) {
|
||||
return $run;
|
||||
}
|
||||
|
||||
$updated = DB::transaction(function () use ($run): OperationRun {
|
||||
$locked = OperationRun::query()
|
||||
->whereKey($run->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if (! $locked instanceof OperationRun) {
|
||||
return $run;
|
||||
}
|
||||
|
||||
if ($locked->status === OperationRunStatus::Completed->value) {
|
||||
return $locked;
|
||||
}
|
||||
|
||||
$counts = is_array($locked->summary_counts ?? null) ? $locked->summary_counts : [];
|
||||
$counts = SummaryCountsNormalizer::normalize($counts);
|
||||
|
||||
$total = (int) ($counts['total'] ?? 0);
|
||||
$processed = (int) ($counts['processed'] ?? 0);
|
||||
$failed = (int) ($counts['failed'] ?? 0);
|
||||
|
||||
if ($total <= 0 || $processed < $total) {
|
||||
return $locked;
|
||||
}
|
||||
|
||||
$outcome = OperationRunOutcome::Succeeded->value;
|
||||
|
||||
if ($failed > 0 && $failed < $total) {
|
||||
$outcome = OperationRunOutcome::PartiallySucceeded->value;
|
||||
}
|
||||
|
||||
if ($failed >= $total) {
|
||||
$outcome = OperationRunOutcome::Failed->value;
|
||||
}
|
||||
|
||||
return $this->updateRun(
|
||||
$locked,
|
||||
status: OperationRunStatus::Completed->value,
|
||||
outcome: $outcome,
|
||||
);
|
||||
});
|
||||
|
||||
$updated->refresh();
|
||||
|
||||
return $updated;
|
||||
}
|
||||
|
||||
public function failRun(OperationRun $run, Throwable $e): OperationRun
|
||||
{
|
||||
return $this->updateRun(
|
||||
@ -183,6 +453,30 @@ public function failRun(OperationRun $run, Throwable $e): OperationRun
|
||||
);
|
||||
}
|
||||
|
||||
private function invokeDispatcher(callable $dispatcher, OperationRun $run): void
|
||||
{
|
||||
$ref = null;
|
||||
|
||||
if (is_array($dispatcher) && count($dispatcher) === 2) {
|
||||
$ref = new ReflectionMethod($dispatcher[0], (string) $dispatcher[1]);
|
||||
} elseif (is_string($dispatcher) && str_contains($dispatcher, '::')) {
|
||||
[$class, $method] = explode('::', $dispatcher, 2);
|
||||
$ref = new ReflectionMethod($class, $method);
|
||||
} elseif ($dispatcher instanceof \Closure) {
|
||||
$ref = new ReflectionFunction($dispatcher);
|
||||
} elseif (is_object($dispatcher) && method_exists($dispatcher, '__invoke')) {
|
||||
$ref = new ReflectionMethod($dispatcher, '__invoke');
|
||||
}
|
||||
|
||||
if ($ref && $ref->getNumberOfParameters() >= 1) {
|
||||
$dispatcher($run);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$dispatcher();
|
||||
}
|
||||
|
||||
protected function calculateHash(int $tenantId, string $type, array $inputs): string
|
||||
{
|
||||
$normalizedInputs = $this->normalizeInputs($inputs);
|
||||
@ -258,27 +552,12 @@ protected function sanitizeFailures(array $failures): array
|
||||
|
||||
protected function sanitizeFailureCode(string $code): string
|
||||
{
|
||||
$code = strtolower(trim($code));
|
||||
|
||||
if ($code === '') {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return substr($code, 0, 80);
|
||||
return RunFailureSanitizer::sanitizeCode($code);
|
||||
}
|
||||
|
||||
protected function sanitizeMessage(string $message): string
|
||||
{
|
||||
$message = trim(str_replace(["\r", "\n"], ' ', $message));
|
||||
|
||||
// Redact obvious bearer tokens / secrets.
|
||||
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', 'Bearer [REDACTED]', $message) ?? $message;
|
||||
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\s*[:=]\s*[^\s]+/i', '$1=[REDACTED]', $message) ?? $message;
|
||||
|
||||
// Redact long opaque blobs that look token-like.
|
||||
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
|
||||
|
||||
return substr($message, 0, 120);
|
||||
return RunFailureSanitizer::sanitizeMessage($message);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
43
app/Services/Operations/BulkIdempotencyFingerprint.php
Normal file
43
app/Services/Operations/BulkIdempotencyFingerprint.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Operations;
|
||||
|
||||
final class BulkIdempotencyFingerprint
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $targetScope
|
||||
* @param array{kind: string, ids_hash?: string, query_hash?: string} $selectionIdentity
|
||||
*/
|
||||
public function build(string $operationType, array $targetScope, array $selectionIdentity): string
|
||||
{
|
||||
$payload = [
|
||||
'type' => trim($operationType),
|
||||
'target_scope' => $targetScope,
|
||||
'selection' => $selectionIdentity,
|
||||
];
|
||||
|
||||
$payload = $this->ksortRecursive($payload);
|
||||
|
||||
$json = json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return hash('sha256', $json);
|
||||
}
|
||||
|
||||
private function ksortRecursive(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$isList = array_is_list($value);
|
||||
if (! $isList) {
|
||||
ksort($value);
|
||||
}
|
||||
|
||||
foreach ($value as $key => $child) {
|
||||
$value[$key] = $this->ksortRecursive($child);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
87
app/Services/Operations/BulkSelectionIdentity.php
Normal file
87
app/Services/Operations/BulkSelectionIdentity.php
Normal file
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Operations;
|
||||
|
||||
final class BulkSelectionIdentity
|
||||
{
|
||||
/**
|
||||
* @param array<int, mixed> $ids
|
||||
* @return array{kind: 'ids', ids_hash: string, ids_count: int}
|
||||
*/
|
||||
public function fromIds(array $ids): array
|
||||
{
|
||||
$normalized = [];
|
||||
|
||||
foreach ($ids as $id) {
|
||||
if (is_int($id)) {
|
||||
$normalized[] = (string) $id;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! is_string($id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$id = trim($id);
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$normalized[] = $id;
|
||||
}
|
||||
|
||||
$normalized = array_values(array_unique($normalized));
|
||||
sort($normalized);
|
||||
|
||||
$json = json_encode($normalized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
|
||||
return [
|
||||
'kind' => 'ids',
|
||||
'ids_hash' => hash('sha256', $json),
|
||||
'ids_count' => count($normalized),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $queryPayload
|
||||
* @return array{kind: 'query', query_hash: string}
|
||||
*/
|
||||
public function fromQuery(array $queryPayload): array
|
||||
{
|
||||
$json = $this->canonicalJson($queryPayload);
|
||||
|
||||
return [
|
||||
'kind' => 'query',
|
||||
'query_hash' => hash('sha256', $json),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function canonicalJson(array $payload): string
|
||||
{
|
||||
$normalized = $this->ksortRecursive($payload);
|
||||
|
||||
return (string) json_encode($normalized, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
private function ksortRecursive(mixed $value): mixed
|
||||
{
|
||||
if (! is_array($value)) {
|
||||
return $value;
|
||||
}
|
||||
|
||||
$isList = array_is_list($value);
|
||||
if (! $isList) {
|
||||
ksort($value);
|
||||
}
|
||||
|
||||
foreach ($value as $key => $child) {
|
||||
$value[$key] = $this->ksortRecursive($child);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
72
app/Services/Operations/TargetScopeConcurrencyLimiter.php
Normal file
72
app/Services/Operations/TargetScopeConcurrencyLimiter.php
Normal file
@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Operations;
|
||||
|
||||
use Illuminate\Contracts\Cache\Lock;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class TargetScopeConcurrencyLimiter
|
||||
{
|
||||
public function __construct(private readonly int $lockTtlSeconds = 900) {}
|
||||
|
||||
/**
|
||||
* Acquire a concurrency slot for a given tenant + target scope.
|
||||
*
|
||||
* Returns a held lock when a slot is available, otherwise null.
|
||||
*
|
||||
* @param array{entra_tenant_id?: mixed, directory_context_id?: mixed} $targetScope
|
||||
*/
|
||||
public function acquireSlot(int $tenantId, array $targetScope): ?Lock
|
||||
{
|
||||
$max = (int) config('tenantpilot.bulk_operations.concurrency.per_target_scope_max', 1);
|
||||
$max = max(0, $max);
|
||||
|
||||
if ($max === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scopeKey = $this->scopeKey($targetScope);
|
||||
|
||||
return $this->acquireSlotInternal("bulk_ops:tenant:{$tenantId}:scope:{$scopeKey}:slot:", $max);
|
||||
}
|
||||
|
||||
private function acquireSlotInternal(string $prefix, int $max): ?Lock
|
||||
{
|
||||
$ttlSeconds = (int) config('tenantpilot.bulk_operations.concurrency.lock_ttl_seconds', $this->lockTtlSeconds);
|
||||
$ttlSeconds = max(1, $ttlSeconds);
|
||||
|
||||
for ($slot = 0; $slot < $max; $slot++) {
|
||||
$lock = Cache::lock($prefix.$slot, $ttlSeconds);
|
||||
|
||||
if ($lock->get()) {
|
||||
return $lock;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{entra_tenant_id?: mixed, directory_context_id?: mixed} $targetScope
|
||||
*/
|
||||
private function scopeKey(array $targetScope): string
|
||||
{
|
||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||
$directoryContextId = $targetScope['directory_context_id'] ?? null;
|
||||
|
||||
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
|
||||
return 'entra:'.trim($entraTenantId);
|
||||
}
|
||||
|
||||
if (is_string($directoryContextId) && trim($directoryContextId) !== '') {
|
||||
return 'directory_context:'.trim($directoryContextId);
|
||||
}
|
||||
|
||||
if (is_int($directoryContextId)) {
|
||||
return 'directory_context:'.$directoryContextId;
|
||||
}
|
||||
|
||||
throw new InvalidArgumentException('Target scope must include entra_tenant_id or directory_context_id.');
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,9 @@ public static function labels(): array
|
||||
'policy.sync' => 'Policy sync',
|
||||
'policy.sync_one' => 'Policy sync',
|
||||
'policy.capture_snapshot' => 'Policy snapshot',
|
||||
'policy.delete' => 'Delete policies',
|
||||
'policy.unignore' => 'Restore policies',
|
||||
'policy.export' => 'Export policies to backup',
|
||||
'inventory.sync' => 'Inventory sync',
|
||||
'directory_groups.sync' => 'Directory groups sync',
|
||||
'drift.generate' => 'Drift generation',
|
||||
@ -26,6 +29,10 @@ public static function labels(): array
|
||||
'backup_schedule.run_now' => 'Backup schedule run',
|
||||
'backup_schedule.retry' => 'Backup schedule retry',
|
||||
'restore.execute' => 'Restore execution',
|
||||
'restore_run.delete' => 'Delete restore runs',
|
||||
'restore_run.restore' => 'Restore restore runs',
|
||||
'restore_run.force_delete' => 'Force delete restore runs',
|
||||
'tenant.sync' => 'Tenant sync',
|
||||
'policy_version.prune' => 'Prune policy versions',
|
||||
'policy_version.restore' => 'Restore policy versions',
|
||||
'policy_version.force_delete' => 'Delete policy versions',
|
||||
@ -47,6 +54,7 @@ public static function expectedDurationSeconds(string $operationType): ?int
|
||||
{
|
||||
return match (trim($operationType)) {
|
||||
'policy.sync', 'policy.sync_one' => 90,
|
||||
'policy.export' => 120,
|
||||
'inventory.sync' => 180,
|
||||
'directory_groups.sync' => 120,
|
||||
'drift.generate' => 240,
|
||||
|
||||
67
app/Support/OpsUx/BulkRunContext.php
Normal file
67
app/Support/OpsUx/BulkRunContext.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\OpsUx;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class BulkRunContext
|
||||
{
|
||||
/**
|
||||
* @param array{entra_tenant_id?: mixed, directory_context_id?: mixed} $targetScope
|
||||
* @param array{kind: string, ids_hash?: string, query_hash?: string} $selectionIdentity
|
||||
* @param array<string, mixed> $extra
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public static function build(
|
||||
string $operationType,
|
||||
array $targetScope,
|
||||
array $selectionIdentity,
|
||||
string $fingerprint,
|
||||
array $extra = [],
|
||||
): array {
|
||||
$targetScope = self::normalizeTargetScope($targetScope);
|
||||
|
||||
return array_merge($extra, [
|
||||
'operation' => [
|
||||
'type' => trim($operationType),
|
||||
],
|
||||
'target_scope' => $targetScope,
|
||||
'selection' => $selectionIdentity,
|
||||
'idempotency' => [
|
||||
'fingerprint' => trim($fingerprint),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{entra_tenant_id?: mixed, directory_context_id?: mixed} $targetScope
|
||||
* @return array{entra_tenant_id?: string, directory_context_id?: string}
|
||||
*/
|
||||
public static function normalizeTargetScope(array $targetScope): array
|
||||
{
|
||||
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||
$directoryContextId = $targetScope['directory_context_id'] ?? null;
|
||||
|
||||
$normalized = [];
|
||||
|
||||
if (is_string($entraTenantId) && trim($entraTenantId) !== '') {
|
||||
$normalized['entra_tenant_id'] = trim($entraTenantId);
|
||||
}
|
||||
|
||||
if (is_string($directoryContextId) && trim($directoryContextId) !== '') {
|
||||
$normalized['directory_context_id'] = trim($directoryContextId);
|
||||
}
|
||||
|
||||
if (is_int($directoryContextId)) {
|
||||
$normalized['directory_context_id'] = (string) $directoryContextId;
|
||||
}
|
||||
|
||||
if (! isset($normalized['entra_tenant_id']) && ! isset($normalized['directory_context_id'])) {
|
||||
throw new InvalidArgumentException('Target scope must include entra_tenant_id or directory_context_id.');
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
31
app/Support/OpsUx/RunFailureSanitizer.php
Normal file
31
app/Support/OpsUx/RunFailureSanitizer.php
Normal file
@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\OpsUx;
|
||||
|
||||
final class RunFailureSanitizer
|
||||
{
|
||||
public static function sanitizeCode(string $code): string
|
||||
{
|
||||
$code = strtolower(trim($code));
|
||||
|
||||
if ($code === '') {
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
return substr($code, 0, 80);
|
||||
}
|
||||
|
||||
public static function sanitizeMessage(string $message): string
|
||||
{
|
||||
$message = trim(str_replace(["\r", "\n"], ' ', $message));
|
||||
|
||||
// Redact obvious bearer tokens / secrets.
|
||||
$message = preg_replace('/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*\b/i', 'Bearer [REDACTED]', $message) ?? $message;
|
||||
$message = preg_replace('/\b(access_token|refresh_token|client_secret|password)\s*[:=]\s*[^\s]+/i', '$1=[REDACTED]', $message) ?? $message;
|
||||
|
||||
// Redact long opaque blobs that look token-like.
|
||||
$message = preg_replace('/\b[A-Za-z0-9\-\._~\+\/]{64,}\b/', '[REDACTED]', $message) ?? $message;
|
||||
|
||||
return substr($message, 0, 120);
|
||||
}
|
||||
}
|
||||
@ -2,11 +2,10 @@
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
final class RunIdempotency
|
||||
final class RestoreRunIdempotency
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
@ -23,16 +22,6 @@ public static function buildKey(int $tenantId, string $operationType, string|int
|
||||
return hash('sha256', json_encode($payload, JSON_THROW_ON_ERROR));
|
||||
}
|
||||
|
||||
public static function findActiveBulkOperationRun(int $tenantId, string $idempotencyKey): ?BulkOperationRun
|
||||
{
|
||||
return BulkOperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->whereIn('status', ['pending', 'running'])
|
||||
->latest('id')
|
||||
->first();
|
||||
}
|
||||
|
||||
public static function findActiveRestoreRun(int $tenantId, string $idempotencyKey): ?RestoreRun
|
||||
{
|
||||
return RestoreRun::query()
|
||||
@ -320,6 +320,10 @@
|
||||
'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3),
|
||||
'recent_finished_seconds' => (int) env('TENANTPILOT_BULK_RECENT_FINISHED_SECONDS', 12),
|
||||
'progress_widget_enabled' => (bool) env('TENANTPILOT_BULK_PROGRESS_WIDGET_ENABLED', true),
|
||||
'concurrency' => [
|
||||
'per_target_scope_max' => (int) env('TENANTPILOT_BULK_CONCURRENCY_PER_TARGET_SCOPE_MAX', 1),
|
||||
'lock_ttl_seconds' => (int) env('TENANTPILOT_BULK_CONCURRENCY_LOCK_TTL_SECONDS', 900),
|
||||
],
|
||||
],
|
||||
|
||||
'inventory_sync' => [
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\BulkOperationRun>
|
||||
*/
|
||||
class BulkOperationRunFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => \App\Models\Tenant::factory(),
|
||||
'user_id' => \App\Models\User::factory(),
|
||||
'resource' => 'policy',
|
||||
'action' => 'delete',
|
||||
'status' => 'pending',
|
||||
'total_items' => 10,
|
||||
'processed_items' => 0,
|
||||
'succeeded' => 0,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
'item_ids' => range(1, 10),
|
||||
'failures' => [],
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('bulk_operation_runs');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::create('bulk_operation_runs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('resource', 50);
|
||||
$table->string('action', 50);
|
||||
$table->string('idempotency_key', 64)->nullable();
|
||||
$table->string('status', 50)->default('pending');
|
||||
$table->unsignedInteger('total_items');
|
||||
$table->unsignedInteger('processed_items')->default(0);
|
||||
$table->unsignedInteger('succeeded')->default(0);
|
||||
$table->unsignedInteger('failed')->default(0);
|
||||
$table->unsignedInteger('skipped')->default(0);
|
||||
$table->jsonb('item_ids');
|
||||
$table->jsonb('failures')->nullable();
|
||||
$table->foreignId('audit_log_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::table('bulk_operation_runs', function (Blueprint $table) {
|
||||
$table->index(['tenant_id', 'resource', 'status'], 'bulk_runs_tenant_resource_status');
|
||||
$table->index(['user_id', 'created_at'], 'bulk_runs_user_created');
|
||||
$table->index(['tenant_id', 'idempotency_key'], 'bulk_runs_tenant_idempotency');
|
||||
});
|
||||
|
||||
DB::statement("CREATE INDEX bulk_runs_status_active ON bulk_operation_runs (status) WHERE status IN ('pending', 'running')");
|
||||
DB::statement("CREATE UNIQUE INDEX bulk_runs_idempotency_active ON bulk_operation_runs (tenant_id, idempotency_key) WHERE idempotency_key IS NOT NULL AND status IN ('pending', 'running')");
|
||||
}
|
||||
};
|
||||
@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use Illuminate\Database\Seeder;
|
||||
|
||||
class BulkOperationsTestSeeder extends Seeder
|
||||
{
|
||||
/**
|
||||
* Run the database seeds.
|
||||
*/
|
||||
public function run(): void
|
||||
{
|
||||
$tenant = \App\Models\Tenant::first() ?? \App\Models\Tenant::factory()->create();
|
||||
$user = \App\Models\User::first() ?? \App\Models\User::factory()->create();
|
||||
|
||||
// Create some policies to test bulk delete
|
||||
\App\Models\Policy::factory()->count(30)->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
]);
|
||||
|
||||
// Create a completed bulk run
|
||||
\App\Models\BulkOperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'completed',
|
||||
'total_items' => 10,
|
||||
'processed_items' => 10,
|
||||
'succeeded' => 10,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\PruneOldOperationRunsJob;
|
||||
use App\Jobs\ReconcileAdapterRunsJob;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Schedule;
|
||||
@ -16,3 +17,8 @@
|
||||
->daily()
|
||||
->name(PruneOldOperationRunsJob::class)
|
||||
->withoutOverlapping();
|
||||
|
||||
Schedule::job(new ReconcileAdapterRunsJob)
|
||||
->everyThirtyMinutes()
|
||||
->name(ReconcileAdapterRunsJob::class)
|
||||
->withoutOverlapping();
|
||||
|
||||
@ -44,7 +44,8 @@ ### 2. Run Migrations
|
||||
### 3. Seed Test Data (Optional)
|
||||
|
||||
```bash
|
||||
./vendor/bin/sail artisan db:seed --class=BulkOperationsTestSeeder
|
||||
# NOTE: Removed by Feature 056 (OperationRun migration).
|
||||
# There is no BulkOperationsTestSeeder anymore.
|
||||
```
|
||||
|
||||
Creates:
|
||||
@ -409,10 +410,10 @@ # Watch queue jobs in real-time
|
||||
|
||||
# Monitor bulk operations
|
||||
./vendor/bin/sail artisan tinker
|
||||
>>> BulkOperationRun::inProgress()->get()
|
||||
>>> \App\Models\OperationRun::query()->where('status', 'running')->get()
|
||||
|
||||
# Seed more test data
|
||||
./vendor/bin/sail artisan db:seed --class=BulkOperationsTestSeeder
|
||||
# NOTE: Removed by Feature 056 (OperationRun migration).
|
||||
|
||||
# Clear cache
|
||||
./vendor/bin/sail artisan optimize:clear
|
||||
|
||||
76
specs/056-remove-legacy-bulkops/discovery.md
Normal file
76
specs/056-remove-legacy-bulkops/discovery.md
Normal file
@ -0,0 +1,76 @@
|
||||
# Discovery Report: Feature 056 — Remove Legacy BulkOperationRun
|
||||
|
||||
## Purpose
|
||||
|
||||
This report records the repo-wide sweep of legacy BulkOperationRun usage and any bulk-like actions that must be classified and migrated to canonical `OperationRun`.
|
||||
|
||||
## Legacy History Decision
|
||||
|
||||
- Default path: legacy BulkOperationRun history is **not** migrated into `OperationRun`.
|
||||
- After cutover, legacy tables are removed; historical investigation relies on database backups/exports if needed.
|
||||
|
||||
## Sweep Checklist
|
||||
|
||||
- [x] app/ (Models, Services, Jobs, Notifications, Support)
|
||||
- [x] app/Filament/ (Resources, Pages, Actions)
|
||||
- [x] database/ (migrations, factories, seeders)
|
||||
- [ ] resources/ (views)
|
||||
- [ ] routes/ (web, console)
|
||||
- [ ] tests/ (Feature, Unit)
|
||||
|
||||
## Findings
|
||||
|
||||
### A) Legacy artifacts (to remove)
|
||||
|
||||
| Kind | Path | Notes |
|
||||
|------|------|-------|
|
||||
| Model | app/Models/BulkOperationRun.php | Legacy run model; referenced across jobs and UI. |
|
||||
| Service | app/Services/BulkOperationService.php | Legacy run lifecycle + failure recording; widely referenced. |
|
||||
| Filament Resource | app/Filament/Resources/BulkOperationRunResource.php | Legacy Monitoring surface; must be removed (Monitoring uses OperationRun). |
|
||||
| Filament Pages | app/Filament/Resources/BulkOperationRunResource/Pages/ListBulkOperationRuns.php | Legacy list page. |
|
||||
| Filament Pages | app/Filament/Resources/BulkOperationRunResource/Pages/ViewBulkOperationRun.php | Legacy detail page. |
|
||||
| Policy | app/Policies/BulkOperationRunPolicy.php | Legacy authorization policy. |
|
||||
| Policy Registration | app/Providers/AppServiceProvider.php | Registers BulkOperationRun policy/gate mapping. |
|
||||
| Helper | app/Support/RunIdempotency.php | `findActiveBulkOperationRun(...)` helper for legacy dedupe. |
|
||||
| Command | app/Console/Commands/TenantpilotPurgeNonPersistentData.php | Still references BulkOperationRun and legacy table counts. |
|
||||
| DB Migrations | database/migrations/*bulk_operation_runs* | Legacy table creation + follow-up schema changes. |
|
||||
| Factory | database/factories/BulkOperationRunFactory.php | Test factory to remove after cutover. |
|
||||
| Seeder | database/seeders/BulkOperationsTestSeeder.php | Test seed data to remove after cutover. |
|
||||
| Table | bulk_operation_runs | Legacy DB table; drop via forward migration after cutover. |
|
||||
|
||||
### B) Bulk-like start surfaces (to migrate)
|
||||
|
||||
| Surface | Path | Operation type | Target scope? | Notes |
|
||||
|---------|------|----------------|---------------|-------|
|
||||
| Policy bulk delete | app/Filament/Resources/PolicyResource.php | `policy.delete` | Yes (tenant + directory scope) | Migrates to OperationRun-backed enqueue + orchestrator/worker. |
|
||||
| Backup set bulk delete | app/Filament/Resources/BackupSetResource.php | `backup_set.delete` | Yes | Migrates to OperationRun-backed enqueue + orchestrator/worker. |
|
||||
| Policy version prune | app/Filament/Resources/PolicyVersionResource.php | `policy_version.prune` | Yes | Migrates to OperationRun-backed enqueue + orchestrator/worker. |
|
||||
| Policy version force delete | app/Filament/Resources/PolicyVersionResource.php | `policy_version.force_delete` | Yes | Migrates to OperationRun-backed enqueue + orchestrator/worker. |
|
||||
| Restore run bulk delete | app/Filament/Resources/RestoreRunResource.php | `restore_run.delete` | Yes | Migrates to OperationRun-backed enqueue + orchestrator/worker. |
|
||||
| Tenant bulk sync | app/Filament/Resources/TenantResource.php | `tenant.sync` | Yes | Migrates to OperationRun-backed enqueue + orchestrator/worker. |
|
||||
| Policy snapshot capture | app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php | `policy.capture_snapshot` | Yes | Migrates to OperationRun-backed enqueue + orchestrator/worker. |
|
||||
| Drift generation | app/Filament/Pages/DriftLanding.php | `drift.generate` | Yes | Mixed legacy + OperationRun today; needs full canonicalization. |
|
||||
| Backup set add policies (picker) | app/Livewire/BackupSetPolicyPickerTable.php | `backup_set.add_policies` | Yes | Mixed legacy + OperationRun today; remove legacy dedupe + legacy links. |
|
||||
|
||||
### C) Bulk-like jobs/workers (to migrate)
|
||||
|
||||
| Job | Path | Remote calls? | Notes |
|
||||
|-----|------|--------------|------|
|
||||
| Policy bulk delete | app/Jobs/BulkPolicyDeleteJob.php | Likely (Graph) | Legacy bulk job; replaced by orchestrator/worker pattern. |
|
||||
| Backup set bulk delete | app/Jobs/BulkBackupSetDeleteJob.php | Likely (Graph) | Legacy bulk job; replaced by orchestrator/worker pattern. |
|
||||
| Policy version prune | app/Jobs/BulkPolicyVersionPruneJob.php | Likely (Graph) | Legacy bulk job; replaced by orchestrator/worker pattern. |
|
||||
| Policy version force delete | app/Jobs/BulkPolicyVersionForceDeleteJob.php | Likely (Graph) | Legacy bulk job; replaced by orchestrator/worker pattern. |
|
||||
| Restore run bulk delete | app/Jobs/BulkRestoreRunDeleteJob.php | Likely (Graph) | Legacy bulk job; replaced by orchestrator/worker pattern. |
|
||||
| Tenant bulk sync | app/Jobs/BulkTenantSyncJob.php | Likely (Graph) | Legacy bulk job; replaced by orchestrator/worker pattern. |
|
||||
| Policy snapshot capture | app/Jobs/CapturePolicySnapshotJob.php | Likely (Graph) | Legacy-ish job; migrate to orchestrator/worker and canonical run updates. |
|
||||
| Drift generator | app/Jobs/GenerateDriftFindingsJob.php | Likely (Graph) | Already carries OperationRun; remove remaining legacy coupling. |
|
||||
| Backup set add policies | app/Jobs/AddPoliciesToBackupSetJob.php | Likely (Graph) | Contains a legacy “fallback link” to BulkOperationRunResource; canonicalize. |
|
||||
| Policy bulk sync (legacy) | app/Jobs/BulkPolicySyncJob.php | Likely (Graph) | Legacy bulk job; migrate/remove as part of full cutover. |
|
||||
|
||||
### D) “View run” links (to canonicalize)
|
||||
|
||||
| Location | Path | Current link target | Fix |
|
||||
|----------|------|---------------------|-----|
|
||||
| Run-status notification | app/Notifications/RunStatusChangedNotification.php | `BulkOperationRunResource::getUrl('view', ...)` | Route via `OperationRunLinks::view(...)` (OperationRun is canonical). |
|
||||
| Add policies job notification links | app/Jobs/AddPoliciesToBackupSetJob.php | Conditional: OperationRun OR legacy BulkOperationRunResource view | Remove legacy fallback; always use OperationRun-backed links. |
|
||||
| Legacy resource itself | app/Filament/Resources/BulkOperationRunResource.php | Legacy list/detail routes exist | Remove resource; rely on Monitoring → Operations. |
|
||||
@ -9,9 +9,22 @@ ## Common commands (Sail-first)
|
||||
|
||||
- Boot: `./vendor/bin/sail up -d`
|
||||
- Run migrations: `./vendor/bin/sail artisan migrate`
|
||||
- Run targeted tests: `./vendor/bin/sail artisan test tests/Feature`
|
||||
- Run targeted tests (Ops UX): `./vendor/bin/sail artisan test tests/Feature/OpsUx`
|
||||
- Run a single test file: `./vendor/bin/sail artisan test tests/Feature/OpsUx/BulkEnqueueIdempotencyTest.php`
|
||||
- Run legacy guard test: `./vendor/bin/sail artisan test tests/Feature/Guards/NoLegacyBulkOperationsTest.php`
|
||||
- Run queue worker (local): `./vendor/bin/sail artisan queue:work --verbose --tries=3 --timeout=300`
|
||||
- Format (required): `./vendor/bin/pint --dirty`
|
||||
|
||||
## Validation (targeted)
|
||||
|
||||
- Ops UX suite: `./vendor/bin/sail artisan test tests/Feature/OpsUx`
|
||||
- Legacy reference guard: `./vendor/bin/sail artisan test tests/Feature/Guards/NoLegacyBulkOperationsTest.php`
|
||||
|
||||
## Ops commands
|
||||
|
||||
- Preview adapter reconciliation (dry run): `./vendor/bin/sail artisan ops:reconcile-adapter-runs --dry-run=true`
|
||||
- Apply adapter reconciliation: `./vendor/bin/sail artisan ops:reconcile-adapter-runs --dry-run=false`
|
||||
|
||||
## What to build (high level)
|
||||
|
||||
- Replace all legacy bulk-run usage with the canonical OperationRun run model.
|
||||
|
||||
@ -10,9 +10,9 @@ ## Phase 1: Setup (Shared Infrastructure)
|
||||
|
||||
**Purpose**: Ensure feature docs/paths are ready for implementation and review
|
||||
|
||||
- [ ] T001 Review and update quickstart commands in specs/056-remove-legacy-bulkops/quickstart.md
|
||||
- [ ] T002 [P] Create discovery report scaffold in specs/056-remove-legacy-bulkops/discovery.md
|
||||
- [ ] T003 [P] Add a “legacy history decision” section to specs/056-remove-legacy-bulkops/discovery.md
|
||||
- [X] T001 Review and update quickstart commands in specs/056-remove-legacy-bulkops/quickstart.md
|
||||
- [X] T002 [P] Create discovery report scaffold in specs/056-remove-legacy-bulkops/discovery.md
|
||||
- [X] T003 [P] Add a “legacy history decision” section to specs/056-remove-legacy-bulkops/discovery.md
|
||||
|
||||
---
|
||||
|
||||
@ -20,14 +20,14 @@ ## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**Purpose**: Shared primitives required for all bulk migrations and hardening
|
||||
|
||||
- [ ] T004 Populate the full repo-wide discovery sweep in specs/056-remove-legacy-bulkops/discovery.md (app/, resources/, database/, tests/)
|
||||
- [ ] T005 [P] Add config keys for per-target scope concurrency (default=1) in config/tenantpilot.php
|
||||
- [ ] T006 [P] Register new bulk operation types/labels/durations in app/Support/OperationCatalog.php
|
||||
- [ ] T007 [P] Implement hybrid selection identity hasher in app/Services/Operations/BulkSelectionIdentity.php
|
||||
- [ ] T008 [P] Implement idempotency fingerprint builder in app/Services/Operations/BulkIdempotencyFingerprint.php
|
||||
- [ ] T009 [P] Implement per-target scope concurrency limiter (Cache locks) in app/Services/Operations/TargetScopeConcurrencyLimiter.php
|
||||
- [ ] T010 Extend OperationRun identity inputs to include target scope + selection identity in app/Services/OperationRunService.php
|
||||
- [ ] T011 Add a bulk enqueue helper that standardizes ensureRun + dispatchOrFail usage in app/Services/OperationRunService.php
|
||||
- [X] T004 Populate the full repo-wide discovery sweep in specs/056-remove-legacy-bulkops/discovery.md (app/, resources/, database/, tests/)
|
||||
- [X] T005 [P] Add config keys for per-target scope concurrency (default=1) in config/tenantpilot.php
|
||||
- [X] T006 [P] Register new bulk operation types/labels/durations in app/Support/OperationCatalog.php
|
||||
- [X] T007 [P] Implement hybrid selection identity hasher in app/Services/Operations/BulkSelectionIdentity.php
|
||||
- [X] T008 [P] Implement idempotency fingerprint builder in app/Services/Operations/BulkIdempotencyFingerprint.php
|
||||
- [X] T009 [P] Implement per-target scope concurrency limiter (Cache locks) in app/Services/Operations/TargetScopeConcurrencyLimiter.php
|
||||
- [X] T010 Extend OperationRun identity inputs to include target scope + selection identity in app/Services/OperationRunService.php
|
||||
- [X] T011 Add a bulk enqueue helper that standardizes ensureRun + dispatchOrFail usage in app/Services/OperationRunService.php
|
||||
|
||||
**Checkpoint**: Shared primitives exist; bulk migrations can proceed consistently
|
||||
|
||||
@ -41,47 +41,47 @@ ## Phase 3: User Story 1 - Run-backed bulk actions are always observable (Priori
|
||||
|
||||
### Tests for User Story 1
|
||||
|
||||
- [ ] T012 [P] [US1] Add bulk enqueue idempotency test in tests/Feature/OpsUx/BulkEnqueueIdempotencyTest.php
|
||||
- [ ] T013 [P] [US1] Add per-target concurrency default=1 test in tests/Feature/OpsUx/TargetScopeConcurrencyLimiterTest.php
|
||||
- [ ] T014 [P] [US1] Add hybrid selection identity hashing test in tests/Unit/Operations/BulkSelectionIdentityTest.php
|
||||
- [X] T012 [P] [US1] Add bulk enqueue idempotency test in tests/Feature/OpsUx/BulkEnqueueIdempotencyTest.php
|
||||
- [X] T013 [P] [US1] Add per-target concurrency default=1 test in tests/Feature/OpsUx/TargetScopeConcurrencyLimiterTest.php
|
||||
- [X] T014 [P] [US1] Add hybrid selection identity hashing test in tests/Unit/Operations/BulkSelectionIdentityTest.php
|
||||
|
||||
### Implementation for User Story 1
|
||||
|
||||
- [ ] T015 [P] [US1] Add OperationRun context shape helpers for bulk runs in app/Support/OpsUx/BulkRunContext.php
|
||||
- [ ] T016 [P] [US1] Implement orchestrator job skeleton for bulk runs in app/Jobs/Operations/BulkOperationOrchestratorJob.php
|
||||
- [ ] T017 [P] [US1] Implement worker job skeleton for bulk items in app/Jobs/Operations/BulkOperationWorkerJob.php
|
||||
- [ ] T018 [US1] Ensure worker jobs update summary_counts via canonical whitelist in app/Services/OperationRunService.php
|
||||
- [X] T015 [P] [US1] Add OperationRun context shape helpers for bulk runs in app/Support/OpsUx/BulkRunContext.php
|
||||
- [X] T016 [P] [US1] Implement orchestrator job skeleton for bulk runs in app/Jobs/Operations/BulkOperationOrchestratorJob.php
|
||||
- [X] T017 [P] [US1] Implement worker job skeleton for bulk items in app/Jobs/Operations/BulkOperationWorkerJob.php
|
||||
- [X] T018 [US1] Ensure worker jobs update summary_counts via canonical whitelist in app/Services/OperationRunService.php
|
||||
|
||||
**Decision (applies to all US1 migrations)**: Use the standard orchestrator + item worker pattern.
|
||||
- Keep T016/T017 as the canonical implementation.
|
||||
- Per-domain bulk logic MUST be implemented as item worker(s) invoked by the orchestrator.
|
||||
- Avoid parallel legacy bulk job systems.
|
||||
|
||||
- [ ] T019 [US1] Migrate policy bulk delete start action to OperationRun-backed flow in app/Filament/Resources/PolicyResource.php
|
||||
- [ ] T020 [US1] Refactor policy bulk delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkPolicyDeleteJob.php
|
||||
- [X] T019 [US1] Migrate policy bulk delete start action to OperationRun-backed flow in app/Filament/Resources/PolicyResource.php
|
||||
- [X] T020 [US1] Refactor policy bulk delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkPolicyDeleteJob.php
|
||||
|
||||
- [ ] T021 [US1] Migrate backup set bulk delete start action to OperationRun-backed flow in app/Filament/Resources/BackupSetResource.php
|
||||
- [ ] T022 [US1] Refactor backup set bulk delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkBackupSetDeleteJob.php
|
||||
- [X] T021 [US1] Migrate backup set bulk delete start action to OperationRun-backed flow in app/Filament/Resources/BackupSetResource.php
|
||||
- [X] T022 [US1] Refactor backup set bulk delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkBackupSetDeleteJob.php
|
||||
|
||||
- [ ] T023 [US1] Migrate policy version prune start action to OperationRun-backed flow in app/Filament/Resources/PolicyVersionResource.php
|
||||
- [ ] T024 [US1] Refactor policy version prune execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkPolicyVersionPruneJob.php
|
||||
- [X] T023 [US1] Migrate policy version prune start action to OperationRun-backed flow in app/Filament/Resources/PolicyVersionResource.php
|
||||
- [X] T024 [US1] Refactor policy version prune execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkPolicyVersionPruneJob.php
|
||||
|
||||
- [ ] T025 [US1] Migrate policy version force delete start action to OperationRun-backed flow in app/Filament/Resources/PolicyVersionResource.php
|
||||
- [ ] T026 [US1] Refactor policy version force delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkPolicyVersionForceDeleteJob.php
|
||||
- [X] T025 [US1] Migrate policy version force delete start action to OperationRun-backed flow in app/Filament/Resources/PolicyVersionResource.php
|
||||
- [X] T026 [US1] Refactor policy version force delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkPolicyVersionForceDeleteJob.php
|
||||
|
||||
- [ ] T027 [US1] Migrate restore run bulk delete start action to OperationRun-backed flow in app/Filament/Resources/RestoreRunResource.php
|
||||
- [ ] T028 [US1] Refactor restore run bulk delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkRestoreRunDeleteJob.php
|
||||
- [X] T027 [US1] Migrate restore run bulk delete start action to OperationRun-backed flow in app/Filament/Resources/RestoreRunResource.php
|
||||
- [X] T028 [US1] Refactor restore run bulk delete execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkRestoreRunDeleteJob.php
|
||||
|
||||
- [ ] T029 [US1] Migrate tenant bulk sync start action to OperationRun-backed flow in app/Filament/Resources/TenantResource.php
|
||||
- [ ] T030 [US1] Refactor tenant bulk sync execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkTenantSyncJob.php
|
||||
- [X] T029 [US1] Migrate tenant bulk sync start action to OperationRun-backed flow in app/Filament/Resources/TenantResource.php
|
||||
- [X] T030 [US1] Refactor tenant bulk sync execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/BulkTenantSyncJob.php
|
||||
|
||||
- [ ] T031 [US1] Migrate policy snapshot capture action to OperationRun-backed flow in app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php
|
||||
- [ ] T032 [US1] Refactor snapshot capture execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/CapturePolicySnapshotJob.php
|
||||
- [X] T031 [US1] Migrate policy snapshot capture action to OperationRun-backed flow in app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php
|
||||
- [X] T032 [US1] Refactor snapshot capture execution into orchestrator/item worker pattern (replace legacy bulk job semantics) in app/Jobs/CapturePolicySnapshotJob.php
|
||||
|
||||
- [ ] T033 [US1] Migrate drift generation flow to OperationRun-backed flow in app/Filament/Pages/DriftLanding.php
|
||||
- [ ] T034 [US1] Remove legacy BulkOperationRun coupling from drift generator job in app/Jobs/GenerateDriftFindingsJob.php
|
||||
- [X] T033 [US1] Migrate drift generation flow to OperationRun-backed flow in app/Filament/Pages/DriftLanding.php
|
||||
- [X] T034 [US1] Remove legacy BulkOperationRun coupling from drift generator job in app/Jobs/GenerateDriftFindingsJob.php
|
||||
|
||||
- [ ] T035 [US1] Remove legacy “fallback link” usage and standardize view-run URLs to canonical OperationRun links in app/Jobs/AddPoliciesToBackupSetJob.php
|
||||
- [X] T035 [US1] Remove legacy “fallback link” usage and standardize view-run URLs to canonical OperationRun links in app/Jobs/AddPoliciesToBackupSetJob.php
|
||||
|
||||
**Checkpoint**: At this point, at least one representative bulk action is fully run-backed and visible in Monitoring
|
||||
|
||||
@ -95,21 +95,21 @@ ## Phase 4: User Story 2 - Monitoring is the single source of run history (Prior
|
||||
|
||||
### Tests for User Story 2
|
||||
|
||||
- [ ] T036 [P] [US2] Add regression test that notifications do not link to BulkOperationRun resources in tests/Feature/OpsUx/NotificationViewRunLinkTest.php
|
||||
- [X] T036 [P] [US2] Add regression test that notifications do not link to BulkOperationRun resources in tests/Feature/OpsUx/NotificationViewRunLinkTest.php
|
||||
|
||||
### Implementation for User Story 2
|
||||
|
||||
- [ ] T037 [US2] Replace BulkOperationRun resource links with OperationRun links in app/Notifications/RunStatusChangedNotification.php
|
||||
- [ ] T038 [US2] Replace BulkOperationRun resource links with OperationRun links in app/Filament/Resources/BackupSetResource.php
|
||||
- [ ] T039 [US2] Replace BulkOperationRun resource links with OperationRun links in app/Filament/Resources/PolicyVersionResource.php
|
||||
- [ ] T040 [US2] Replace BulkOperationRun resource links/redirects with OperationRun links in app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php
|
||||
- [X] T037 [US2] Replace BulkOperationRun resource links with OperationRun links in app/Notifications/RunStatusChangedNotification.php
|
||||
- [X] T038 [US2] Replace BulkOperationRun resource links with OperationRun links in app/Filament/Resources/BackupSetResource.php
|
||||
- [X] T039 [US2] Replace BulkOperationRun resource links with OperationRun links in app/Filament/Resources/PolicyVersionResource.php
|
||||
- [X] T040 [US2] Replace BulkOperationRun resource links/redirects with OperationRun links in app/Filament/Resources/PolicyResource/Pages/ViewPolicy.php
|
||||
|
||||
- [ ] T041 [US2] Remove legacy resource and pages: app/Filament/Resources/BulkOperationRunResource.php
|
||||
- [ ] T042 [US2] Remove legacy resource pages: app/Filament/Resources/BulkOperationRunResource/Pages/ListBulkOperationRuns.php
|
||||
- [ ] T043 [US2] Remove legacy resource pages: app/Filament/Resources/BulkOperationRunResource/Pages/ViewBulkOperationRun.php
|
||||
- [X] T041 [US2] Remove legacy resource and pages: app/Filament/Resources/BulkOperationRunResource.php
|
||||
- [X] T042 [US2] Remove legacy resource pages: app/Filament/Resources/BulkOperationRunResource/Pages/ListBulkOperationRuns.php
|
||||
- [X] T043 [US2] Remove legacy resource pages: app/Filament/Resources/BulkOperationRunResource/Pages/ViewBulkOperationRun.php
|
||||
|
||||
- [ ] T044 [US2] Remove legacy authorization policy in app/Policies/BulkOperationRunPolicy.php
|
||||
- [ ] T045 [US2] Remove BulkOperationRun gate registration in app/Providers/AppServiceProvider.php
|
||||
- [X] T044 [US2] Remove legacy authorization policy in app/Policies/BulkOperationRunPolicy.php
|
||||
- [X] T045 [US2] Remove BulkOperationRun gate registration in app/Providers/AppServiceProvider.php
|
||||
|
||||
**Checkpoint**: No navigation/link surfaces reference legacy bulk runs; Monitoring is the sole run history surface
|
||||
|
||||
@ -123,16 +123,16 @@ ## Phase 5: User Story 3 - Developers can’t accidentally reintroduce legacy pa
|
||||
|
||||
### Tests for User Story 3
|
||||
|
||||
- [ ] T046 [P] [US3] Add “no legacy references” test guard in tests/Feature/Guards/NoLegacyBulkOperationsTest.php
|
||||
- [ ] T047 [P] [US3] Add tenant isolation guard for bulk enqueue inputs in tests/Feature/OpsUx/BulkTenantIsolationTest.php
|
||||
- [ ] T048 [P] [US3] Extend summary_counts whitelist coverage for bulk updates in tests/Feature/OpsUx/SummaryCountsWhitelistTest.php
|
||||
- [X] T046 [P] [US3] Add “no legacy references” test guard in tests/Feature/Guards/NoLegacyBulkOperationsTest.php
|
||||
- [X] T047 [P] [US3] Add tenant isolation guard for bulk enqueue inputs in tests/Feature/OpsUx/BulkTenantIsolationTest.php
|
||||
- [X] T048 [P] [US3] Extend summary_counts whitelist coverage for bulk updates in tests/Feature/OpsUx/SummaryCountsWhitelistTest.php
|
||||
|
||||
### Implementation for User Story 3
|
||||
|
||||
- [ ] T049 [US3] Remove legacy BulkOperationRun unit tests in tests/Unit/BulkOperationRunStatusBucketTest.php
|
||||
- [ ] T050 [US3] Remove legacy BulkOperationRun unit tests in tests/Unit/BulkOperationRunProgressTest.php
|
||||
- [ ] T051 [US3] Remove legacy factory and update any dependent tests in database/factories/BulkOperationRunFactory.php
|
||||
- [ ] T052 [US3] Remove legacy test seeder and update any dependent docs/tests in database/seeders/BulkOperationsTestSeeder.php
|
||||
- [X] T049 [US3] Remove legacy BulkOperationRun unit tests in tests/Unit/BulkOperationRunStatusBucketTest.php
|
||||
- [X] T050 [US3] Remove legacy BulkOperationRun unit tests in tests/Unit/BulkOperationRunProgressTest.php
|
||||
- [X] T051 [US3] Remove legacy factory and update any dependent tests in database/factories/BulkOperationRunFactory.php
|
||||
- [X] T052 [US3] Remove legacy test seeder and update any dependent docs/tests in database/seeders/BulkOperationsTestSeeder.php
|
||||
|
||||
**Checkpoint**: Guardrails prevent reintroduction; test suite enforces taxonomy and canonical run surfaces
|
||||
|
||||
@ -142,8 +142,8 @@ ## Phase 6: Polish & Cross-Cutting Concerns
|
||||
|
||||
**Purpose**: Final removal of legacy DB artifacts and cleanup
|
||||
|
||||
- [ ] T053 Create migration to drop legacy bulk_operation_runs table in database/migrations/2026_01_18_000001_drop_bulk_operation_runs_table.php
|
||||
- [ ] T054 Do NOT delete historical migrations; add forward drop migrations only
|
||||
- [X] T053 Create migration to drop legacy bulk_operation_runs table in database/migrations/2026_01_18_000001_drop_bulk_operation_runs_table.php
|
||||
- [X] T054 Do NOT delete historical migrations; add forward drop migrations only
|
||||
- Keep old migrations to support fresh installs and CI rebuilds
|
||||
- Add a new forward migration: drop legacy bulk tables after cutover
|
||||
- Document the cutover precondition (no new legacy writes)
|
||||
@ -151,7 +151,7 @@ ## Phase 6: Polish & Cross-Cutting Concerns
|
||||
- [ ] T055 (optional) If schema history cleanup is required, use a documented squash/snapshot process
|
||||
- Only via explicit procedure (not ad-hoc deletes)
|
||||
- Must keep a reproducible schema for new environments
|
||||
- [ ] T056 Validate feature via targeted test run list and update notes in specs/056-remove-legacy-bulkops/quickstart.md
|
||||
- [X] T056 Validate feature via targeted test run list and update notes in specs/056-remove-legacy-bulkops/quickstart.md
|
||||
|
||||
---
|
||||
|
||||
@ -166,15 +166,15 @@ ## Monitoring DB-only render guard (NFR-01)
|
||||
|
||||
## Legacy removal (FR-006)
|
||||
|
||||
- [ ] T062 Remove BulkOperationRun model + related database artifacts (after cutover)
|
||||
- [X] T062 Remove BulkOperationRun model + related database artifacts (after cutover)
|
||||
- Delete app/Models/BulkOperationRun.php (and any related models)
|
||||
- Ensure no runtime references remain
|
||||
|
||||
- [ ] T063 Remove BulkOperationService and migrate all call sites to OperationRunService patterns
|
||||
- [X] T063 Remove BulkOperationService and migrate all call sites to OperationRunService patterns
|
||||
- Replace all uses of BulkOperationService::createRun(...) / dispatch flows
|
||||
- Ensure all bulk actions create OperationRun and dispatch orchestrator/worker jobs
|
||||
|
||||
- [ ] T064 Add CI guard to prevent reintroduction of BulkOperationRun/BulkOperationService
|
||||
- [X] T064 Add CI guard to prevent reintroduction of BulkOperationRun/BulkOperationService
|
||||
- Grep/arch test: fail if repo contains BulkOperationRun or BulkOperationService
|
||||
|
||||
## Target scope display (FR-008)
|
||||
@ -205,7 +205,7 @@ ## Remote retry/backoff/jitter policy (NFR-03)
|
||||
|
||||
## Canonical “View run” sweep and guard (FR-005)
|
||||
|
||||
- [ ] T069 Perform a repo-wide sweep to ensure all “View run” links route to Monitoring → Operations → Run Detail
|
||||
- [X] T069 Perform a repo-wide sweep to ensure all “View run” links route to Monitoring → Operations → Run Detail
|
||||
- Grep (or ripgrep if available) for legacy routes/resource URLs and legacy BulkOperationRun links
|
||||
- Ensure links go through a canonical OperationRun URL helper (or equivalent single source)
|
||||
- Optional: add a CI grep/guard forbidding known legacy route names/URLs
|
||||
@ -213,6 +213,15 @@ ## Canonical “View run” sweep and guard (FR-005)
|
||||
|
||||
---
|
||||
|
||||
## Adapter run reconciliation (NFR-04)
|
||||
|
||||
- [X] T070 Add DB-only adapter run reconciler in app/Services/AdapterRunReconciler.php
|
||||
- [X] T071 Implement ops:reconcile-adapter-runs command in app/Console/Commands/OpsReconcileAdapterRuns.php
|
||||
- [X] T072 Implement scheduled reconciliation job + scheduler wiring in app/Jobs/ReconcileAdapterRunsJob.php and routes/console.php
|
||||
- [X] T073 Add AdapterRunReconciler tests in tests/Feature/OpsUx/AdapterRunReconcilerTest.php
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### User Story Dependencies
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSet;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
@ -75,14 +74,13 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
|
||||
|
||||
Cache::flush();
|
||||
|
||||
(new RunBackupScheduleJob($run->id, null, $operationRun))->handle(
|
||||
(new RunBackupScheduleJob($run->id, $operationRun))->handle(
|
||||
app(PolicySyncService::class),
|
||||
app(BackupService::class),
|
||||
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
|
||||
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
|
||||
app(\App\Services\Intune\AuditLogger::class),
|
||||
app(\App\Services\BackupScheduling\RunErrorMapper::class),
|
||||
app(BulkOperationService::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
@ -92,11 +90,14 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
|
||||
$operationRun->refresh();
|
||||
expect($operationRun->status)->toBe('completed');
|
||||
expect($operationRun->outcome)->toBe('succeeded');
|
||||
expect($operationRun->summary_counts)->toMatchArray([
|
||||
expect($operationRun->context)->toMatchArray([
|
||||
'backup_schedule_id' => (int) $schedule->id,
|
||||
'backup_schedule_run_id' => (int) $run->id,
|
||||
'backup_set_id' => (int) $backupSet->id,
|
||||
]);
|
||||
expect($operationRun->summary_counts)->toMatchArray([
|
||||
'created' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips runs when all policy types are unknown', function () {
|
||||
@ -137,14 +138,13 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new RunBackupScheduleJob($run->id, null, $operationRun))->handle(
|
||||
(new RunBackupScheduleJob($run->id, $operationRun))->handle(
|
||||
app(PolicySyncService::class),
|
||||
app(BackupService::class),
|
||||
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
|
||||
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
|
||||
app(\App\Services\Intune\AuditLogger::class),
|
||||
app(\App\Services\BackupScheduling\RunErrorMapper::class),
|
||||
app(BulkOperationService::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
@ -237,15 +237,16 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
|
||||
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
|
||||
app(\App\Services\Intune\AuditLogger::class),
|
||||
app(\App\Services\BackupScheduling\RunErrorMapper::class),
|
||||
app(BulkOperationService::class),
|
||||
);
|
||||
|
||||
$operationRun->refresh();
|
||||
expect($operationRun->status)->toBe('completed');
|
||||
expect($operationRun->outcome)->toBe('succeeded');
|
||||
expect($operationRun->summary_counts)->toMatchArray([
|
||||
'backup_schedule_id' => (int) $schedule->id,
|
||||
expect($operationRun->context)->toMatchArray([
|
||||
'backup_schedule_run_id' => (int) $run->id,
|
||||
'backup_set_id' => (int) $backupSet->id,
|
||||
]);
|
||||
expect($operationRun->summary_counts)->toMatchArray([
|
||||
'created' => 1,
|
||||
]);
|
||||
});
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\User;
|
||||
use App\Notifications\OperationRunQueued;
|
||||
@ -68,14 +67,6 @@
|
||||
'backup_schedule_run_id' => (int) $run->id,
|
||||
]);
|
||||
|
||||
expect(BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('resource', 'backup_schedule')
|
||||
->where('action', 'run')
|
||||
->count())
|
||||
->toBe(1);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
|
||||
return $job->backupScheduleRunId === (int) $run->id
|
||||
&& $job->operationRun instanceof OperationRun
|
||||
@ -139,14 +130,6 @@
|
||||
'backup_schedule_run_id' => (int) $run->id,
|
||||
]);
|
||||
|
||||
expect(BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('resource', 'backup_schedule')
|
||||
->where('action', 'retry')
|
||||
->count())
|
||||
->toBe(1);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
|
||||
return $job->backupScheduleRunId === (int) $run->id
|
||||
&& $job->operationRun instanceof OperationRun
|
||||
@ -259,14 +242,6 @@
|
||||
->count())
|
||||
->toBe(2);
|
||||
|
||||
expect(BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('resource', 'backup_schedule')
|
||||
->where('action', 'run')
|
||||
->count())
|
||||
->toBe(1);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
@ -330,14 +305,6 @@
|
||||
->count())
|
||||
->toBe(2);
|
||||
|
||||
expect(BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('resource', 'backup_schedule')
|
||||
->where('action', 'retry')
|
||||
->count())
|
||||
->toBe(1);
|
||||
|
||||
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||
$this->assertDatabaseCount('notifications', 1);
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
|
||||
@ -3,13 +3,13 @@
|
||||
use App\Jobs\AddPoliciesToBackupSetJob;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\FoundationSnapshotService;
|
||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||
use App\Services\Intune\SnapshotValidator;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Mockery\MockInterface;
|
||||
|
||||
@ -44,23 +44,19 @@
|
||||
'snapshot' => ['id' => $policyA->external_id],
|
||||
]);
|
||||
|
||||
$run = BulkOperationRun::factory()->create([
|
||||
$run = OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'resource' => 'backup_set',
|
||||
'action' => 'add_policies',
|
||||
'status' => 'pending',
|
||||
'total_items' => 2,
|
||||
'item_ids' => [
|
||||
'backup_set_id' => $backupSet->id,
|
||||
'policy_ids' => [$policyA->id, $policyB->id],
|
||||
'options' => [
|
||||
'include_assignments' => true,
|
||||
'include_scope_tags' => true,
|
||||
'include_foundations' => false,
|
||||
],
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'backup_set.add_policies',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => [
|
||||
'backup_set_id' => (int) $backupSet->getKey(),
|
||||
'policy_ids' => [(int) $policyA->getKey(), (int) $policyB->getKey()],
|
||||
],
|
||||
'failures' => [],
|
||||
'summary_counts' => [],
|
||||
'failure_summary' => [],
|
||||
]);
|
||||
|
||||
$this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($policyA, $policyB, $tenant, $versionA) {
|
||||
@ -107,15 +103,21 @@
|
||||
});
|
||||
|
||||
$job = new AddPoliciesToBackupSetJob(
|
||||
bulkRunId: (int) $run->getKey(),
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
backupSetId: (int) $backupSet->getKey(),
|
||||
includeAssignments: true,
|
||||
includeScopeTags: true,
|
||||
includeFoundations: false,
|
||||
policyIds: [(int) $policyA->getKey(), (int) $policyB->getKey()],
|
||||
options: [
|
||||
'include_assignments' => true,
|
||||
'include_scope_tags' => true,
|
||||
'include_foundations' => false,
|
||||
],
|
||||
idempotencyKey: 'test-idempotency-key',
|
||||
operationRun: $run,
|
||||
);
|
||||
|
||||
$job->handle(
|
||||
bulkOperationService: app(BulkOperationService::class),
|
||||
operationRunService: app(OperationRunService::class),
|
||||
captureOrchestrator: app(PolicyCaptureOrchestrator::class),
|
||||
foundationSnapshots: $this->mock(FoundationSnapshotService::class),
|
||||
snapshotValidator: app(SnapshotValidator::class),
|
||||
@ -124,23 +126,23 @@
|
||||
$run->refresh();
|
||||
$backupSet->refresh();
|
||||
|
||||
expect($run->status)->toBe('completed_with_errors');
|
||||
expect($run->total_items)->toBe(2);
|
||||
expect($run->processed_items)->toBe(2);
|
||||
expect($run->succeeded)->toBe(1);
|
||||
expect($run->failed)->toBe(1);
|
||||
expect($run->skipped)->toBe(0);
|
||||
expect($run->status)->toBe('completed');
|
||||
expect($run->outcome)->toBe('partially_succeeded');
|
||||
expect((int) ($run->summary_counts['total'] ?? 0))->toBe(2);
|
||||
expect((int) ($run->summary_counts['processed'] ?? 0))->toBe(2);
|
||||
expect((int) ($run->summary_counts['succeeded'] ?? 0))->toBe(1);
|
||||
expect((int) ($run->summary_counts['failed'] ?? 0))->toBe(1);
|
||||
expect((int) ($run->summary_counts['skipped'] ?? 0))->toBe(0);
|
||||
|
||||
expect(BackupItem::query()
|
||||
->where('backup_set_id', $backupSet->id)
|
||||
->where('policy_id', $policyA->id)
|
||||
->exists())->toBeTrue();
|
||||
|
||||
$failureEntry = collect($run->failures ?? [])
|
||||
->firstWhere('item_id', (string) $policyB->id);
|
||||
$failureEntry = collect($run->failure_summary ?? [])
|
||||
->first(fn ($entry): bool => is_array($entry) && (($entry['code'] ?? null) === 'graph.graph_forbidden'));
|
||||
|
||||
expect($failureEntry)->not->toBeNull();
|
||||
expect($failureEntry['reason_code'] ?? null)->toBe('graph_forbidden');
|
||||
|
||||
expect($backupSet->status)->toBe('partial');
|
||||
});
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Notifications\DatabaseNotification;
|
||||
@ -50,7 +49,7 @@
|
||||
operationRun: $opRun,
|
||||
);
|
||||
|
||||
$job->handle(app(AuditLogger::class), app(BulkOperationService::class));
|
||||
$job->handle(app(AuditLogger::class));
|
||||
|
||||
$this->assertDatabaseHas('notifications', [
|
||||
'notifiable_id' => $user->getKey(),
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -51,14 +51,15 @@
|
||||
|
||||
$sets->each(fn (BackupSet $set) => expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue());
|
||||
|
||||
$bulkRun = BulkOperationRun::query()
|
||||
->where('resource', 'backup_set')
|
||||
->where('action', 'delete')
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('type', 'backup_set.delete')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->status)->toBe('completed');
|
||||
expect($opRun)->not->toBeNull();
|
||||
expect($opRun->status)->toBe('completed');
|
||||
});
|
||||
|
||||
test('backup sets can be archived even when referenced by restore runs', function () {
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -56,12 +56,14 @@
|
||||
$completedRuns->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue());
|
||||
expect(RestoreRun::withTrashed()->find($running->id)?->trashed())->toBeFalse();
|
||||
|
||||
$bulkRun = BulkOperationRun::query()
|
||||
->where('resource', 'restore_run')
|
||||
->where('action', 'delete')
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('type', 'restore_run.delete')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->skipped)->toBeGreaterThanOrEqual(1);
|
||||
expect($opRun)->not->toBeNull();
|
||||
$counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : [];
|
||||
expect((int) ($counts['skipped'] ?? 0))->toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
@ -18,13 +18,31 @@
|
||||
$policies = Policy::factory()->count(25)->create(['tenant_id' => $tenant->id]);
|
||||
$policyIds = $policies->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 25);
|
||||
/** @var OperationRunService $service */
|
||||
$service = app(OperationRunService::class);
|
||||
|
||||
$opRun = $service->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'policy.delete',
|
||||
inputs: [
|
||||
'scope' => 'subset',
|
||||
'policy_ids' => $policyIds,
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
// Simulate Async dispatch (this logic will be in Filament Action)
|
||||
BulkPolicyDeleteJob::dispatch($run->id);
|
||||
BulkPolicyDeleteJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $policyIds,
|
||||
operationRun: $opRun,
|
||||
);
|
||||
|
||||
Queue::assertPushed(BulkPolicyDeleteJob::class, function ($job) use ($run) {
|
||||
return $job->bulkRunId === $run->id;
|
||||
Queue::assertPushed(BulkPolicyDeleteJob::class, function ($job) use ($tenant, $user, $opRun, $policyIds) {
|
||||
return $job->tenantId === (int) $tenant->getKey()
|
||||
&& $job->userId === (int) $user->getKey()
|
||||
&& $job->operationRun?->getKey() === $opRun->getKey()
|
||||
&& $job->policyIds === $policyIds;
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\BulkPolicyDeleteJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -15,18 +16,37 @@
|
||||
$policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]);
|
||||
$policyIds = $policies->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 10);
|
||||
/** @var OperationRunService $service */
|
||||
$service = app(OperationRunService::class);
|
||||
|
||||
// Simulate Sync execution
|
||||
BulkPolicyDeleteJob::dispatchSync($run->id);
|
||||
$opRun = $service->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'policy.delete',
|
||||
inputs: [
|
||||
'scope' => 'subset',
|
||||
'policy_ids' => $policyIds,
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed')
|
||||
->and($run->processed_items)->toBe(10)
|
||||
->and($run->audit_log_id)->not->toBeNull();
|
||||
// Simulate Sync execution (workers will run immediately on sync queue)
|
||||
BulkPolicyDeleteJob::dispatchSync(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $policyIds,
|
||||
operationRun: $opRun,
|
||||
);
|
||||
|
||||
expect(\App\Models\AuditLog::where('action', 'bulk.policy.delete.completed')->exists())->toBeTrue();
|
||||
$opRun->refresh();
|
||||
expect($opRun)->toBeInstanceOf(OperationRun::class);
|
||||
expect($opRun->status)->toBe('completed');
|
||||
expect($opRun->outcome)->toBe('succeeded');
|
||||
expect($opRun->summary_counts)->toMatchArray([
|
||||
'total' => 10,
|
||||
'processed' => 10,
|
||||
'succeeded' => 10,
|
||||
'failed' => 0,
|
||||
]);
|
||||
|
||||
$policies->each(function ($policy) {
|
||||
expect($policy->refresh()->ignored_at)->not->toBeNull();
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -45,12 +45,13 @@
|
||||
|
||||
$runs->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue());
|
||||
|
||||
$bulkRun = BulkOperationRun::query()
|
||||
->where('resource', 'restore_run')
|
||||
->where('action', 'delete')
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('type', 'restore_run.delete')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->status)->toBe('completed');
|
||||
expect($opRun)->not->toBeNull();
|
||||
expect($opRun->status)->toBe('completed');
|
||||
});
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\BulkPolicyExportJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -26,23 +27,38 @@
|
||||
|
||||
$missingVersionPolicy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun(
|
||||
$tenant,
|
||||
$user,
|
||||
'policy',
|
||||
'export',
|
||||
[$okPolicy->id, $missingVersionPolicy->id],
|
||||
2
|
||||
/** @var OperationRunService $service */
|
||||
$service = app(OperationRunService::class);
|
||||
|
||||
$opRun = $service->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'policy.export',
|
||||
inputs: [
|
||||
'scope' => 'subset',
|
||||
'policy_ids' => [$okPolicy->id, $missingVersionPolicy->id],
|
||||
],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new BulkPolicyExportJob($run->id, 'Failures Backup'))->handle($service);
|
||||
(new BulkPolicyExportJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: [$okPolicy->id, $missingVersionPolicy->id],
|
||||
backupName: 'Failures Backup',
|
||||
operationRun: $opRun,
|
||||
))->handle($service);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed_with_errors')
|
||||
->and($run->succeeded)->toBe(1)
|
||||
->and($run->failed)->toBe(1)
|
||||
->and($run->processed_items)->toBe(2);
|
||||
$opRun->refresh();
|
||||
expect($opRun)->toBeInstanceOf(OperationRun::class);
|
||||
expect($opRun->status)->toBe('completed');
|
||||
expect($opRun->outcome)->toBe('partially_succeeded');
|
||||
expect($opRun->summary_counts)->toMatchArray([
|
||||
'total' => 2,
|
||||
'processed' => 2,
|
||||
'succeeded' => 1,
|
||||
'failed' => 1,
|
||||
'created' => 1,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseHas('backup_sets', [
|
||||
'tenant_id' => $tenant->id,
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\BulkPolicyExportJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -24,15 +25,33 @@
|
||||
'captured_at' => now(),
|
||||
]);
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'policy', 'export', [$policy->id], 1);
|
||||
$opRun = OperationRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'policy.export',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'run_identity_hash' => 'policy-export-test',
|
||||
'context' => [
|
||||
'policy_ids' => [$policy->id],
|
||||
'backup_name' => 'Feature Backup',
|
||||
],
|
||||
]);
|
||||
|
||||
// Simulate Sync
|
||||
$job = new BulkPolicyExportJob($run->id, 'Feature Backup');
|
||||
$job->handle($service);
|
||||
$job = new BulkPolicyExportJob(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: [$policy->id],
|
||||
backupName: 'Feature Backup',
|
||||
backupDescription: null,
|
||||
operationRun: $opRun,
|
||||
);
|
||||
$job->handle(app(OperationRunService::class));
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe('completed');
|
||||
$opRun->refresh();
|
||||
expect($opRun->status)->toBe('completed');
|
||||
|
||||
$this->assertDatabaseHas('backup_sets', [
|
||||
'name' => 'Feature Backup',
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
@ -50,12 +50,13 @@
|
||||
expect(BackupSet::withTrashed()->find($set->id))->toBeNull();
|
||||
expect(BackupItem::withTrashed()->find($item->id))->toBeNull();
|
||||
|
||||
$bulkRun = BulkOperationRun::query()
|
||||
->where('resource', 'backup_set')
|
||||
->where('action', 'force_delete')
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('type', 'backup_set.force_delete')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->status)->toBe('completed');
|
||||
expect($opRun)->not->toBeNull();
|
||||
expect($opRun->status)->toBe('completed');
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
@ -38,18 +38,19 @@
|
||||
])
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
$run = BulkOperationRun::query()
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('resource', 'policy_version')
|
||||
->where('action', 'force_delete')
|
||||
->where('type', 'policy_version.force_delete')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull();
|
||||
expect($run->succeeded)->toBe(1)
|
||||
->and($run->skipped)->toBe(0)
|
||||
->and($run->failed)->toBe(0);
|
||||
|
||||
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
expect((int) ($counts['succeeded'] ?? 0))->toBe(1)
|
||||
->and((int) ($counts['skipped'] ?? 0))->toBe(0)
|
||||
->and((int) ($counts['failed'] ?? 0))->toBe(0);
|
||||
|
||||
expect(PolicyVersion::withTrashed()->whereKey($version->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -52,12 +52,14 @@
|
||||
|
||||
$runs->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id))->toBeNull());
|
||||
|
||||
$bulkRun = BulkOperationRun::query()
|
||||
->where('resource', 'restore_run')
|
||||
->where('action', 'force_delete')
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'restore_run.force_delete')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->status)->toBe('completed');
|
||||
expect($opRun)->not->toBeNull();
|
||||
expect($opRun->status)->toBe('completed');
|
||||
expect($opRun->outcome)->toBeIn(['succeeded', 'partially_succeeded']);
|
||||
expect((int) ($opRun->summary_counts['deleted'] ?? 0))->toBe(3);
|
||||
});
|
||||
|
||||
@ -1,105 +1,64 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\BulkOperationProgress;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Models\OperationRun;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('progress widget shows running operations for current tenant and user', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$tenant->makeCurrent();
|
||||
$user = User::factory()->create();
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
// Own running op
|
||||
BulkOperationRun::factory()->create([
|
||||
// Active op
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'policy.delete',
|
||||
'status' => 'running',
|
||||
'resource' => 'policy',
|
||||
'action' => 'delete',
|
||||
'total_items' => 100,
|
||||
'processed_items' => 50,
|
||||
'outcome' => 'pending',
|
||||
'context' => ['scope' => 'subset'],
|
||||
]);
|
||||
|
||||
// Completed op (should not show)
|
||||
BulkOperationRun::factory()->create([
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'policy.delete',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'updated_at' => now()->subMinutes(5),
|
||||
]);
|
||||
|
||||
// Other user's op (should not show)
|
||||
$otherUser = User::factory()->create();
|
||||
BulkOperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $otherUser->id,
|
||||
'status' => 'running',
|
||||
]);
|
||||
|
||||
auth()->login($user); // Login user explicitly for auth()->id() call in component
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BulkOperationProgress::class)
|
||||
->assertSee('Delete Policy')
|
||||
->assertSee('50 / 100');
|
||||
->assertSee('Delete policies')
|
||||
->assertDontSee('Unknown operation');
|
||||
});
|
||||
|
||||
test('progress widget reconciles stale pending backup schedule runs', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
$tenant->makeCurrent();
|
||||
$user = User::factory()->create();
|
||||
test('progress widget shows queued backup schedule runs as operation runs', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
$this->actingAs($user);
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Nightly',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
'next_run_at' => now()->addHour(),
|
||||
]);
|
||||
|
||||
$bulkRun = BulkOperationRun::factory()->create([
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'pending',
|
||||
'resource' => 'backup_schedule',
|
||||
'action' => 'run',
|
||||
'total_items' => 1,
|
||||
'processed_items' => 0,
|
||||
'item_ids' => [(string) $schedule->id],
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'backup_schedule.run_now',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => ['scope' => 'scheduled'],
|
||||
'created_at' => now()->subMinutes(2),
|
||||
'updated_at' => now()->subMinutes(2),
|
||||
]);
|
||||
|
||||
BackupScheduleRun::query()->create([
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'scheduled_for' => now()->startOfMinute(),
|
||||
'started_at' => now()->subMinute(),
|
||||
'finished_at' => now(),
|
||||
'status' => BackupScheduleRun::STATUS_SUCCESS,
|
||||
'summary' => null,
|
||||
]);
|
||||
|
||||
auth()->login($user);
|
||||
|
||||
Livewire::actingAs($user)
|
||||
->test(BulkOperationProgress::class)
|
||||
->assertSee('Run Backup schedule')
|
||||
->assertSee('1 / 1');
|
||||
|
||||
expect($bulkRun->refresh()->status)->toBe('completed');
|
||||
->assertSee('Backup schedule run');
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
@ -50,16 +50,16 @@
|
||||
])
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
$run = BulkOperationRun::query()
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('resource', 'policy_version')
|
||||
->where('action', 'prune')
|
||||
->where('type', 'policy_version.prune')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull();
|
||||
$reasons = collect($run->failures ?? [])->pluck('reason')->all();
|
||||
expect($reasons)->toContain('Current version')
|
||||
->and($reasons)->toContain('Too recent');
|
||||
|
||||
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
expect((int) ($counts['processed'] ?? 0))->toBe(2);
|
||||
expect((int) ($counts['skipped'] ?? 0))->toBe(2);
|
||||
});
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Filament\Facades\Filament;
|
||||
@ -53,12 +53,13 @@
|
||||
expect($set->trashed())->toBeFalse();
|
||||
expect($item->trashed())->toBeFalse();
|
||||
|
||||
$bulkRun = BulkOperationRun::query()
|
||||
->where('resource', 'backup_set')
|
||||
->where('action', 'restore')
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('type', 'backup_set.restore')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->status)->toBe('completed');
|
||||
expect($opRun)->not->toBeNull();
|
||||
expect($opRun->status)->toBe('completed');
|
||||
});
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Filament\Resources\PolicyVersionResource;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
@ -36,18 +36,19 @@
|
||||
->callTableBulkAction('bulk_restore_versions', collect([$version]))
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
$run = BulkOperationRun::query()
|
||||
$run = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('resource', 'policy_version')
|
||||
->where('action', 'restore')
|
||||
->where('type', 'policy_version.restore')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($run)->not->toBeNull();
|
||||
expect($run->succeeded)->toBe(1)
|
||||
->and($run->skipped)->toBe(0)
|
||||
->and($run->failed)->toBe(0);
|
||||
|
||||
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||
expect((int) ($counts['succeeded'] ?? 0))->toBe(1)
|
||||
->and((int) ($counts['skipped'] ?? 0))->toBe(0)
|
||||
->and((int) ($counts['failed'] ?? 0))->toBe(0);
|
||||
|
||||
$version->refresh();
|
||||
expect($version->trashed())->toBeFalse();
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\RestoreRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -45,18 +45,19 @@
|
||||
->callTableBulkAction('bulk_restore', collect([$run]))
|
||||
->assertHasNoTableBulkActionErrors();
|
||||
|
||||
$bulkRun = BulkOperationRun::query()
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('user_id', $user->id)
|
||||
->where('resource', 'restore_run')
|
||||
->where('action', 'restore')
|
||||
->where('type', 'restore_run.restore')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->succeeded)->toBe(1)
|
||||
->and($bulkRun->skipped)->toBe(0)
|
||||
->and($bulkRun->failed)->toBe(0);
|
||||
expect($opRun)->not->toBeNull();
|
||||
|
||||
$counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : [];
|
||||
expect((int) ($counts['succeeded'] ?? 0))->toBe(1)
|
||||
->and((int) ($counts['skipped'] ?? 0))->toBe(0)
|
||||
->and((int) ($counts['failed'] ?? 0))->toBe(0);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->trashed())->toBeFalse();
|
||||
|
||||
@ -1,22 +1,20 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\BulkPolicySyncJob;
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Jobs\SyncPoliciesJob;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Services\Graph\GraphResponse;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
test('bulk sync updates selected policies from graph', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
test('policy sync updates selected policies from graph and updates the operation run', function () {
|
||||
$tenant = Tenant::factory()->create([
|
||||
'status' => 'active',
|
||||
]);
|
||||
$tenant->makeCurrent();
|
||||
$user = User::factory()->create();
|
||||
|
||||
$policies = Policy::factory()
|
||||
->count(3)
|
||||
@ -25,6 +23,7 @@
|
||||
'policy_type' => 'deviceConfiguration',
|
||||
'platform' => 'windows10AndLater',
|
||||
'last_synced_at' => null,
|
||||
'ignored_at' => null,
|
||||
]);
|
||||
|
||||
app()->instance(GraphClientInterface::class, new class implements GraphClientInterface
|
||||
@ -67,17 +66,44 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
}
|
||||
});
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'policy', 'sync', $policies->modelKeys(), 3);
|
||||
/** @var OperationRunService $runs */
|
||||
$runs = app(OperationRunService::class);
|
||||
|
||||
BulkPolicySyncJob::dispatchSync($run->id);
|
||||
$selectedIds = $policies
|
||||
->pluck('id')
|
||||
->map(static fn ($id): int => (int) $id)
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
$bulkRun = BulkOperationRun::query()->find($run->id);
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->status)->toBe('completed');
|
||||
expect($bulkRun->total_items)->toBe(3);
|
||||
expect($bulkRun->succeeded)->toBe(3);
|
||||
expect($bulkRun->failed)->toBe(0);
|
||||
$opRun = $runs->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'policy.sync',
|
||||
inputs: [
|
||||
'scope' => 'subset',
|
||||
'policy_ids' => $selectedIds,
|
||||
],
|
||||
initiator: null,
|
||||
);
|
||||
|
||||
SyncPoliciesJob::dispatchSync(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
types: null,
|
||||
policyIds: $selectedIds,
|
||||
operationRun: $opRun,
|
||||
);
|
||||
|
||||
$opRun->refresh();
|
||||
|
||||
expect($opRun->status)->toBe('completed');
|
||||
expect($opRun->outcome)->toBe('succeeded');
|
||||
expect($opRun->summary_counts)->toMatchArray([
|
||||
'total' => 3,
|
||||
'processed' => 3,
|
||||
'succeeded' => 3,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
]);
|
||||
|
||||
$policies->each(function (Policy $policy) {
|
||||
$policy->refresh();
|
||||
@ -88,6 +114,4 @@ public function request(string $method, string $path, array $options = []): Grap
|
||||
'example' => 'value',
|
||||
]);
|
||||
});
|
||||
|
||||
expect(AuditLog::where('action', 'bulk.policy.sync.completed')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\BulkPolicyUnignoreJob;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BulkOperationService;
|
||||
use App\Services\OperationRunService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
@ -22,18 +23,34 @@
|
||||
|
||||
$policyIds = $policies->pluck('id')->toArray();
|
||||
|
||||
$service = app(BulkOperationService::class);
|
||||
$run = $service->createRun($tenant, $user, 'policy', 'unignore', $policyIds, count($policyIds));
|
||||
$opRun = OperationRun::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'policy.unignore',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'run_identity_hash' => 'policy-unignore-test',
|
||||
'context' => [
|
||||
'policy_ids' => $policyIds,
|
||||
],
|
||||
]);
|
||||
|
||||
BulkPolicyUnignoreJob::dispatchSync($run->id);
|
||||
BulkPolicyUnignoreJob::dispatchSync(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
userId: (int) $user->getKey(),
|
||||
policyIds: $policyIds,
|
||||
operationRun: $opRun,
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
$opRun->refresh();
|
||||
|
||||
expect($run->status)->toBe('completed')
|
||||
->and($run->processed_items)->toBe(5)
|
||||
->and($run->audit_log_id)->not->toBeNull();
|
||||
expect($opRun->status)->toBe('completed');
|
||||
|
||||
expect(\App\Models\AuditLog::where('action', 'bulk.policy.unignore.completed')->exists())->toBeTrue();
|
||||
$counts = is_array($opRun->summary_counts) ? $opRun->summary_counts : [];
|
||||
expect((int) ($counts['processed'] ?? 0))->toBe(5);
|
||||
expect((int) ($counts['succeeded'] ?? 0))->toBe(5);
|
||||
expect((int) ($counts['failed'] ?? 0))->toBe(0);
|
||||
|
||||
$policies->each(function (Policy $policy): void {
|
||||
expect($policy->refresh()->ignored_at)->toBeNull();
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\RestoreRun;
|
||||
@ -79,7 +79,7 @@
|
||||
'recorded_at' => now(),
|
||||
]);
|
||||
|
||||
BulkOperationRun::factory()->create([
|
||||
OperationRun::factory()->create([
|
||||
'tenant_id' => $tenantA->id,
|
||||
'user_id' => $user->id,
|
||||
'status' => 'completed',
|
||||
@ -130,7 +130,7 @@
|
||||
expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(RestoreRun::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(AuditLog::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(BulkOperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
|
||||
expect(BackupSchedule::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
|
||||
@ -2,9 +2,8 @@
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Support\RunIdempotency;
|
||||
use App\Models\OperationRun;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
@ -32,28 +31,26 @@
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$idempotencyKey = RunIdempotency::buildKey(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
operationType: 'drift.generate',
|
||||
targetId: $scopeKey,
|
||||
context: [
|
||||
OperationRun::create([
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'drift.generate',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'run_identity_hash' => 'drift-zero-findings',
|
||||
'summary_counts' => [
|
||||
'total' => 1,
|
||||
'processed' => 1,
|
||||
'succeeded' => 1,
|
||||
'failed' => 0,
|
||||
'created' => 0,
|
||||
],
|
||||
'context' => [
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => (int) $baseline->getKey(),
|
||||
'current_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
);
|
||||
|
||||
BulkOperationRun::factory()->for($tenant)->for($user)->create([
|
||||
'resource' => 'drift',
|
||||
'action' => 'generate',
|
||||
'status' => 'completed',
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
'item_ids' => [$scopeKey],
|
||||
'total_items' => 1,
|
||||
'processed_items' => 1,
|
||||
'succeeded' => 1,
|
||||
'failed' => 0,
|
||||
'skipped' => 0,
|
||||
]);
|
||||
|
||||
Livewire::test(DriftLanding::class)
|
||||
@ -62,10 +59,5 @@
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
|
||||
expect(BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->count())->toBe(1);
|
||||
|
||||
Queue::assertNotPushed(GenerateDriftFindingsJob::class);
|
||||
});
|
||||
|
||||
@ -2,12 +2,10 @@
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\BulkOperationRun;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\RunIdempotency;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
@ -46,33 +44,6 @@
|
||||
|
||||
Livewire::test(DriftLanding::class);
|
||||
|
||||
$idempotencyKey = RunIdempotency::buildKey(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
operationType: 'drift.generate',
|
||||
targetId: $scopeKey,
|
||||
context: [
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => (int) $baseline->getKey(),
|
||||
'current_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
);
|
||||
|
||||
$bulkRun = BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($bulkRun)->not->toBeNull();
|
||||
expect($bulkRun->resource)->toBe('drift');
|
||||
expect($bulkRun->action)->toBe('generate');
|
||||
expect($bulkRun->status)->toBe('pending');
|
||||
expect($bulkRun->item_ids)->toBe([
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => (int) $baseline->getKey(),
|
||||
'current_run_id' => (int) $current->getKey(),
|
||||
]);
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'drift.generate')
|
||||
@ -88,13 +59,12 @@
|
||||
expect(collect($notifications)->last()['actions'][0]['url'] ?? null)
|
||||
->toBe(OperationRunLinks::view($opRun, $tenant));
|
||||
|
||||
Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $bulkRun, $opRun): bool {
|
||||
Queue::assertPushed(GenerateDriftFindingsJob::class, function (GenerateDriftFindingsJob $job) use ($tenant, $user, $baseline, $current, $scopeKey, $opRun): bool {
|
||||
return $job->tenantId === (int) $tenant->getKey()
|
||||
&& $job->userId === (int) $user->getKey()
|
||||
&& $job->baselineRunId === (int) $baseline->getKey()
|
||||
&& $job->currentRunId === (int) $current->getKey()
|
||||
&& $job->scopeKey === $scopeKey
|
||||
&& $job->bulkOperationRunId === (int) $bulkRun->getKey()
|
||||
&& $job->operationRun instanceof OperationRun
|
||||
&& (int) $job->operationRun->getKey() === (int) $opRun?->getKey();
|
||||
});
|
||||
@ -126,22 +96,6 @@
|
||||
|
||||
Queue::assertPushed(GenerateDriftFindingsJob::class, 1);
|
||||
|
||||
$idempotencyKey = RunIdempotency::buildKey(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
operationType: 'drift.generate',
|
||||
targetId: $scopeKey,
|
||||
context: [
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => (int) $baseline->getKey(),
|
||||
'current_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
);
|
||||
|
||||
expect(BulkOperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->count())->toBe(1);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'drift.generate')
|
||||
@ -166,7 +120,6 @@
|
||||
Livewire::test(DriftLanding::class);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||
});
|
||||
|
||||
@ -194,6 +147,5 @@
|
||||
Livewire::test(DriftLanding::class);
|
||||
|
||||
Queue::assertNothingPushed();
|
||||
expect(BulkOperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||
expect(OperationRun::query()->where('tenant_id', $tenant->getKey())->count())->toBe(0);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user