056-remove-legacy-bulkops (#65)
Kurzbeschreibung Versteckt die Rerun-Row-Action für archivierte (soft-deleted) RestoreRuns und verhindert damit fehlerhafte Neu-Starts aus dem Archiv; ergänzt einen Regressionstest. Änderungen Code: RestoreRunResource.php — Sichtbarkeit der rerun-Action geprüft auf ! $record->trashed() und defensive Abbruchprüfung im Action-Handler. Tests: RestoreRunRerunTest.php — neuer Test rerun action is hidden for archived restore runs. Warum Archivierte RestoreRuns durften nicht neu gestartet werden; UI zeigte trotzdem die Option. Das führte zu verwirrendem Verhalten und möglichen Fehlern beim Enqueueing. Verifikation / QA Unit/Feature: ./vendor/bin/sail artisan test tests/Feature/RestoreRunRerunTest.php Stil/format: ./vendor/bin/pint --dirty Manuell (UI): Als Tenant-Admin Filament → Restore Runs öffnen. Filter Archived aktivieren (oder Trashed filter auswählen). Sicherstellen, dass für archivierte Einträge die Rerun-Action nicht sichtbar ist. Auf einem aktiven (nicht-archivierten) Run prüfen, dass Rerun sichtbar bleibt und wie erwartet eine neue RestoreRun erzeugt. Wichtige Hinweise Kein DB-Migration required. Diese PR enthält nur den UI-/Filament-Fix; die zuvor gemachten operative Fixes für Queue/adapter-Reconciliation bleiben ebenfalls auf dem Branch (z. B. frühere commits während der Debugging-Session). T055 (Schema squash) wurde bewusst zurückgestellt und ist nicht Teil dieses PRs. Merge-Checklist Tests lokal laufen (RestoreRunRerunTest grünt) Pint läuft ohne ungepatchte Fehler Branch gepusht: 056-remove-legacy-bulkops (PR-URL: https://git.cloudarix.de/ahmido/TenantAtlas/compare/dev...056-remove-legacy-bulkops) Co-authored-by: Ahmed Darrazi <ahmeddarrazi@adsmac.local> Reviewed-on: #65
This commit is contained in:
parent
bd6df1f343
commit
a97beefda3
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -10,6 +10,7 @@ ## Active Technologies
|
|||||||
- PostgreSQL (JSONB) (feat/042-inventory-dependencies-graph)
|
- PostgreSQL (JSONB) (feat/042-inventory-dependencies-graph)
|
||||||
- PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3 (feat/047-inventory-foundations-nodes)
|
- PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3 (feat/047-inventory-foundations-nodes)
|
||||||
- PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) (feat/047-inventory-foundations-nodes)
|
- PostgreSQL (JSONB for `InventoryItem.meta_jsonb`) (feat/047-inventory-foundations-nodes)
|
||||||
|
- PostgreSQL (JSONB in `operation_runs.context`, `operation_runs.summary_counts`) (056-remove-legacy-bulkops)
|
||||||
|
|
||||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||||
|
|
||||||
@ -29,9 +30,9 @@ ## Code Style
|
|||||||
PHP 8.4.15: Follow standard conventions
|
PHP 8.4.15: Follow standard conventions
|
||||||
|
|
||||||
## Recent Changes
|
## Recent Changes
|
||||||
|
- 056-remove-legacy-bulkops: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3
|
||||||
- feat/047-inventory-foundations-nodes: Added PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3
|
- feat/047-inventory-foundations-nodes: Added PHP 8.4.x (Laravel 12) + Laravel 12, Filament v4, Livewire v3
|
||||||
- feat/042-inventory-dependencies-graph: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3
|
- feat/042-inventory-dependencies-graph: Added PHP 8.4.x + Laravel 12, Filament v4, Livewire v3
|
||||||
- feat/032-backup-scheduling-mvp: Added PHP 8.4.15 + Laravel 12, Filament v4, Livewire v3
|
|
||||||
|
|
||||||
|
|
||||||
<!-- MANUAL ADDITIONS START -->
|
<!-- MANUAL ADDITIONS START -->
|
||||||
|
|||||||
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\BackupSchedule;
|
||||||
use App\Models\BackupScheduleRun;
|
use App\Models\BackupScheduleRun;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
@ -88,7 +88,7 @@ public function handle(): int
|
|||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
BulkOperationRun::query()
|
OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
@ -152,7 +152,7 @@ private function countsForTenant(Tenant $tenant): array
|
|||||||
return [
|
return [
|
||||||
'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(),
|
'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(),
|
||||||
'backup_schedules' => BackupSchedule::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(),
|
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
||||||
'restore_runs' => RestoreRun::withTrashed()->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(),
|
'backup_items' => BackupItem::withTrashed()->where('tenant_id', $tenant->id)->count(),
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
use App\Models\BackupScheduleRun;
|
use App\Models\BackupScheduleRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
use Illuminate\Console\Command;
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||||
@ -18,7 +18,7 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
|||||||
|
|
||||||
protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.';
|
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')));
|
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
|
||||||
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
$olderThanMinutes = max(0, (int) $this->option('older-than'));
|
||||||
@ -70,8 +70,8 @@ public function handle(OperationRunService $operationRunService, BulkOperationSe
|
|||||||
outcome: 'failed',
|
outcome: 'failed',
|
||||||
failures: [
|
failures: [
|
||||||
[
|
[
|
||||||
'code' => 'RUN_NOT_FOUND',
|
'code' => 'backup_schedule_run.not_found',
|
||||||
'message' => $bulkOperationService->sanitizeFailureReason('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) {
|
$outcome = match ($scheduleRun->status) {
|
||||||
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
|
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
|
||||||
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
|
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
|
||||||
BackupScheduleRun::STATUS_SKIPPED,
|
BackupScheduleRun::STATUS_SKIPPED => 'succeeded',
|
||||||
BackupScheduleRun::STATUS_CANCELED => 'cancelled',
|
BackupScheduleRun::STATUS_CANCELED => 'failed',
|
||||||
default => 'failed',
|
default => 'failed',
|
||||||
};
|
};
|
||||||
|
|
||||||
$summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : [];
|
$summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : [];
|
||||||
$syncFailures = $summary['sync_failures'] ?? [];
|
$syncFailures = $summary['sync_failures'] ?? [];
|
||||||
|
|
||||||
$summaryCounts = [
|
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
||||||
'backup_schedule_id' => (int) $scheduleRun->backup_schedule_id,
|
$policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0);
|
||||||
'backup_schedule_run_id' => (int) $scheduleRun->getKey(),
|
$syncFailuresCount = is_array($syncFailures) ? count($syncFailures) : 0;
|
||||||
'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,
|
|
||||||
];
|
|
||||||
|
|
||||||
$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 = [];
|
$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)) {
|
if (filled($scheduleRun->error_message) || filled($scheduleRun->error_code)) {
|
||||||
$failures[] = [
|
$failures[] = [
|
||||||
'code' => (string) ($scheduleRun->error_code ?: 'BACKUP_SCHEDULE_ERROR'),
|
'code' => (string) ($scheduleRun->error_code ?: 'backup_schedule_run.error'),
|
||||||
'message' => $bulkOperationService->sanitizeFailureReason((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')),
|
'message' => RunFailureSanitizer::sanitizeMessage((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -151,8 +165,8 @@ public function handle(OperationRunService $operationRunService, BulkOperationSe
|
|||||||
}
|
}
|
||||||
|
|
||||||
$failures[] = [
|
$failures[] = [
|
||||||
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR',
|
'code' => $status !== null ? "graph.http_{$status}" : 'graph.error',
|
||||||
'message' => $bulkOperationService->sanitizeFailureReason($message),
|
'message' => RunFailureSanitizer::sanitizeMessage($message),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,19 +5,17 @@
|
|||||||
use App\Filament\Resources\FindingResource;
|
use App\Filament\Resources\FindingResource;
|
||||||
use App\Filament\Resources\InventorySyncRunResource;
|
use App\Filament\Resources\InventorySyncRunResource;
|
||||||
use App\Jobs\GenerateDriftFindingsJob;
|
use App\Jobs\GenerateDriftFindingsJob;
|
||||||
use App\Models\BulkOperationRun;
|
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Drift\DriftRunSelector;
|
use App\Services\Drift\DriftRunSelector;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\RunIdempotency;
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -50,8 +48,6 @@ class DriftLanding extends Page
|
|||||||
|
|
||||||
public ?int $operationRunId = null;
|
public ?int $operationRunId = null;
|
||||||
|
|
||||||
public ?int $bulkOperationRunId = null;
|
|
||||||
|
|
||||||
/** @var array<string, int>|null */
|
/** @var array<string, int>|null */
|
||||||
public ?array $statusCounts = null;
|
public ?array $statusCounts = null;
|
||||||
|
|
||||||
@ -118,17 +114,6 @@ public function mount(): void
|
|||||||
$this->operationRunId = (int) $existingOperationRun->getKey();
|
$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()
|
$exists = Finding::query()
|
||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||||
@ -153,48 +138,39 @@ public function mount(): void
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$latestRun = BulkOperationRun::query()
|
$existingOperationRun?->refresh();
|
||||||
->where('tenant_id', $tenant->getKey())
|
|
||||||
->where('idempotency_key', $idempotencyKey)
|
|
||||||
->latest('id')
|
|
||||||
->first();
|
|
||||||
|
|
||||||
$activeRun = RunIdempotency::findActiveBulkOperationRun((int) $tenant->getKey(), $idempotencyKey);
|
if ($existingOperationRun instanceof OperationRun
|
||||||
if ($activeRun instanceof BulkOperationRun) {
|
&& in_array($existingOperationRun->status, ['queued', 'running'], true)
|
||||||
|
) {
|
||||||
$this->state = 'generating';
|
$this->state = 'generating';
|
||||||
$this->bulkOperationRunId = (int) $activeRun->getKey();
|
$this->operationRunId = (int) $existingOperationRun->getKey();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($latestRun instanceof BulkOperationRun && $latestRun->status === 'completed') {
|
if ($existingOperationRun instanceof OperationRun
|
||||||
$this->state = 'ready';
|
&& $existingOperationRun->status === 'completed'
|
||||||
$this->bulkOperationRunId = (int) $latestRun->getKey();
|
) {
|
||||||
|
$counts = is_array($existingOperationRun->summary_counts ?? null) ? $existingOperationRun->summary_counts : [];
|
||||||
|
$created = (int) ($counts['created'] ?? 0);
|
||||||
|
|
||||||
$newCount = (int) Finding::query()
|
if ($existingOperationRun->outcome === 'failed') {
|
||||||
->where('tenant_id', $tenant->getKey())
|
$this->state = 'error';
|
||||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
$this->message = 'Drift generation failed for this comparison. See the run for details.';
|
||||||
->where('scope_key', $scopeKey)
|
$this->operationRunId = (int) $existingOperationRun->getKey();
|
||||||
->where('baseline_run_id', $baseline->getKey())
|
|
||||||
->where('current_run_id', $current->getKey())
|
|
||||||
->where('status', Finding::STATUS_NEW)
|
|
||||||
->count();
|
|
||||||
|
|
||||||
$this->statusCounts = [Finding::STATUS_NEW => $newCount];
|
return;
|
||||||
|
|
||||||
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;
|
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)) {
|
return;
|
||||||
$this->state = 'error';
|
}
|
||||||
$this->message = 'Drift generation failed for this comparison. See the run for details.';
|
|
||||||
$this->bulkOperationRunId = (int) $latestRun->getKey();
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $user->canSyncTenant($tenant)) {
|
if (! $user->canSyncTenant($tenant)) {
|
||||||
@ -204,73 +180,60 @@ public function mount(): void
|
|||||||
return;
|
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 */
|
/** @var OperationRunService $opService */
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$opRun = $opService->ensureRun(
|
|
||||||
|
$opRun = $opService->enqueueBulkOperation(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'drift.generate',
|
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,
|
'scope_key' => $scopeKey,
|
||||||
'baseline_run_id' => (int) $baseline->getKey(),
|
'baseline_run_id' => (int) $baseline->getKey(),
|
||||||
'current_run_id' => (int) $current->getKey(),
|
'current_run_id' => (int) $current->getKey(),
|
||||||
],
|
],
|
||||||
initiator: $user
|
emitQueuedNotification: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->operationRunId = (int) $opRun->getKey();
|
$this->operationRunId = (int) $opRun->getKey();
|
||||||
|
$this->state = 'generating';
|
||||||
|
|
||||||
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'])) {
|
if (! $opRun->wasRecentlyCreated) {
|
||||||
$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.
|
|
||||||
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Drift generation already active')
|
->title('Drift generation already active')
|
||||||
->body('This operation is already queued or running.')
|
->body('This operation is already queued or running.')
|
||||||
->warning()
|
->warning()
|
||||||
->actions([
|
->actions([
|
||||||
Action::make('view_run')
|
Action::make('view_run')
|
||||||
->label('View Run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return;
|
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);
|
OpsUxBrowserEvents::dispatchRunEnqueued($this);
|
||||||
OperationUxPresenter::queuedToast((string) $opRun->type)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Inventory\InventorySyncService;
|
use App\Services\Inventory\InventorySyncService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
@ -112,7 +111,7 @@ protected function getHeaderActions(): array
|
|||||||
|
|
||||||
return $user->canSyncTenant(Tenant::current());
|
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();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
@ -202,22 +201,12 @@ protected function getHeaderActions(): array
|
|||||||
$policyTypes = [];
|
$policyTypes = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
$bulkRun = $bulkOperationService->createRun(
|
|
||||||
tenant: $tenant,
|
|
||||||
user: $user,
|
|
||||||
resource: 'inventory',
|
|
||||||
action: 'sync',
|
|
||||||
itemIds: $policyTypes,
|
|
||||||
totalItems: count($policyTypes),
|
|
||||||
);
|
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'inventory.sync.dispatched',
|
action: 'inventory.sync.dispatched',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'inventory_sync_run_id' => $run->id,
|
'inventory_sync_run_id' => $run->id,
|
||||||
'bulk_run_id' => $bulkRun->id,
|
|
||||||
'selection_hash' => $run->selection_hash,
|
'selection_hash' => $run->selection_hash,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@ -228,12 +217,11 @@ protected function getHeaderActions(): array
|
|||||||
resourceId: (string) $run->id,
|
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(
|
RunInventorySyncJob::dispatch(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
userId: (int) $user->getKey(),
|
userId: (int) $user->getKey(),
|
||||||
inventorySyncRunId: (int) $run->id,
|
inventorySyncRunId: (int) $run->id,
|
||||||
bulkRunId: (int) $bulkRun->getKey(),
|
|
||||||
operationRun: $opRun
|
operationRun: $opRun
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
use App\Rules\SupportedPolicyTypesRule;
|
use App\Rules\SupportedPolicyTypesRule;
|
||||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
@ -397,23 +396,8 @@ public static function table(Table $table): Table
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$bulkRunId = null;
|
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
||||||
|
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
||||||
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));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
@ -536,23 +520,8 @@ public static function table(Table $table): Table
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$bulkRunId = null;
|
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
||||||
|
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
||||||
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));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
OpsUxBrowserEvents::dispatchRunEnqueued($livewire);
|
||||||
@ -591,16 +560,6 @@ public static function table(Table $table): Table
|
|||||||
$operationRunService = app(OperationRunService::class);
|
$operationRunService = app(OperationRunService::class);
|
||||||
|
|
||||||
$bulkRun = null;
|
$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 = [];
|
$createdRunIds = [];
|
||||||
|
|
||||||
@ -678,13 +637,12 @@ public static function table(Table $table): Table
|
|||||||
'backup_schedule_run_id' => $run->id,
|
'backup_schedule_run_id' => $run->id,
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||||
'trigger' => 'bulk_run_now',
|
'trigger' => 'bulk_run_now',
|
||||||
'bulk_run_id' => $bulkRun?->id,
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void {
|
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
||||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun));
|
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
||||||
}, emitQueuedNotification: false);
|
}, emitQueuedNotification: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -731,16 +689,6 @@ public static function table(Table $table): Table
|
|||||||
$operationRunService = app(OperationRunService::class);
|
$operationRunService = app(OperationRunService::class);
|
||||||
|
|
||||||
$bulkRun = null;
|
$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 = [];
|
$createdRunIds = [];
|
||||||
|
|
||||||
@ -818,13 +766,12 @@ public static function table(Table $table): Table
|
|||||||
'backup_schedule_run_id' => $run->id,
|
'backup_schedule_run_id' => $run->id,
|
||||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||||
'trigger' => 'bulk_retry',
|
'trigger' => 'bulk_retry',
|
||||||
'bulk_run_id' => $bulkRun?->id,
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $bulkRun, $operationRun): void {
|
$operationRunService->dispatchOrFail($operationRun, function () use ($run, $operationRun): void {
|
||||||
Bus::dispatch(new RunBackupScheduleJob($run->id, $bulkRun?->id, $operationRun));
|
Bus::dispatch(new RunBackupScheduleJob($run->id, $operationRun));
|
||||||
}, emitQueuedNotification: false);
|
}, emitQueuedNotification: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,9 +9,12 @@
|
|||||||
use App\Jobs\BulkBackupSetRestoreJob;
|
use App\Jobs\BulkBackupSetRestoreJob;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\BulkOperationService;
|
use App\Models\User;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\BackupService;
|
use App\Services\Intune\BackupService;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
@ -198,22 +201,48 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $tenant instanceof Tenant) {
|
||||||
$run = $service->createRun($tenant, $user, 'backup_set', 'delete', $ids, $count);
|
return;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
|
||||||
@ -238,22 +267,48 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $tenant instanceof Tenant) {
|
||||||
$run = $service->createRun($tenant, $user, 'backup_set', 'restore', $ids, $count);
|
return;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
|
||||||
@ -293,22 +348,48 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $tenant instanceof Tenant) {
|
||||||
$run = $service->createRun($tenant, $user, 'backup_set', 'force_delete', $ids, $count);
|
return;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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(),
|
->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;
|
|
||||||
}
|
|
||||||
@ -37,6 +37,16 @@ class OperationRunResource extends Resource
|
|||||||
|
|
||||||
protected static ?string $navigationLabel = 'Operations';
|
protected static ?string $navigationLabel = 'Operations';
|
||||||
|
|
||||||
|
public static function getEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
$tenantId = Tenant::current()?->getKey();
|
||||||
|
|
||||||
|
return parent::getEloquentQuery()
|
||||||
|
->with('user')
|
||||||
|
->latest('id')
|
||||||
|
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
||||||
|
}
|
||||||
|
|
||||||
public static function form(Schema $schema): Schema
|
public static function form(Schema $schema): Schema
|
||||||
{
|
{
|
||||||
return $schema;
|
return $schema;
|
||||||
@ -58,6 +68,11 @@ public static function infolist(Schema $schema): Schema
|
|||||||
->badge()
|
->badge()
|
||||||
->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)),
|
->color(fn (OperationRun $record): string => static::outcomeColor($record->outcome)),
|
||||||
TextEntry::make('initiator_name')->label('Initiator'),
|
TextEntry::make('initiator_name')->label('Initiator'),
|
||||||
|
TextEntry::make('target_scope_display')
|
||||||
|
->label('Target')
|
||||||
|
->getStateUsing(fn (OperationRun $record): ?string => static::targetScopeDisplay($record))
|
||||||
|
->visible(fn (OperationRun $record): bool => static::targetScopeDisplay($record) !== null)
|
||||||
|
->columnSpanFull(),
|
||||||
TextEntry::make('elapsed')
|
TextEntry::make('elapsed')
|
||||||
->label('Elapsed')
|
->label('Elapsed')
|
||||||
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
|
->getStateUsing(fn (OperationRun $record): string => RunDurationInsights::elapsedHuman($record)),
|
||||||
@ -238,13 +253,6 @@ public static function table(Table $table): Table
|
|||||||
->bulkActions([]);
|
->bulkActions([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function getEloquentQuery(): Builder
|
|
||||||
{
|
|
||||||
return parent::getEloquentQuery()
|
|
||||||
->with('user')
|
|
||||||
->latest('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function getPages(): array
|
public static function getPages(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@ -273,4 +281,42 @@ private static function outcomeColor(?string $outcome): string
|
|||||||
default => 'gray',
|
default => 'gray',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function targetScopeDisplay(OperationRun $record): ?string
|
||||||
|
{
|
||||||
|
$context = is_array($record->context) ? $record->context : [];
|
||||||
|
|
||||||
|
$targetScope = $context['target_scope'] ?? null;
|
||||||
|
if (! is_array($targetScope)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$entraTenantName = $targetScope['entra_tenant_name'] ?? null;
|
||||||
|
$entraTenantId = $targetScope['entra_tenant_id'] ?? null;
|
||||||
|
$directoryContextId = $targetScope['directory_context_id'] ?? null;
|
||||||
|
|
||||||
|
$entraTenantName = is_string($entraTenantName) ? trim($entraTenantName) : null;
|
||||||
|
$entraTenantId = is_string($entraTenantId) ? trim($entraTenantId) : null;
|
||||||
|
|
||||||
|
$directoryContextId = match (true) {
|
||||||
|
is_string($directoryContextId) => trim($directoryContextId),
|
||||||
|
is_int($directoryContextId) => (string) $directoryContextId,
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
$entra = null;
|
||||||
|
|
||||||
|
if ($entraTenantName !== null && $entraTenantName !== '') {
|
||||||
|
$entra = $entraTenantId ? "{$entraTenantName} ({$entraTenantId})" : $entraTenantName;
|
||||||
|
} elseif ($entraTenantId !== null && $entraTenantId !== '') {
|
||||||
|
$entra = $entraTenantId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$parts = array_values(array_filter([
|
||||||
|
$entra,
|
||||||
|
$directoryContextId ? "directory_context_id: {$directoryContextId}" : null,
|
||||||
|
], fn (?string $value): bool => $value !== null && $value !== ''));
|
||||||
|
|
||||||
|
return $parts !== [] ? implode(' · ', $parts) : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,9 +11,9 @@
|
|||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Intune\PolicyNormalizer;
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
@ -391,7 +391,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return $user->canSyncTenant($tenant);
|
return $user->canSyncTenant($tenant);
|
||||||
})
|
})
|
||||||
->action(function (Policy $record) {
|
->action(function (Policy $record, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
@ -458,10 +458,51 @@ public static function table(Table $table): Table
|
|||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $user instanceof User) {
|
||||||
$run = $service->createRun($tenant, $user, 'policy', 'export', [$record->id], 1);
|
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'),
|
])->icon('heroicon-o-ellipsis-vertical'),
|
||||||
])
|
])
|
||||||
@ -499,24 +540,54 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $user instanceof User) {
|
||||||
$run = $service->createRun($tenant, $user, 'policy', 'delete', $ids, $count);
|
return;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @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(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
|
||||||
@ -537,8 +608,50 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $user instanceof User) {
|
||||||
$run = $service->createRun($tenant, $user, 'policy', 'unignore', $ids, $count);
|
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) {
|
if ($count >= 20) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -550,11 +663,17 @@ public static function table(Table $table): Table
|
|||||||
->duration(8000)
|
->duration(8000)
|
||||||
->sendToDatabase($user)
|
->sendToDatabase($user)
|
||||||
->send();
|
->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(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
|
||||||
@ -581,7 +700,7 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
return $value === 'ignored';
|
return $value === 'ignored';
|
||||||
})
|
})
|
||||||
->action(function (Collection $records) {
|
->action(function (Collection $records, HasTable $livewire): void {
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
@ -660,8 +779,53 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $user instanceof User) {
|
||||||
$run = $service->createRun($tenant, $user, 'policy', 'export', $ids, $count);
|
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) {
|
if ($count >= 20) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -673,11 +837,15 @@ public static function table(Table $table): Table
|
|||||||
->duration(8000)
|
->duration(8000)
|
||||||
->sendToDatabase($user)
|
->sendToDatabase($user)
|
||||||
->send();
|
->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(),
|
->deselectRecordsAfterCompletion(),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Filament\Resources\PolicyResource\Pages;
|
namespace App\Filament\Resources\PolicyResource\Pages;
|
||||||
|
|
||||||
use App\Filament\Resources\BulkOperationRunResource;
|
|
||||||
use App\Filament\Resources\PolicyResource;
|
use App\Filament\Resources\PolicyResource;
|
||||||
use App\Jobs\CapturePolicySnapshotJob;
|
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\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\RunIdempotency;
|
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Filament\Forms;
|
use Filament\Forms;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
@ -54,64 +54,66 @@ protected function getActions(): array
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$idempotencyKey = RunIdempotency::buildKey(
|
/** @var BulkSelectionIdentity $selection */
|
||||||
tenantId: $tenant->getKey(),
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
operationType: 'policy.capture_snapshot',
|
$selectionIdentity = $selection->fromIds([(string) $policy->getKey()]);
|
||||||
targetId: $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(
|
if (! $opRun->wasRecentlyCreated) {
|
||||||
tenantId: $tenant->getKey(),
|
|
||||||
idempotencyKey: $idempotencyKey
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($existingRun) {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Snapshot already in progress')
|
->title('Snapshot already in progress')
|
||||||
->body('An active run already exists for this policy. Opening run details.')
|
->body('An active run already exists for this policy. Opening run details.')
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->info()
|
->info()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
$this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $existingRun], tenant: $tenant));
|
$this->redirect(OperationRunLinks::view($opRun, $tenant));
|
||||||
|
|
||||||
return;
|
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')
|
OperationUxPresenter::queuedToast('policy.capture_snapshot')
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
$this->redirect(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant));
|
$this->redirect(OperationRunLinks::view($opRun, $tenant));
|
||||||
})
|
})
|
||||||
->color('primary'),
|
->color('primary'),
|
||||||
];
|
];
|
||||||
|
|||||||
@ -10,10 +10,13 @@
|
|||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\BulkOperationService;
|
use App\Models\User;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\PolicyNormalizer;
|
use App\Services\Intune\PolicyNormalizer;
|
||||||
use App\Services\Intune\VersionDiff;
|
use App\Services\Intune\VersionDiff;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
@ -406,22 +409,66 @@ public static function table(Table $table): Table
|
|||||||
|
|
||||||
$retentionDays = (int) ($data['retention_days'] ?? 90);
|
$retentionDays = (int) ($data['retention_days'] ?? 90);
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $tenant instanceof Tenant) {
|
||||||
$run = $service->createRun($tenant, $user, 'policy_version', 'prune', $ids, $count);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($count >= 20) {
|
$initiator = $user instanceof User ? $user : null;
|
||||||
OperationUxPresenter::queuedToast('policy_version.prune')
|
|
||||||
|
/** @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([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->duration(8000)
|
||||||
|
->sendToDatabase($initiator);
|
||||||
BulkPolicyVersionPruneJob::dispatch($run->id, $retentionDays);
|
|
||||||
} else {
|
|
||||||
BulkPolicyVersionPruneJob::dispatchSync($run->id, $retentionDays);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast('policy_version.prune')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
|
||||||
@ -446,22 +493,48 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $tenant instanceof Tenant) {
|
||||||
$run = $service->createRun($tenant, $user, 'policy_version', 'restore', $ids, $count);
|
return;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
|
||||||
@ -495,22 +568,64 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $tenant instanceof Tenant) {
|
||||||
$run = $service->createRun($tenant, $user, 'policy_version', 'force_delete', $ids, $count);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if ($count >= 20) {
|
$initiator = $user instanceof User ? $user : null;
|
||||||
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
|
||||||
|
/** @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([
|
||||||
Actions\Action::make('view_run')
|
Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(BulkOperationRunResource::getUrl('view', ['record' => $run], tenant: $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->send();
|
->duration(8000)
|
||||||
|
->sendToDatabase($initiator);
|
||||||
BulkPolicyVersionForceDeleteJob::dispatch($run->id);
|
|
||||||
} else {
|
|
||||||
BulkPolicyVersionForceDeleteJob::dispatchSync($run->id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OperationUxPresenter::queuedToast('policy_version.force_delete')
|
||||||
|
->actions([
|
||||||
|
Actions\Action::make('view_run')
|
||||||
|
->label('View run')
|
||||||
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
|
])
|
||||||
|
->send();
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
]),
|
]),
|
||||||
|
|||||||
@ -12,17 +12,20 @@
|
|||||||
use App\Models\EntraGroup;
|
use App\Models\EntraGroup;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Models\User;
|
||||||
use App\Rules\SkipOrUuidRule;
|
use App\Rules\SkipOrUuidRule;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Directory\EntraGroupLabelResolver;
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\RestoreDiffGenerator;
|
use App\Services\Intune\RestoreDiffGenerator;
|
||||||
use App\Services\Intune\RestoreRiskChecker;
|
use App\Services\Intune\RestoreRiskChecker;
|
||||||
use App\Services\Intune\RestoreService;
|
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\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
|
use App\Support\RestoreRunIdempotency;
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
use App\Support\RunIdempotency;
|
|
||||||
use BackedEnum;
|
use BackedEnum;
|
||||||
use Filament\Actions;
|
use Filament\Actions;
|
||||||
use Filament\Actions\ActionGroup;
|
use Filament\Actions\ActionGroup;
|
||||||
@ -738,7 +741,8 @@ public static function table(Table $table): Table
|
|||||||
->visible(function (RestoreRun $record): bool {
|
->visible(function (RestoreRun $record): bool {
|
||||||
$backupSet = $record->backupSet;
|
$backupSet = $record->backupSet;
|
||||||
|
|
||||||
return $record->isDeletable()
|
return ! $record->trashed()
|
||||||
|
&& $record->isDeletable()
|
||||||
&& $backupSet !== null
|
&& $backupSet !== null
|
||||||
&& ! $backupSet->trashed();
|
&& ! $backupSet->trashed();
|
||||||
})
|
})
|
||||||
@ -751,10 +755,10 @@ public static function table(Table $table): Table
|
|||||||
$tenant = $record->tenant;
|
$tenant = $record->tenant;
|
||||||
$backupSet = $record->backupSet;
|
$backupSet = $record->backupSet;
|
||||||
|
|
||||||
if (! $tenant || ! $backupSet || $backupSet->trashed()) {
|
if ($record->trashed() || ! $tenant || ! $backupSet || $backupSet->trashed()) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Restore run cannot be rerun')
|
->title('Restore run cannot be rerun')
|
||||||
->body('Backup set is archived or unavailable.')
|
->body('Restore run or backup set is archived or unavailable.')
|
||||||
->warning()
|
->warning()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
@ -780,14 +784,14 @@ public static function table(Table $table): Table
|
|||||||
'rerun_of_restore_run_id' => $record->id,
|
'rerun_of_restore_run_id' => $record->id,
|
||||||
];
|
];
|
||||||
|
|
||||||
$idempotencyKey = RunIdempotency::restoreExecuteKey(
|
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
backupSetId: (int) $backupSet->getKey(),
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
selectedItemIds: $selectedItemIds,
|
selectedItemIds: $selectedItemIds,
|
||||||
groupMapping: $groupMapping,
|
groupMapping: $groupMapping,
|
||||||
);
|
);
|
||||||
|
|
||||||
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -813,7 +817,7 @@ public static function table(Table $table): Table
|
|||||||
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
||||||
]);
|
]);
|
||||||
} catch (QueryException $exception) {
|
} catch (QueryException $exception) {
|
||||||
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -1016,7 +1020,6 @@ public static function table(Table $table): Table
|
|||||||
return [
|
return [
|
||||||
Forms\Components\TextInput::make('confirmation')
|
Forms\Components\TextInput::make('confirmation')
|
||||||
->label('Type DELETE to confirm')
|
->label('Type DELETE to confirm')
|
||||||
->required()
|
|
||||||
->in(['DELETE'])
|
->in(['DELETE'])
|
||||||
->validationMessages([
|
->validationMessages([
|
||||||
'in' => 'Please type DELETE to confirm.',
|
'in' => 'Please type DELETE to confirm.',
|
||||||
@ -1032,24 +1035,48 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $tenant instanceof Tenant) {
|
||||||
$run = $service->createRun($tenant, $user, 'restore_run', 'delete', $ids, $count);
|
return;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
|
||||||
@ -1074,24 +1101,59 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $tenant instanceof Tenant) {
|
||||||
$run = $service->createRun($tenant, $user, 'restore_run', 'restore', $ids, $count);
|
return;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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(),
|
->deselectRecordsAfterCompletion(),
|
||||||
|
|
||||||
@ -1125,24 +1187,59 @@ public static function table(Table $table): Table
|
|||||||
$count = $records->count();
|
$count = $records->count();
|
||||||
$ids = $records->pluck('id')->toArray();
|
$ids = $records->pluck('id')->toArray();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
if (! $tenant instanceof Tenant) {
|
||||||
$run = $service->createRun($tenant, $user, 'restore_run', 'force_delete', $ids, $count);
|
return;
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$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(),
|
->deselectRecordsAfterCompletion(),
|
||||||
]),
|
]),
|
||||||
@ -1484,14 +1581,14 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
$metadata['preview_ran_at'] = $previewRanAt;
|
$metadata['preview_ran_at'] = $previewRanAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
$idempotencyKey = RunIdempotency::restoreExecuteKey(
|
$idempotencyKey = RestoreRunIdempotency::restoreExecuteKey(
|
||||||
tenantId: (int) $tenant->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
backupSetId: (int) $backupSet->getKey(),
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
selectedItemIds: $selectedItemIds,
|
selectedItemIds: $selectedItemIds,
|
||||||
groupMapping: $groupMapping,
|
groupMapping: $groupMapping,
|
||||||
);
|
);
|
||||||
|
|
||||||
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
@ -1517,7 +1614,7 @@ public static function createRestoreRun(array $data): RestoreRun
|
|||||||
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
'group_mapping' => $groupMapping !== [] ? $groupMapping : null,
|
||||||
]);
|
]);
|
||||||
} catch (QueryException $exception) {
|
} catch (QueryException $exception) {
|
||||||
$existing = RunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
$existing = RestoreRunIdempotency::findActiveRestoreRun((int) $tenant->getKey(), $idempotencyKey);
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
|
|||||||
@ -8,7 +8,6 @@
|
|||||||
use App\Jobs\SyncPoliciesJob;
|
use App\Jobs\SyncPoliciesJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Directory\EntraGroupLabelResolver;
|
use App\Services\Directory\EntraGroupLabelResolver;
|
||||||
use App\Services\Graph\GraphClientInterface;
|
use App\Services\Graph\GraphClientInterface;
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
@ -17,6 +16,7 @@
|
|||||||
use App\Services\Intune\TenantConfigService;
|
use App\Services\Intune\TenantConfigService;
|
||||||
use App\Services\Intune\TenantPermissionService;
|
use App\Services\Intune\TenantPermissionService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
@ -448,49 +448,42 @@ public static function table(Table $table): Table
|
|||||||
$ids = $eligible->pluck('id')->toArray();
|
$ids = $eligible->pluck('id')->toArray();
|
||||||
$count = $eligible->count();
|
$count = $eligible->count();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
/** @var BulkSelectionIdentity $selection */
|
||||||
$run = $service->createRun($tenantContext, $user, 'tenant', 'sync', $ids, $count);
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
$selectionIdentity = $selection->fromIds($ids);
|
||||||
|
|
||||||
foreach ($eligible as $tenant) {
|
/** @var OperationRunService $runs */
|
||||||
// Note: We might want canonical runs for bulk syncs too, but spec Phase 1 mentions tenant-scoped operations.
|
$runs = app(OperationRunService::class);
|
||||||
// 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 $opService */
|
$opRun = $runs->enqueueBulkOperation(
|
||||||
$opService = app(OperationRunService::class);
|
tenant: $tenantContext,
|
||||||
$opRun = $opService->ensureRun(
|
type: 'tenant.sync',
|
||||||
tenant: $tenant,
|
targetScope: [
|
||||||
type: 'policy.sync',
|
'entra_tenant_id' => (string) ($tenantContext->tenant_id ?? $tenantContext->external_id),
|
||||||
inputs: ['scope' => 'full', 'bulk_run_id' => $run->id],
|
],
|
||||||
initiator: $user
|
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);
|
OperationUxPresenter::queuedToast('tenant.sync')
|
||||||
|
->actions([
|
||||||
$auditLogger->log(
|
Actions\Action::make('view_run')
|
||||||
tenant: $tenant,
|
->label('View run')
|
||||||
action: 'tenant.sync_dispatched',
|
->url(OperationRunLinks::view($opRun, $tenantContext)),
|
||||||
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)
|
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
BulkTenantSyncJob::dispatch($run->id);
|
|
||||||
})
|
})
|
||||||
->deselectRecordsAfterCompletion(),
|
->deselectRecordsAfterCompletion(),
|
||||||
])
|
])
|
||||||
|
|||||||
@ -2,20 +2,19 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Filament\Resources\BulkOperationRunResource;
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\BulkOperationRun;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\BulkOperationService;
|
use App\Models\User;
|
||||||
use App\Services\Intune\FoundationSnapshotService;
|
use App\Services\Intune\FoundationSnapshotService;
|
||||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||||
use App\Services\Intune\SnapshotValidator;
|
use App\Services\Intune\SnapshotValidator;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -34,12 +33,17 @@ class AddPoliciesToBackupSetJob implements ShouldQueue
|
|||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
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 function __construct(
|
||||||
public int $bulkRunId,
|
public int $tenantId,
|
||||||
|
public int $userId,
|
||||||
public int $backupSetId,
|
public int $backupSetId,
|
||||||
public bool $includeAssignments,
|
public array $policyIds,
|
||||||
public bool $includeScopeTags,
|
public array $options,
|
||||||
public bool $includeFoundations,
|
public string $idempotencyKey,
|
||||||
?OperationRun $operationRun = null
|
?OperationRun $operationRun = null
|
||||||
) {
|
) {
|
||||||
$this->operationRun = $operationRun;
|
$this->operationRun = $operationRun;
|
||||||
@ -51,39 +55,31 @@ public function middleware(): array
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function handle(
|
public function handle(
|
||||||
BulkOperationService $bulkOperationService,
|
OperationRunService $operationRunService,
|
||||||
PolicyCaptureOrchestrator $captureOrchestrator,
|
PolicyCaptureOrchestrator $captureOrchestrator,
|
||||||
FoundationSnapshotService $foundationSnapshots,
|
FoundationSnapshotService $foundationSnapshots,
|
||||||
SnapshotValidator $snapshotValidator,
|
SnapshotValidator $snapshotValidator,
|
||||||
): void {
|
): void {
|
||||||
$run = BulkOperationRun::with(['tenant', 'user'])->find($this->bulkRunId);
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
|
||||||
if (! $run) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$started = BulkOperationRun::query()
|
$tenant = Tenant::query()->find($this->tenantId);
|
||||||
->whereKey($run->getKey())
|
$initiator = User::query()->find($this->userId);
|
||||||
->where('status', 'pending')
|
|
||||||
->update(['status' => 'running']);
|
|
||||||
|
|
||||||
if ($started === 0) {
|
$policyIds = $this->normalizePolicyIds($this->policyIds);
|
||||||
return;
|
$includeAssignments = (bool) ($this->options['include_assignments'] ?? false);
|
||||||
}
|
$includeScopeTags = (bool) ($this->options['include_scope_tags'] ?? false);
|
||||||
|
$includeFoundations = (bool) ($this->options['include_foundations'] ?? false);
|
||||||
$run->refresh();
|
|
||||||
|
|
||||||
$tenant = $run->tenant ?? Tenant::query()->find($run->tenant_id);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
$this->markRunFailed(
|
$this->failRun(
|
||||||
bulkOperationService: $bulkOperationService,
|
operationRunService: $operationRunService,
|
||||||
run: $run,
|
|
||||||
tenant: null,
|
tenant: null,
|
||||||
itemId: (string) $this->backupSetId,
|
code: 'tenant.not_found',
|
||||||
reasonCode: 'unknown',
|
message: 'Tenant not found for run.',
|
||||||
reason: 'Tenant not found for run.',
|
initiator: $initiator,
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -95,49 +91,64 @@ public function handle(
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $backupSet) {
|
if (! $backupSet) {
|
||||||
$this->markRunFailed(
|
$this->failRun(
|
||||||
bulkOperationService: $bulkOperationService,
|
operationRunService: $operationRunService,
|
||||||
run: $run,
|
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
itemId: (string) $this->backupSetId,
|
code: 'backup_set.not_found',
|
||||||
reasonCode: 'backup_set_not_found',
|
message: 'Backup set not found.',
|
||||||
reason: 'Backup set not found.',
|
initiator: $initiator,
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($backupSet->trashed()) {
|
if ($backupSet->trashed()) {
|
||||||
$this->markRunFailed(
|
$this->failRun(
|
||||||
bulkOperationService: $bulkOperationService,
|
operationRunService: $operationRunService,
|
||||||
run: $run,
|
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
itemId: (string) $backupSet->getKey(),
|
code: 'backup_set.archived',
|
||||||
reasonCode: 'backup_set_archived',
|
message: 'Backup set is archived.',
|
||||||
reason: 'Backup set is archived.',
|
initiator: $initiator,
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
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 === []) {
|
if ($policyIds === []) {
|
||||||
$bulkOperationService->complete($run);
|
$operationRunService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: 'failed',
|
||||||
|
failures: [[
|
||||||
|
'code' => 'selection.empty',
|
||||||
|
'message' => 'No policies selected.',
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
|
||||||
if ($this->operationRun) {
|
$this->notifyRunFailed($initiator, $tenant, 'No policies selected.');
|
||||||
/** @var OperationRunService $opService */
|
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opService->updateRun($this->operationRun, 'completed', 'succeeded', ['policy_ids' => 0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((int) $run->total_items !== count($policyIds)) {
|
|
||||||
$run->update(['total_items' => count($policyIds)]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$existingBackupFailures = (array) Arr::get($backupSet->metadata ?? [], 'failures', []);
|
$existingBackupFailures = (array) Arr::get($backupSet->metadata ?? [], 'failures', []);
|
||||||
$newBackupFailures = [];
|
$newBackupFailures = [];
|
||||||
|
|
||||||
@ -172,14 +183,14 @@ public function handle(
|
|||||||
->get()
|
->get()
|
||||||
->keyBy('id');
|
->keyBy('id');
|
||||||
|
|
||||||
|
$runFailuresForOperationRun = [];
|
||||||
|
|
||||||
foreach ($policyIds as $policyId) {
|
foreach ($policyIds as $policyId) {
|
||||||
if (isset($activePolicyIdSet[$policyId])) {
|
if (isset($activePolicyIdSet[$policyId])) {
|
||||||
$bulkOperationService->recordSkippedWithReason(
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||||
run: $run,
|
'processed' => 1,
|
||||||
itemId: (string) $policyId,
|
'skipped' => 1,
|
||||||
reason: 'Already in backup set',
|
]);
|
||||||
reasonCode: 'already_in_backup_set',
|
|
||||||
);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -193,7 +204,11 @@ public function handle(
|
|||||||
$didMutateBackupSet = true;
|
$didMutateBackupSet = true;
|
||||||
$backupSetItemMutations++;
|
$backupSetItemMutations++;
|
||||||
|
|
||||||
$bulkOperationService->recordSuccess($run);
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||||
|
'processed' => 1,
|
||||||
|
'succeeded' => 1,
|
||||||
|
'updated' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -203,29 +218,30 @@ public function handle(
|
|||||||
if (! $policy instanceof Policy) {
|
if (! $policy instanceof Policy) {
|
||||||
$newBackupFailures[] = [
|
$newBackupFailures[] = [
|
||||||
'policy_id' => $policyId,
|
'policy_id' => $policyId,
|
||||||
'reason' => $bulkOperationService->sanitizeFailureReason('Policy not found.'),
|
'reason' => RunFailureSanitizer::sanitizeMessage('Policy not found.'),
|
||||||
'status' => null,
|
'status' => null,
|
||||||
'reason_code' => 'policy_not_found',
|
'reason_code' => 'policy_not_found',
|
||||||
];
|
];
|
||||||
$didMutateBackupSet = true;
|
$didMutateBackupSet = true;
|
||||||
|
|
||||||
$bulkOperationService->recordFailure(
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||||
run: $run,
|
'processed' => 1,
|
||||||
itemId: (string) $policyId,
|
'failed' => 1,
|
||||||
reason: 'Policy not found.',
|
]);
|
||||||
reasonCode: 'policy_not_found',
|
|
||||||
);
|
$runFailuresForOperationRun[] = [
|
||||||
|
'code' => 'policy.not_found',
|
||||||
|
'message' => "Policy {$policyId} not found.",
|
||||||
|
];
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($policy->ignored_at) {
|
if ($policy->ignored_at) {
|
||||||
$bulkOperationService->recordSkippedWithReason(
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||||
run: $run,
|
'processed' => 1,
|
||||||
itemId: (string) $policyId,
|
'skipped' => 1,
|
||||||
reason: 'Policy is ignored locally',
|
]);
|
||||||
reasonCode: 'policy_ignored',
|
|
||||||
);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -234,16 +250,16 @@ public function handle(
|
|||||||
$captureResult = $captureOrchestrator->capture(
|
$captureResult = $captureOrchestrator->capture(
|
||||||
policy: $policy,
|
policy: $policy,
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
includeAssignments: $this->includeAssignments,
|
includeAssignments: $includeAssignments,
|
||||||
includeScopeTags: $this->includeScopeTags,
|
includeScopeTags: $includeScopeTags,
|
||||||
createdBy: $run->user?->email ? Str::limit($run->user->email, 255, '') : null,
|
createdBy: $initiator?->email ? Str::limit((string) $initiator->email, 255, '') : null,
|
||||||
metadata: [
|
metadata: [
|
||||||
'source' => 'backup',
|
'source' => 'backup',
|
||||||
'backup_set_id' => $backupSet->getKey(),
|
'backup_set_id' => $backupSet->getKey(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
} catch (Throwable $throwable) {
|
} catch (Throwable $throwable) {
|
||||||
$reason = $bulkOperationService->sanitizeFailureReason($throwable->getMessage());
|
$reason = RunFailureSanitizer::sanitizeMessage($throwable->getMessage());
|
||||||
|
|
||||||
$newBackupFailures[] = [
|
$newBackupFailures[] = [
|
||||||
'policy_id' => $policyId,
|
'policy_id' => $policyId,
|
||||||
@ -253,12 +269,15 @@ public function handle(
|
|||||||
];
|
];
|
||||||
$didMutateBackupSet = true;
|
$didMutateBackupSet = true;
|
||||||
|
|
||||||
$bulkOperationService->recordFailure(
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||||
run: $run,
|
'processed' => 1,
|
||||||
itemId: (string) $policyId,
|
'failed' => 1,
|
||||||
reason: $reason,
|
]);
|
||||||
reasonCode: 'unknown',
|
|
||||||
);
|
$runFailuresForOperationRun[] = [
|
||||||
|
'code' => 'policy.capture_exception',
|
||||||
|
'message' => $reason,
|
||||||
|
];
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -267,7 +286,7 @@ public function handle(
|
|||||||
$failure = $captureResult['failure'];
|
$failure = $captureResult['failure'];
|
||||||
$status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null;
|
$status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null;
|
||||||
$reasonCode = $this->mapGraphFailureReasonCode($status);
|
$reasonCode = $this->mapGraphFailureReasonCode($status);
|
||||||
$reason = $bulkOperationService->sanitizeFailureReason((string) ($failure['reason'] ?? 'Graph capture failed.'));
|
$reason = RunFailureSanitizer::sanitizeMessage((string) ($failure['reason'] ?? 'Graph capture failed.'));
|
||||||
|
|
||||||
$newBackupFailures[] = [
|
$newBackupFailures[] = [
|
||||||
'policy_id' => $policyId,
|
'policy_id' => $policyId,
|
||||||
@ -277,12 +296,15 @@ public function handle(
|
|||||||
];
|
];
|
||||||
$didMutateBackupSet = true;
|
$didMutateBackupSet = true;
|
||||||
|
|
||||||
$bulkOperationService->recordFailure(
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||||
run: $run,
|
'processed' => 1,
|
||||||
itemId: (string) $policyId,
|
'failed' => 1,
|
||||||
reason: $reason,
|
]);
|
||||||
reasonCode: $reasonCode,
|
|
||||||
);
|
$runFailuresForOperationRun[] = [
|
||||||
|
'code' => "graph.{$reasonCode}",
|
||||||
|
'message' => $reason,
|
||||||
|
];
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -293,18 +315,21 @@ public function handle(
|
|||||||
if (! $version || ! is_array($captured)) {
|
if (! $version || ! is_array($captured)) {
|
||||||
$newBackupFailures[] = [
|
$newBackupFailures[] = [
|
||||||
'policy_id' => $policyId,
|
'policy_id' => $policyId,
|
||||||
'reason' => $bulkOperationService->sanitizeFailureReason('Capture result missing version payload.'),
|
'reason' => RunFailureSanitizer::sanitizeMessage('Capture result missing version payload.'),
|
||||||
'status' => null,
|
'status' => null,
|
||||||
'reason_code' => 'unknown',
|
'reason_code' => 'unknown',
|
||||||
];
|
];
|
||||||
$didMutateBackupSet = true;
|
$didMutateBackupSet = true;
|
||||||
|
|
||||||
$bulkOperationService->recordFailure(
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||||
run: $run,
|
'processed' => 1,
|
||||||
itemId: (string) $policyId,
|
'failed' => 1,
|
||||||
reason: 'Capture result missing version payload.',
|
]);
|
||||||
reasonCode: 'unknown',
|
|
||||||
);
|
$runFailuresForOperationRun[] = [
|
||||||
|
'code' => 'capture.missing_payload',
|
||||||
|
'message' => 'Capture result missing version payload.',
|
||||||
|
];
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -352,12 +377,10 @@ public function handle(
|
|||||||
]);
|
]);
|
||||||
} catch (QueryException $exception) {
|
} catch (QueryException $exception) {
|
||||||
if ((string) $exception->getCode() === '23505') {
|
if ((string) $exception->getCode() === '23505') {
|
||||||
$bulkOperationService->recordSkippedWithReason(
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||||
run: $run,
|
'processed' => 1,
|
||||||
itemId: (string) $policyId,
|
'skipped' => 1,
|
||||||
reason: 'Already in backup set',
|
]);
|
||||||
reasonCode: 'already_in_backup_set',
|
|
||||||
);
|
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@ -369,12 +392,15 @@ public function handle(
|
|||||||
$didMutateBackupSet = true;
|
$didMutateBackupSet = true;
|
||||||
$backupSetItemMutations++;
|
$backupSetItemMutations++;
|
||||||
|
|
||||||
$bulkOperationService->recordSuccess($run);
|
$operationRunService->incrementSummaryCounts($this->operationRun, [
|
||||||
|
'processed' => 1,
|
||||||
|
'succeeded' => 1,
|
||||||
|
'created' => 1,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->includeFoundations) {
|
if ($includeFoundations) {
|
||||||
[$foundationOutcome, $foundationFailureEntries] = $this->captureFoundations(
|
[$foundationOutcome, $foundationFailureEntries] = $this->captureFoundations(
|
||||||
bulkOperationService: $bulkOperationService,
|
|
||||||
foundationSnapshots: $foundationSnapshots,
|
foundationSnapshots: $foundationSnapshots,
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
backupSet: $backupSet,
|
backupSet: $backupSet,
|
||||||
@ -391,13 +417,10 @@ public function handle(
|
|||||||
$newBackupFailures = array_merge($newBackupFailures, $foundationFailureEntries);
|
$newBackupFailures = array_merge($newBackupFailures, $foundationFailureEntries);
|
||||||
|
|
||||||
foreach ($foundationFailureEntries as $foundationFailure) {
|
foreach ($foundationFailureEntries as $foundationFailure) {
|
||||||
$this->appendRunFailure($run, [
|
$runFailuresForOperationRun[] = [
|
||||||
'type' => 'foundation',
|
'code' => 'foundation.capture_failed',
|
||||||
'item_id' => (string) ($foundationFailure['foundation_type'] ?? 'foundation'),
|
'message' => (string) ($foundationFailure['reason'] ?? 'Foundation capture failed.'),
|
||||||
'reason_code' => (string) ($foundationFailure['reason_code'] ?? 'unknown'),
|
];
|
||||||
'reason' => (string) ($foundationFailure['reason'] ?? 'Foundation capture failed.'),
|
|
||||||
'status' => $foundationFailure['status'] ?? null,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -420,45 +443,41 @@ public function handle(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$bulkOperationService->complete($run);
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
if ($this->operationRun) {
|
$counts = is_array($this->operationRun->summary_counts) ? $this->operationRun->summary_counts : [];
|
||||||
/** @var OperationRunService $opService */
|
$failed = (int) ($counts['failed'] ?? 0);
|
||||||
$opService = app(OperationRunService::class);
|
$succeeded = (int) ($counts['succeeded'] ?? 0);
|
||||||
|
$skipped = (int) ($counts['skipped'] ?? 0);
|
||||||
|
|
||||||
$opOutcome = match (true) {
|
$outcome = 'succeeded';
|
||||||
$run->status === 'completed' => 'succeeded',
|
if ($failed > 0 && $succeeded > 0) {
|
||||||
$run->status === 'completed_with_errors' => 'partially_succeeded',
|
$outcome = 'partially_succeeded';
|
||||||
$run->status === 'failed' => 'failed',
|
}
|
||||||
default => 'failed'
|
if ($failed > 0 && $succeeded === 0) {
|
||||||
};
|
$outcome = 'failed';
|
||||||
|
|
||||||
$opService->updateRun(
|
|
||||||
$this->operationRun,
|
|
||||||
'completed',
|
|
||||||
$opOutcome,
|
|
||||||
[
|
|
||||||
'policies_added' => $backupSetItemMutations,
|
|
||||||
'foundations_added' => $foundationMutations,
|
|
||||||
'failures' => count($newBackupFailures),
|
|
||||||
],
|
|
||||||
$newBackupFailures
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $run->user) {
|
$operationRunService->updateRun(
|
||||||
|
$this->operationRun,
|
||||||
|
status: 'completed',
|
||||||
|
outcome: $outcome,
|
||||||
|
failures: $runFailuresForOperationRun,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $initiator instanceof User) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$message = "Added {$run->succeeded} policies";
|
$message = "Added {$succeeded} policies";
|
||||||
if ($run->skipped > 0) {
|
if ($skipped > 0) {
|
||||||
$message .= " ({$run->skipped} skipped)";
|
$message .= " ({$skipped} skipped)";
|
||||||
}
|
}
|
||||||
if ($run->failed > 0) {
|
if ($failed > 0) {
|
||||||
$message .= " ({$run->failed} failed)";
|
$message .= " ({$failed} failed)";
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->includeFoundations) {
|
if ($includeFoundations) {
|
||||||
$message .= ". Foundations: {$foundationMutations} items";
|
$message .= ". Foundations: {$foundationMutations} items";
|
||||||
|
|
||||||
if ($foundationFailures > 0) {
|
if ($foundationFailures > 0) {
|
||||||
@ -468,7 +487,7 @@ public function handle(
|
|||||||
|
|
||||||
$message .= '.';
|
$message .= '.';
|
||||||
|
|
||||||
$partial = $run->status === 'completed_with_errors' || $foundationFailures > 0;
|
$partial = $outcome === 'partially_succeeded' || $foundationFailures > 0;
|
||||||
|
|
||||||
$notification = Notification::make()
|
$notification = Notification::make()
|
||||||
->title($partial ? 'Add Policies Completed (partial)' : 'Add Policies Completed')
|
->title($partial ? 'Add Policies Completed (partial)' : 'Add Policies Completed')
|
||||||
@ -476,7 +495,7 @@ public function handle(
|
|||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('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) {
|
if ($partial) {
|
||||||
@ -486,22 +505,15 @@ public function handle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
$notification
|
$notification
|
||||||
->sendToDatabase($run->user)
|
->sendToDatabase($initiator)
|
||||||
->send();
|
->send();
|
||||||
} catch (Throwable $throwable) {
|
} catch (Throwable $throwable) {
|
||||||
$run->refresh();
|
$this->failRun(
|
||||||
|
operationRunService: $operationRunService,
|
||||||
if (in_array($run->status, ['completed', 'completed_with_errors'], true)) {
|
|
||||||
throw $throwable;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->markRunFailed(
|
|
||||||
bulkOperationService: $bulkOperationService,
|
|
||||||
run: $run,
|
|
||||||
tenant: $tenant instanceof Tenant ? $tenant : null,
|
tenant: $tenant instanceof Tenant ? $tenant : null,
|
||||||
itemId: (string) $this->backupSetId,
|
code: 'exception.unhandled',
|
||||||
reasonCode: 'unknown',
|
message: $throwable->getMessage(),
|
||||||
reason: $throwable->getMessage(),
|
initiator: $initiator,
|
||||||
);
|
);
|
||||||
|
|
||||||
// TrackOperationRun will catch this throw
|
// TrackOperationRun will catch this throw
|
||||||
@ -510,20 +522,11 @@ public function handle(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param array<int, int> $policyIds
|
||||||
* @return array<int>
|
* @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_unique(array_map('intval', $policyIds)));
|
||||||
$policyIds = array_values(array_filter($policyIds, fn (int $value): bool => $value > 0));
|
$policyIds = array_values(array_filter($policyIds, fn (int $value): bool => $value > 0));
|
||||||
sort($policyIds);
|
sort($policyIds);
|
||||||
@ -531,61 +534,32 @@ private function extractPolicyIds(BulkOperationRun $run): array
|
|||||||
return $policyIds;
|
return $policyIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private function failRun(
|
||||||
* @param array<string, mixed> $entry
|
OperationRunService $operationRunService,
|
||||||
*/
|
|
||||||
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,
|
|
||||||
?Tenant $tenant,
|
?Tenant $tenant,
|
||||||
string $itemId,
|
string $code,
|
||||||
string $reasonCode,
|
string $message,
|
||||||
string $reason,
|
?User $initiator = null,
|
||||||
): void {
|
): void {
|
||||||
$reason = $bulkOperationService->sanitizeFailureReason($reason);
|
$safeMessage = RunFailureSanitizer::sanitizeMessage($message);
|
||||||
|
$safeCode = RunFailureSanitizer::sanitizeCode($code);
|
||||||
|
|
||||||
$this->appendRunFailure($run, [
|
$operationRunService->updateRun(
|
||||||
'type' => 'run',
|
$this->operationRun,
|
||||||
'item_id' => $itemId,
|
status: 'completed',
|
||||||
'reason_code' => $reasonCode,
|
outcome: 'failed',
|
||||||
'reason' => $reason,
|
failures: [[
|
||||||
]);
|
'code' => $safeCode,
|
||||||
|
'message' => $safeMessage,
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
$this->notifyRunFailed($initiator, $tenant, $safeMessage);
|
||||||
$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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -597,13 +571,13 @@ private function notifyRunFailed(BulkOperationRun $run, ?Tenant $tenant, string
|
|||||||
$notification->actions([
|
$notification->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('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
|
$notification
|
||||||
->danger()
|
->danger()
|
||||||
->sendToDatabase($run->user)
|
->sendToDatabase($initiator)
|
||||||
->send();
|
->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}>}
|
* @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(
|
private function captureFoundations(
|
||||||
BulkOperationService $bulkOperationService,
|
|
||||||
FoundationSnapshotService $foundationSnapshots,
|
FoundationSnapshotService $foundationSnapshots,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
BackupSet $backupSet,
|
BackupSet $backupSet,
|
||||||
@ -647,7 +620,7 @@ private function captureFoundations(
|
|||||||
|
|
||||||
$status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null;
|
$status = isset($failure['status']) && is_numeric($failure['status']) ? (int) $failure['status'] : null;
|
||||||
$reasonCode = $this->mapGraphFailureReasonCode($status);
|
$reasonCode = $this->mapGraphFailureReasonCode($status);
|
||||||
$reason = $bulkOperationService->sanitizeFailureReason((string) ($failure['reason'] ?? 'Foundation capture failed.'));
|
$reason = RunFailureSanitizer::sanitizeMessage((string) ($failure['reason'] ?? 'Foundation capture failed.'));
|
||||||
|
|
||||||
$failures[] = [
|
$failures[] = [
|
||||||
'foundation_type' => $foundationType,
|
'foundation_type' => $foundationType,
|
||||||
|
|||||||
@ -2,149 +2,93 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\BackupSet;
|
use App\Jobs\Operations\BackupSetDeleteWorkerJob;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\OperationRunService;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Throwable;
|
use RuntimeException;
|
||||||
|
|
||||||
class BulkBackupSetDeleteJob implements ShouldQueue
|
class BulkBackupSetDeleteJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $backupSetIds
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
public function __construct(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->start($run);
|
$runs->updateRun($this->operationRun, 'running');
|
||||||
|
|
||||||
$itemCount = 0;
|
$ids = $this->normalizeIds($this->backupSetIds);
|
||||||
$succeeded = 0;
|
|
||||||
$failed = 0;
|
|
||||||
$skipped = 0;
|
|
||||||
$skipReasons = [];
|
|
||||||
|
|
||||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
|
||||||
$failureThreshold = (int) floor($totalItems / 2);
|
|
||||||
|
|
||||||
foreach (($run->item_ids ?? []) as $backupSetId) {
|
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||||
$itemCount++;
|
$chunkSize = max(1, $chunkSize);
|
||||||
|
|
||||||
try {
|
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||||
/** @var BackupSet|null $backupSet */
|
foreach ($chunk as $backupSetId) {
|
||||||
$backupSet = BackupSet::withTrashed()
|
dispatch(new BackupSetDeleteWorkerJob(
|
||||||
->where('tenant_id', $run->tenant_id)
|
tenantId: $this->tenantId,
|
||||||
->whereKey($backupSetId)
|
userId: $this->userId,
|
||||||
->first();
|
backupSetId: $backupSetId,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
|
context: $this->context,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (! $backupSet) {
|
/**
|
||||||
$service->recordFailure($run, (string) $backupSetId, 'Backup set not found');
|
* @param array<int, mixed> $ids
|
||||||
$failed++;
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function normalizeIds(array $ids): array
|
||||||
|
{
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
if ($failed > $failureThreshold) {
|
foreach ($ids as $id) {
|
||||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
if (is_int($id)) {
|
||||||
|
$normalized[] = $id;
|
||||||
|
|
||||||
if ($run->user) {
|
continue;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($itemCount % $chunkSize === 0) {
|
if (is_numeric($id)) {
|
||||||
$run->refresh();
|
$normalized[] = (int) $id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->complete($run);
|
$normalized = array_values(array_unique($normalized));
|
||||||
|
sort($normalized);
|
||||||
|
|
||||||
if (! $run->user) {
|
return $normalized;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,159 +2,93 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\BackupSet;
|
use App\Jobs\Operations\BackupSetForceDeleteWorkerJob;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\OperationRunService;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Throwable;
|
use RuntimeException;
|
||||||
|
|
||||||
class BulkBackupSetForceDeleteJob implements ShouldQueue
|
class BulkBackupSetForceDeleteJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $backupSetIds
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
public function __construct(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->start($run);
|
$runs->updateRun($this->operationRun, 'running');
|
||||||
|
|
||||||
$itemCount = 0;
|
$ids = $this->normalizeIds($this->backupSetIds);
|
||||||
$succeeded = 0;
|
|
||||||
$failed = 0;
|
|
||||||
$skipped = 0;
|
|
||||||
$skipReasons = [];
|
|
||||||
|
|
||||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
|
||||||
$failureThreshold = (int) floor($totalItems / 2);
|
|
||||||
|
|
||||||
foreach (($run->item_ids ?? []) as $backupSetId) {
|
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||||
$itemCount++;
|
$chunkSize = max(1, $chunkSize);
|
||||||
|
|
||||||
try {
|
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||||
/** @var BackupSet|null $backupSet */
|
foreach ($chunk as $backupSetId) {
|
||||||
$backupSet = BackupSet::withTrashed()
|
dispatch(new BackupSetForceDeleteWorkerJob(
|
||||||
->where('tenant_id', $run->tenant_id)
|
tenantId: $this->tenantId,
|
||||||
->whereKey($backupSetId)
|
userId: $this->userId,
|
||||||
->first();
|
backupSetId: $backupSetId,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
|
context: $this->context,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (! $backupSet) {
|
/**
|
||||||
$service->recordFailure($run, (string) $backupSetId, 'Backup set not found');
|
* @param array<int, mixed> $ids
|
||||||
$failed++;
|
* @return array<int, int>
|
||||||
|
*/
|
||||||
|
private function normalizeIds(array $ids): array
|
||||||
|
{
|
||||||
|
$normalized = [];
|
||||||
|
|
||||||
if ($failed > $failureThreshold) {
|
foreach ($ids as $id) {
|
||||||
$service->abort($run, 'Circuit breaker: more than 50% of items failed.');
|
if (is_int($id)) {
|
||||||
|
$normalized[] = $id;
|
||||||
|
|
||||||
if ($run->user) {
|
continue;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($itemCount % $chunkSize === 0) {
|
if (is_numeric($id)) {
|
||||||
$run->refresh();
|
$normalized[] = (int) $id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->complete($run);
|
$normalized = array_values(array_unique($normalized));
|
||||||
|
sort($normalized);
|
||||||
|
|
||||||
if (! $run->user) {
|
return $normalized;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,151 +2,138 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\BackupSet;
|
use App\Jobs\Operations\BackupSetRestoreWorkerJob;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\OperationRunService;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use RuntimeException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class BulkBackupSetRestoreJob implements ShouldQueue
|
class BulkBackupSetRestoreJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public int $bulkRunId = 0;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $backupSetIds
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $bulkRunId,
|
public int $tenantId,
|
||||||
) {}
|
public int $userId,
|
||||||
|
public array $backupSetIds,
|
||||||
|
?OperationRun $operationRun = null,
|
||||||
|
public array $context = [],
|
||||||
|
) {
|
||||||
|
$this->operationRun = $operationRun;
|
||||||
|
$this->bulkRunId = $operationRun?->getKey() ? (int) $operationRun->getKey() : 0;
|
||||||
|
}
|
||||||
|
|
||||||
public function handle(BulkOperationService $service): void
|
public function handle(OperationRunService $runs): void
|
||||||
{
|
{
|
||||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
$this->operationRun = $this->resolveOperationRun();
|
||||||
|
|
||||||
if (! $run || $run->status !== 'pending') {
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
|
if ($this->operationRun->status === 'completed') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->start($run);
|
$runs->updateRun($this->operationRun, 'running');
|
||||||
|
|
||||||
$itemCount = 0;
|
$ids = $this->normalizeIds($this->backupSetIds);
|
||||||
$succeeded = 0;
|
|
||||||
$failed = 0;
|
|
||||||
$skipped = 0;
|
|
||||||
$skipReasons = [];
|
|
||||||
|
|
||||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
|
||||||
$failureThreshold = (int) floor($totalItems / 2);
|
|
||||||
|
|
||||||
foreach (($run->item_ids ?? []) as $backupSetId) {
|
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||||
$itemCount++;
|
$chunkSize = max(1, $chunkSize);
|
||||||
|
|
||||||
try {
|
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||||
/** @var BackupSet|null $backupSet */
|
foreach ($chunk as $backupSetId) {
|
||||||
$backupSet = BackupSet::withTrashed()
|
dispatch(new BackupSetRestoreWorkerJob(
|
||||||
->where('tenant_id', $run->tenant_id)
|
tenantId: $this->tenantId,
|
||||||
->whereKey($backupSetId)
|
userId: $this->userId,
|
||||||
->first();
|
backupSetId: $backupSetId,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
if (! $backupSet) {
|
context: $this->context,
|
||||||
$service->recordFailure($run, (string) $backupSetId, 'Backup set 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 (! $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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($itemCount % $chunkSize === 0) {
|
|
||||||
$run->refresh();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$service->complete($run);
|
public function failed(Throwable $e): void
|
||||||
|
{
|
||||||
|
$run = $this->operationRun;
|
||||||
|
|
||||||
if (! $run->user) {
|
if (! $run instanceof OperationRun && $this->bulkRunId > 0) {
|
||||||
|
$run = OperationRun::query()->find($this->bulkRunId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $run instanceof OperationRun) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$message = "Restored {$succeeded} backup sets";
|
/** @var OperationRunService $runs */
|
||||||
if ($skipped > 0) {
|
$runs = app(OperationRunService::class);
|
||||||
$message .= " ({$skipped} skipped)";
|
|
||||||
}
|
$runs->updateRun(
|
||||||
if ($failed > 0) {
|
$run,
|
||||||
$message .= " ({$failed} failed)";
|
status: 'completed',
|
||||||
|
outcome: 'failed',
|
||||||
|
failures: [[
|
||||||
|
'code' => 'bulk_job.failed',
|
||||||
|
'message' => $e->getMessage(),
|
||||||
|
]],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveOperationRun(): OperationRun
|
||||||
|
{
|
||||||
|
if ($this->operationRun instanceof OperationRun) {
|
||||||
|
return $this->operationRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! empty($skipReasons)) {
|
if ($this->bulkRunId > 0) {
|
||||||
$summary = collect($skipReasons)
|
$run = OperationRun::query()->find($this->bulkRunId);
|
||||||
->sortDesc()
|
|
||||||
->map(fn (int $count, string $reason) => "{$reason} ({$count})")
|
|
||||||
->take(3)
|
|
||||||
->implode(', ');
|
|
||||||
|
|
||||||
if ($summary !== '') {
|
if ($run instanceof OperationRun) {
|
||||||
$message .= " Skip reasons: {$summary}.";
|
return $run;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$message .= '.';
|
throw new RuntimeException('OperationRun is required for BulkBackupSetRestoreJob.');
|
||||||
|
}
|
||||||
|
|
||||||
Notification::make()
|
/**
|
||||||
->title('Bulk Restore Completed')
|
* @param array<int, mixed> $ids
|
||||||
->body($message)
|
* @return array<int, int>
|
||||||
->icon('heroicon-o-check-circle')
|
*/
|
||||||
->success()
|
private function normalizeIds(array $ids): array
|
||||||
->sendToDatabase($run->user)
|
{
|
||||||
->send();
|
$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,184 +2,93 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\BulkOperationRun;
|
use App\Jobs\Operations\PolicyBulkDeleteWorkerJob;
|
||||||
use App\Models\Policy;
|
use App\Models\OperationRun;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\OperationRunService;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Throwable;
|
use RuntimeException;
|
||||||
|
|
||||||
class BulkPolicyDeleteJob implements ShouldQueue
|
class BulkPolicyDeleteJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $policyIds
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
public function __construct(
|
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(OperationRunService $runs): void
|
||||||
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function handle(BulkOperationService $service): void
|
|
||||||
{
|
{
|
||||||
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
|
throw new RuntimeException('OperationRun is required for BulkPolicyDeleteJob.');
|
||||||
|
}
|
||||||
|
|
||||||
$run = BulkOperationRun::with('user')->find($this->bulkRunId);
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
if (! $run || $run->status !== 'pending') {
|
|
||||||
|
|
||||||
|
if ($this->operationRun->status === 'completed') {
|
||||||
return;
|
return;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->start($run);
|
$runs->updateRun($this->operationRun, 'running');
|
||||||
|
|
||||||
try {
|
$ids = $this->normalizeIds($this->policyIds);
|
||||||
|
|
||||||
$itemCount = 0;
|
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||||
$succeeded = 0;
|
|
||||||
$failed = 0;
|
|
||||||
$skipped = 0;
|
|
||||||
$failures = [];
|
|
||||||
|
|
||||||
$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 ?? []);
|
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||||
$failureThreshold = (int) floor($totalItems / 2);
|
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++;
|
foreach ($ids as $id) {
|
||||||
|
if (is_int($id)) {
|
||||||
try {
|
$normalized[] = $id;
|
||||||
|
|
||||||
$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();
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->complete($run);
|
if (is_numeric($id)) {
|
||||||
|
$normalized[] = (int) $id;
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
} 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;
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
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 Filament\Notifications\Notification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -19,31 +25,53 @@ class BulkPolicyExportJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public function __construct(
|
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 $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') {
|
public function handle(OperationRunService $operationRunService): void
|
||||||
return;
|
{
|
||||||
|
$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 {
|
try {
|
||||||
// Create Backup Set
|
// Create Backup Set
|
||||||
$backupSet = BackupSet::create([
|
$backupSet = BackupSet::create([
|
||||||
'tenant_id' => $run->tenant_id,
|
'tenant_id' => $tenant->getKey(),
|
||||||
'name' => $this->backupName,
|
'name' => $this->backupName,
|
||||||
// 'description' => $this->backupDescription, // Not in schema
|
// 'description' => $this->backupDescription, // Not in schema
|
||||||
'status' => 'completed',
|
'status' => 'completed',
|
||||||
'created_by' => $run->user?->name ?? (string) $run->user_id, // Schema has created_by string
|
'created_by' => $user->name,
|
||||||
'item_count' => count($run->item_ids),
|
'item_count' => count($ids),
|
||||||
'completed_at' => now(),
|
'completed_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -51,37 +79,55 @@ public function handle(BulkOperationService $service): void
|
|||||||
$succeeded = 0;
|
$succeeded = 0;
|
||||||
$failed = 0;
|
$failed = 0;
|
||||||
$failures = [];
|
$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);
|
$failureThreshold = (int) floor($totalItems / 2);
|
||||||
|
|
||||||
foreach ($run->item_ids as $policyId) {
|
foreach ($ids as $policyId) {
|
||||||
$itemCount++;
|
$itemCount++;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$policy = Policy::find($policyId);
|
$policy = Policy::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($policyId);
|
||||||
|
|
||||||
if (! $policy) {
|
if (! $policy) {
|
||||||
$service->recordFailure($run, (string) $policyId, 'Policy not found');
|
|
||||||
$failed++;
|
$failed++;
|
||||||
$failures[] = [
|
$failures[] = ['code' => 'policy.not_found', 'message' => "Policy {$policyId} not found."];
|
||||||
'item_id' => (string) $policyId,
|
|
||||||
'reason' => 'Policy not found',
|
|
||||||
'timestamp' => now()->toIso8601String(),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($failed > $failureThreshold) {
|
if ($failed > $failureThreshold) {
|
||||||
$backupSet->update(['status' => 'failed']);
|
$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()
|
Notification::make()
|
||||||
->title('Bulk Export Aborted')
|
->title('Bulk Export Aborted')
|
||||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
->icon('heroicon-o-exclamation-triangle')
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
->danger()
|
->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();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,25 +141,42 @@ public function handle(BulkOperationService $service): void
|
|||||||
$latestVersion = $policy->versions()->orderByDesc('captured_at')->first();
|
$latestVersion = $policy->versions()->orderByDesc('captured_at')->first();
|
||||||
|
|
||||||
if (! $latestVersion) {
|
if (! $latestVersion) {
|
||||||
$service->recordFailure($run, (string) $policyId, 'No versions available for policy');
|
|
||||||
$failed++;
|
$failed++;
|
||||||
$failures[] = [
|
$failures[] = ['code' => 'policy.no_versions', 'message' => "No versions available for policy {$policyId}."];
|
||||||
'item_id' => (string) $policyId,
|
|
||||||
'reason' => 'No versions available for policy',
|
|
||||||
'timestamp' => now()->toIso8601String(),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($failed > $failureThreshold) {
|
if ($failed > $failureThreshold) {
|
||||||
$backupSet->update(['status' => 'failed']);
|
$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()
|
Notification::make()
|
||||||
->title('Bulk Export Aborted')
|
->title('Bulk Export Aborted')
|
||||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
->icon('heroicon-o-exclamation-triangle')
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
->danger()
|
->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();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,7 +188,7 @@ public function handle(BulkOperationService $service): void
|
|||||||
|
|
||||||
// Create Backup Item
|
// Create Backup Item
|
||||||
BackupItem::create([
|
BackupItem::create([
|
||||||
'tenant_id' => $run->tenant_id,
|
'tenant_id' => $tenant->getKey(),
|
||||||
'backup_set_id' => $backupSet->id,
|
'backup_set_id' => $backupSet->id,
|
||||||
'policy_id' => $policy->id,
|
'policy_id' => $policy->id,
|
||||||
'policy_identifier' => $policy->external_id, // Added
|
'policy_identifier' => $policy->external_id, // Added
|
||||||
@ -139,46 +202,81 @@ public function handle(BulkOperationService $service): void
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service->recordSuccess($run);
|
|
||||||
$succeeded++;
|
$succeeded++;
|
||||||
|
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$service->recordFailure($run, (string) $policyId, $e->getMessage());
|
|
||||||
$failed++;
|
$failed++;
|
||||||
$failures[] = [
|
$failures[] = ['code' => 'policy.export.failed', 'message' => $e->getMessage()];
|
||||||
'item_id' => (string) $policyId,
|
|
||||||
'reason' => $e->getMessage(),
|
|
||||||
'timestamp' => now()->toIso8601String(),
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($failed > $failureThreshold) {
|
if ($failed > $failureThreshold) {
|
||||||
$backupSet->update(['status' => 'failed']);
|
$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()
|
Notification::make()
|
||||||
->title('Bulk Export Aborted')
|
->title('Bulk Export Aborted')
|
||||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
->icon('heroicon-o-exclamation-triangle')
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
->danger()
|
->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();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
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
|
// Update BackupSet item count (if denormalized) or just leave it
|
||||||
// Assuming BackupSet might need an item count or status update
|
// 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) {
|
if ($succeeded > 0 || $failed > 0) {
|
||||||
$message = "Successfully exported {$succeeded} policies to backup '{$this->backupName}'";
|
$message = "Successfully exported {$succeeded} policies to backup '{$this->backupName}'";
|
||||||
@ -192,24 +290,39 @@ public function handle(BulkOperationService $service): void
|
|||||||
->body($message)
|
->body($message)
|
||||||
->icon('heroicon-o-check-circle')
|
->icon('heroicon-o-check-circle')
|
||||||
->success()
|
->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();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (Throwable $e) {
|
} 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
|
if (isset($user) && $user instanceof User) {
|
||||||
$run->refresh();
|
|
||||||
$run->load('user');
|
|
||||||
|
|
||||||
if ($run->user) {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Bulk Export Failed')
|
->title('Bulk Export Failed')
|
||||||
->body($e->getMessage())
|
->body($e->getMessage())
|
||||||
->icon('heroicon-o-x-circle')
|
->icon('heroicon-o-x-circle')
|
||||||
->danger()
|
->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();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,17 +2,14 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Intune\PolicySyncService;
|
use App\Services\Intune\PolicySyncService;
|
||||||
use Filament\Notifications\Notification;
|
use App\Services\OperationRunService;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class BulkPolicySyncJob implements ShouldQueue
|
class BulkPolicySyncJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
@ -20,128 +17,24 @@ class BulkPolicySyncJob implements ShouldQueue
|
|||||||
|
|
||||||
public function __construct(public int $bulkRunId) {}
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->start($run);
|
(new SyncPoliciesJob(
|
||||||
|
tenantId: (int) $run->tenant_id,
|
||||||
try {
|
types: null,
|
||||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
policyIds: $policyIds,
|
||||||
$itemCount = 0;
|
operationRun: $run,
|
||||||
|
))->handle($syncService, $runs);
|
||||||
$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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\BulkOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
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 Filament\Notifications\Notification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -17,63 +23,113 @@ class BulkPolicyUnignoreJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
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') {
|
public function handle(OperationRunService $operationRunService): void
|
||||||
return;
|
{
|
||||||
|
$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 {
|
try {
|
||||||
$itemCount = 0;
|
|
||||||
$succeeded = 0;
|
$succeeded = 0;
|
||||||
$failed = 0;
|
$failed = 0;
|
||||||
$skipped = 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) {
|
foreach ($ids as $policyId) {
|
||||||
$itemCount++;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$policy = Policy::find($policyId);
|
$policy = Policy::query()
|
||||||
|
->where('tenant_id', $tenant->getKey())
|
||||||
|
->find($policyId);
|
||||||
|
|
||||||
if (! $policy) {
|
if (! $policy) {
|
||||||
$service->recordFailure($run, (string) $policyId, 'Policy not found');
|
|
||||||
$failed++;
|
$failed++;
|
||||||
|
$failures[] = [
|
||||||
|
'code' => 'policy.not_found',
|
||||||
|
'message' => "Policy {$policyId} not found.",
|
||||||
|
];
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $policy->ignored_at) {
|
if (! $policy->ignored_at) {
|
||||||
$service->recordSkipped($run);
|
|
||||||
$skipped++;
|
$skipped++;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$policy->unignore();
|
$policy->unignore();
|
||||||
|
|
||||||
$service->recordSuccess($run);
|
|
||||||
$succeeded++;
|
$succeeded++;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$service->recordFailure($run, (string) $policyId, $e->getMessage());
|
|
||||||
$failed++;
|
$failed++;
|
||||||
}
|
$failures[] = [
|
||||||
|
'code' => 'policy.unignore.failed',
|
||||||
if ($itemCount % $chunkSize === 0) {
|
'message' => $e->getMessage(),
|
||||||
$run->refresh();
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$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";
|
$message = "Restored {$succeeded} policies";
|
||||||
|
|
||||||
if ($skipped > 0) {
|
if ($skipped > 0) {
|
||||||
@ -91,22 +147,38 @@ public function handle(BulkOperationService $service): void
|
|||||||
->body($message)
|
->body($message)
|
||||||
->icon('heroicon-o-check-circle')
|
->icon('heroicon-o-check-circle')
|
||||||
->success()
|
->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();
|
->send();
|
||||||
}
|
}
|
||||||
} catch (Throwable $e) {
|
} 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();
|
if (isset($user) && $user instanceof User) {
|
||||||
$run->load('user');
|
|
||||||
|
|
||||||
if ($run->user) {
|
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Bulk Restore Failed')
|
->title('Bulk Restore Failed')
|
||||||
->body($e->getMessage())
|
->body($e->getMessage())
|
||||||
->icon('heroicon-o-x-circle')
|
->icon('heroicon-o-x-circle')
|
||||||
->danger()
|
->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();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,147 +2,93 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\BulkOperationRun;
|
use App\Jobs\Operations\PolicyVersionForceDeleteWorkerJob;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\OperationRun;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\OperationRunService;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Throwable;
|
use RuntimeException;
|
||||||
|
|
||||||
class BulkPolicyVersionForceDeleteJob implements ShouldQueue
|
class BulkPolicyVersionForceDeleteJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $policyVersionIds
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
public function __construct(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->start($run);
|
$runs->updateRun($this->operationRun, 'running');
|
||||||
|
|
||||||
$itemCount = 0;
|
$ids = $this->normalizeIds($this->policyVersionIds);
|
||||||
$succeeded = 0;
|
|
||||||
$failed = 0;
|
|
||||||
$skipped = 0;
|
|
||||||
$skipReasons = [];
|
|
||||||
|
|
||||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
|
||||||
$failureThreshold = (int) floor($totalItems / 2);
|
|
||||||
|
|
||||||
foreach (($run->item_ids ?? []) as $versionId) {
|
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||||
$itemCount++;
|
$chunkSize = max(1, $chunkSize);
|
||||||
|
|
||||||
try {
|
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||||
/** @var PolicyVersion|null $version */
|
foreach ($chunk as $policyVersionId) {
|
||||||
$version = PolicyVersion::withTrashed()
|
dispatch(new PolicyVersionForceDeleteWorkerJob(
|
||||||
->where('tenant_id', $run->tenant_id)
|
tenantId: $this->tenantId,
|
||||||
->whereKey($versionId)
|
userId: $this->userId,
|
||||||
->first();
|
policyVersionId: $policyVersionId,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
if (! $version) {
|
context: $this->context,
|
||||||
$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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\BulkOperationRun;
|
use App\Jobs\Operations\PolicyVersionPruneWorkerJob;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\OperationRun;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\OperationRunService;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Throwable;
|
use RuntimeException;
|
||||||
|
|
||||||
class BulkPolicyVersionPruneJob implements ShouldQueue
|
class BulkPolicyVersionPruneJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $policyVersionIds
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $bulkRunId,
|
public int $tenantId,
|
||||||
|
public int $userId,
|
||||||
|
public array $policyVersionIds,
|
||||||
public int $retentionDays = 90,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->start($run);
|
$runs->updateRun($this->operationRun, 'running');
|
||||||
|
|
||||||
$itemCount = 0;
|
$ids = $this->normalizeIds($this->policyVersionIds);
|
||||||
$succeeded = 0;
|
|
||||||
$failed = 0;
|
|
||||||
$skipped = 0;
|
|
||||||
$skipReasons = [];
|
|
||||||
|
|
||||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
|
||||||
$failureThreshold = (int) floor($totalItems / 2);
|
|
||||||
|
|
||||||
foreach (($run->item_ids ?? []) as $versionId) {
|
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||||
$itemCount++;
|
$chunkSize = max(1, $chunkSize);
|
||||||
|
|
||||||
try {
|
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||||
/** @var PolicyVersion|null $version */
|
foreach ($chunk as $policyVersionId) {
|
||||||
$version = PolicyVersion::withTrashed()
|
dispatch(new PolicyVersionPruneWorkerJob(
|
||||||
->where('tenant_id', $run->tenant_id)
|
tenantId: $this->tenantId,
|
||||||
->whereKey($versionId)
|
userId: $this->userId,
|
||||||
->first();
|
policyVersionId: $policyVersionId,
|
||||||
|
retentionDays: $this->retentionDays,
|
||||||
if (! $version) {
|
operationRun: $this->operationRun,
|
||||||
$service->recordFailure($run, (string) $versionId, 'Policy version not found');
|
context: $this->context,
|
||||||
$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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\BulkOperationRun;
|
use App\Jobs\Operations\PolicyVersionRestoreWorkerJob;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\OperationRun;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\OperationRunService;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Throwable;
|
use RuntimeException;
|
||||||
|
|
||||||
class BulkPolicyVersionRestoreJob implements ShouldQueue
|
class BulkPolicyVersionRestoreJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $policyVersionIds
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
public function __construct(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->start($run);
|
$runs->updateRun($this->operationRun, 'running');
|
||||||
|
|
||||||
$itemCount = 0;
|
$ids = $this->normalizeIds($this->policyVersionIds);
|
||||||
$succeeded = 0;
|
|
||||||
$failed = 0;
|
|
||||||
$skipped = 0;
|
|
||||||
$skipReasons = [];
|
|
||||||
|
|
||||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
|
||||||
$failureThreshold = (int) floor($totalItems / 2);
|
|
||||||
|
|
||||||
foreach (($run->item_ids ?? []) as $versionId) {
|
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||||
$itemCount++;
|
$chunkSize = max(1, $chunkSize);
|
||||||
|
|
||||||
try {
|
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||||
/** @var PolicyVersion|null $version */
|
foreach ($chunk as $policyVersionId) {
|
||||||
$version = PolicyVersion::withTrashed()
|
dispatch(new PolicyVersionRestoreWorkerJob(
|
||||||
->where('tenant_id', $run->tenant_id)
|
tenantId: $this->tenantId,
|
||||||
->whereKey($versionId)
|
userId: $this->userId,
|
||||||
->first();
|
policyVersionId: $policyVersionId,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
if (! $version) {
|
context: $this->context,
|
||||||
$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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\BulkOperationRun;
|
use App\Jobs\Operations\RestoreRunDeleteWorkerJob;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\OperationRunService;
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Throwable;
|
use RuntimeException;
|
||||||
|
|
||||||
class BulkRestoreRunDeleteJob implements ShouldQueue
|
class BulkRestoreRunDeleteJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, mixed> $restoreRunIds
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
public function __construct(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->start($run);
|
$runs->updateRun($this->operationRun, 'running');
|
||||||
|
|
||||||
try {
|
$ids = $this->normalizeIds($this->restoreRunIds);
|
||||||
$itemCount = 0;
|
|
||||||
$succeeded = 0;
|
|
||||||
$failed = 0;
|
|
||||||
$skipped = 0;
|
|
||||||
$skipReasons = [];
|
|
||||||
|
|
||||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
|
||||||
$failureThreshold = (int) floor($totalItems / 2);
|
|
||||||
|
|
||||||
foreach (($run->item_ids ?? []) as $restoreRunId) {
|
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||||
$itemCount++;
|
$chunkSize = max(1, $chunkSize);
|
||||||
|
|
||||||
try {
|
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||||
/** @var RestoreRun|null $restoreRun */
|
foreach ($chunk as $restoreRunId) {
|
||||||
$restoreRun = RestoreRun::withTrashed()
|
dispatch(new RestoreRunDeleteWorkerJob(
|
||||||
->where('tenant_id', $run->tenant_id)
|
tenantId: $this->tenantId,
|
||||||
->whereKey($restoreRunId)
|
userId: $this->userId,
|
||||||
->first();
|
restoreRunId: $restoreRunId,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
if (! $restoreRun) {
|
context: $this->context,
|
||||||
$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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$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;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\BulkOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\RestoreRun;
|
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 Filament\Notifications\Notification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -17,19 +23,41 @@ class BulkRestoreRunForceDeleteJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public function __construct(
|
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') {
|
public function handle(OperationRunService $operationRunService): void
|
||||||
return;
|
{
|
||||||
|
$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;
|
$itemCount = 0;
|
||||||
$succeeded = 0;
|
$succeeded = 0;
|
||||||
@ -37,34 +65,57 @@ public function handle(BulkOperationService $service): void
|
|||||||
$skipped = 0;
|
$skipped = 0;
|
||||||
$skipReasons = [];
|
$skipReasons = [];
|
||||||
|
|
||||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
$failures = [];
|
||||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
|
||||||
|
$totalItems = count($ids);
|
||||||
$failureThreshold = (int) floor($totalItems / 2);
|
$failureThreshold = (int) floor($totalItems / 2);
|
||||||
|
|
||||||
foreach (($run->item_ids ?? []) as $restoreRunId) {
|
foreach ($ids as $restoreRunId) {
|
||||||
$itemCount++;
|
$itemCount++;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
/** @var RestoreRun|null $restoreRun */
|
/** @var RestoreRun|null $restoreRun */
|
||||||
$restoreRun = RestoreRun::withTrashed()
|
$restoreRun = RestoreRun::withTrashed()
|
||||||
->where('tenant_id', $run->tenant_id)
|
->where('tenant_id', $tenant->getKey())
|
||||||
->whereKey($restoreRunId)
|
->whereKey($restoreRunId)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $restoreRun) {
|
if (! $restoreRun) {
|
||||||
$service->recordFailure($run, (string) $restoreRunId, 'Restore run not found');
|
|
||||||
$failed++;
|
$failed++;
|
||||||
|
$failures[] = ['code' => 'restore_run.not_found', 'message' => "Restore run {$restoreRunId} not found."];
|
||||||
|
|
||||||
if ($failed > $failureThreshold) {
|
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()
|
Notification::make()
|
||||||
->title('Bulk Force Delete Aborted')
|
->title('Bulk Force Delete Aborted')
|
||||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
->icon('heroicon-o-exclamation-triangle')
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
->danger()
|
->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();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,7 +126,6 @@ public function handle(BulkOperationService $service): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! $restoreRun->trashed()) {
|
if (! $restoreRun->trashed()) {
|
||||||
$service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Not archived');
|
|
||||||
$skipped++;
|
$skipped++;
|
||||||
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
||||||
|
|
||||||
@ -83,38 +133,76 @@ public function handle(BulkOperationService $service): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
$restoreRun->forceDelete();
|
$restoreRun->forceDelete();
|
||||||
$service->recordSuccess($run);
|
|
||||||
$succeeded++;
|
$succeeded++;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$service->recordFailure($run, (string) $restoreRunId, $e->getMessage());
|
|
||||||
$failed++;
|
$failed++;
|
||||||
|
$failures[] = ['code' => 'restore_run.force_delete.failed', 'message' => $e->getMessage()];
|
||||||
|
|
||||||
if ($failed > $failureThreshold) {
|
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()
|
Notification::make()
|
||||||
->title('Bulk Force Delete Aborted')
|
->title('Bulk Force Delete Aborted')
|
||||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
->icon('heroicon-o-exclamation-triangle')
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
->danger()
|
->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();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($itemCount % $chunkSize === 0) {
|
|
||||||
$run->refresh();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->complete($run);
|
$outcome = OperationRunOutcome::Succeeded->value;
|
||||||
|
|
||||||
if (! $run->user) {
|
if ($failed > 0 && $failed < $totalItems) {
|
||||||
return;
|
$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";
|
$message = "Force deleted {$succeeded} restore runs";
|
||||||
@ -144,7 +232,12 @@ public function handle(BulkOperationService $service): void
|
|||||||
->body($message)
|
->body($message)
|
||||||
->icon('heroicon-o-check-circle')
|
->icon('heroicon-o-check-circle')
|
||||||
->success()
|
->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();
|
->send();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,9 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\BulkOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\RestoreRun;
|
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 Filament\Notifications\Notification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -17,19 +23,41 @@ class BulkRestoreRunRestoreJob implements ShouldQueue
|
|||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
public function __construct(
|
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') {
|
public function handle(OperationRunService $operationRunService): void
|
||||||
return;
|
{
|
||||||
|
$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;
|
$itemCount = 0;
|
||||||
$succeeded = 0;
|
$succeeded = 0;
|
||||||
@ -37,34 +65,56 @@ public function handle(BulkOperationService $service): void
|
|||||||
$skipped = 0;
|
$skipped = 0;
|
||||||
$skipReasons = [];
|
$skipReasons = [];
|
||||||
|
|
||||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
$failures = [];
|
||||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
|
||||||
|
$totalItems = count($ids);
|
||||||
$failureThreshold = (int) floor($totalItems / 2);
|
$failureThreshold = (int) floor($totalItems / 2);
|
||||||
|
|
||||||
foreach (($run->item_ids ?? []) as $restoreRunId) {
|
foreach ($ids as $restoreRunId) {
|
||||||
$itemCount++;
|
$itemCount++;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
/** @var RestoreRun|null $restoreRun */
|
/** @var RestoreRun|null $restoreRun */
|
||||||
$restoreRun = RestoreRun::withTrashed()
|
$restoreRun = RestoreRun::withTrashed()
|
||||||
->where('tenant_id', $run->tenant_id)
|
->where('tenant_id', $tenant->getKey())
|
||||||
->whereKey($restoreRunId)
|
->whereKey($restoreRunId)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $restoreRun) {
|
if (! $restoreRun) {
|
||||||
$service->recordFailure($run, (string) $restoreRunId, 'Restore run not found');
|
|
||||||
$failed++;
|
$failed++;
|
||||||
|
$failures[] = ['code' => 'restore_run.not_found', 'message' => "Restore run {$restoreRunId} not found."];
|
||||||
|
|
||||||
if ($failed > $failureThreshold) {
|
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()
|
Notification::make()
|
||||||
->title('Bulk Restore Aborted')
|
->title('Bulk Restore Aborted')
|
||||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
->icon('heroicon-o-exclamation-triangle')
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
->danger()
|
->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();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -75,7 +125,6 @@ public function handle(BulkOperationService $service): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (! $restoreRun->trashed()) {
|
if (! $restoreRun->trashed()) {
|
||||||
$service->recordSkippedWithReason($run, (string) $restoreRun->id, 'Not archived');
|
|
||||||
$skipped++;
|
$skipped++;
|
||||||
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
$skipReasons['Not archived'] = ($skipReasons['Not archived'] ?? 0) + 1;
|
||||||
|
|
||||||
@ -83,38 +132,74 @@ public function handle(BulkOperationService $service): void
|
|||||||
}
|
}
|
||||||
|
|
||||||
$restoreRun->restore();
|
$restoreRun->restore();
|
||||||
$service->recordSuccess($run);
|
|
||||||
$succeeded++;
|
$succeeded++;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$service->recordFailure($run, (string) $restoreRunId, $e->getMessage());
|
|
||||||
$failed++;
|
$failed++;
|
||||||
|
$failures[] = ['code' => 'restore_run.restore.failed', 'message' => $e->getMessage()];
|
||||||
|
|
||||||
if ($failed > $failureThreshold) {
|
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()
|
Notification::make()
|
||||||
->title('Bulk Restore Aborted')
|
->title('Bulk Restore Aborted')
|
||||||
->body('Circuit breaker triggered: too many failures (>50%).')
|
->body('Circuit breaker triggered: too many failures (>50%).')
|
||||||
->icon('heroicon-o-exclamation-triangle')
|
->icon('heroicon-o-exclamation-triangle')
|
||||||
->danger()
|
->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();
|
->send();
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($itemCount % $chunkSize === 0) {
|
|
||||||
$run->refresh();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->complete($run);
|
$outcome = OperationRunOutcome::Succeeded->value;
|
||||||
|
|
||||||
if (! $run->user) {
|
if ($failed > 0 && $failed < $totalItems) {
|
||||||
return;
|
$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";
|
$message = "Restored {$succeeded} restore runs";
|
||||||
@ -144,7 +229,12 @@ public function handle(BulkOperationService $service): void
|
|||||||
->body($message)
|
->body($message)
|
||||||
->icon('heroicon-o-check-circle')
|
->icon('heroicon-o-check-circle')
|
||||||
->success()
|
->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();
|
->send();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,151 +2,92 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\BulkOperationRun;
|
use App\Jobs\Operations\TenantSyncWorkerJob;
|
||||||
use App\Models\Tenant;
|
use App\Models\OperationRun;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\Intune\PolicySyncService;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Throwable;
|
use RuntimeException;
|
||||||
|
|
||||||
class BulkTenantSyncJob implements ShouldQueue
|
class BulkTenantSyncJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$service->start($run);
|
$runs->updateRun($this->operationRun, 'running');
|
||||||
|
|
||||||
try {
|
$ids = $this->normalizeIds($this->tenantIds);
|
||||||
$chunkSize = max(1, (int) config('tenantpilot.bulk_operations.chunk_size', 10));
|
|
||||||
$itemCount = 0;
|
|
||||||
|
|
||||||
$supported = config('tenantpilot.supported_policy_types');
|
$runs->incrementSummaryCounts($this->operationRun, ['total' => count($ids)]);
|
||||||
|
|
||||||
$totalItems = $run->total_items ?: count($run->item_ids ?? []);
|
$chunkSize = (int) config('tenantpilot.bulk_operations.chunk_size', 10);
|
||||||
$failureThreshold = (int) floor($totalItems / 2);
|
$chunkSize = max(1, $chunkSize);
|
||||||
|
|
||||||
foreach (($run->item_ids ?? []) as $tenantId) {
|
foreach (array_chunk($ids, $chunkSize) as $chunk) {
|
||||||
$itemCount++;
|
foreach ($chunk as $targetTenantId) {
|
||||||
|
dispatch(new TenantSyncWorkerJob(
|
||||||
try {
|
tenantId: $targetTenantId,
|
||||||
$tenant = Tenant::query()->whereKey($tenantId)->first();
|
userId: $this->userId,
|
||||||
|
operationRun: $this->operationRun,
|
||||||
if (! $tenant) {
|
context: $this->context,
|
||||||
$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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$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;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Models\BulkOperationRun;
|
use App\Jobs\Operations\CapturePolicySnapshotWorkerJob;
|
||||||
use App\Models\Policy;
|
use App\Models\OperationRun;
|
||||||
use App\Notifications\RunStatusChangedNotification;
|
use App\Services\OperationRunService;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Intune\VersionService;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
use Throwable;
|
use RuntimeException;
|
||||||
|
|
||||||
class CapturePolicySnapshotJob implements ShouldQueue
|
class CapturePolicySnapshotJob implements ShouldQueue
|
||||||
{
|
{
|
||||||
@ -21,87 +19,48 @@ class CapturePolicySnapshotJob implements ShouldQueue
|
|||||||
use Queueable;
|
use Queueable;
|
||||||
use SerializesModels;
|
use SerializesModels;
|
||||||
|
|
||||||
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $bulkOperationRunId,
|
public int $tenantId,
|
||||||
|
public int $userId,
|
||||||
public int $policyId,
|
public int $policyId,
|
||||||
public bool $includeAssignments = true,
|
public bool $includeAssignments = true,
|
||||||
public bool $includeScopeTags = true,
|
public bool $includeScopeTags = true,
|
||||||
public ?string $createdBy = null,
|
public ?string $createdBy = null,
|
||||||
) {}
|
?OperationRun $operationRun = null,
|
||||||
|
public array $context = [],
|
||||||
public function handle(BulkOperationService $bulkOperationService, VersionService $versionService): void
|
) {
|
||||||
{
|
$this->operationRun = $operationRun;
|
||||||
$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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function notifyStatus(BulkOperationRun $run, string $status): void
|
public function handle(OperationRunService $runs): void
|
||||||
{
|
{
|
||||||
if (! $run->relationLoaded('user')) {
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
$run->loadMissing('user');
|
throw new RuntimeException('OperationRun is required for CapturePolicySnapshotJob.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $run->user) {
|
$this->operationRun->refresh();
|
||||||
|
|
||||||
|
if ($this->operationRun->status === 'completed') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$normalizedStatus = $status === 'pending' ? 'queued' : $status;
|
$runs->updateRun($this->operationRun, 'running');
|
||||||
|
$runs->incrementSummaryCounts($this->operationRun, ['total' => 1]);
|
||||||
|
|
||||||
$run->user->notify(new RunStatusChangedNotification([
|
dispatch(new CapturePolicySnapshotWorkerJob(
|
||||||
'tenant_id' => (int) $run->tenant_id,
|
tenantId: $this->tenantId,
|
||||||
'run_type' => 'bulk_operation',
|
userId: $this->userId,
|
||||||
'run_id' => (int) $run->getKey(),
|
policyId: $this->policyId,
|
||||||
'status' => (string) $normalizedStatus,
|
includeAssignments: $this->includeAssignments,
|
||||||
'counts' => [
|
includeScopeTags: $this->includeScopeTags,
|
||||||
'total' => (int) $run->total_items,
|
createdBy: $this->createdBy,
|
||||||
'processed' => (int) $run->processed_items,
|
operationRun: $this->operationRun,
|
||||||
'succeeded' => (int) $run->succeeded,
|
context: $this->context,
|
||||||
'failed' => (int) $run->failed,
|
));
|
||||||
'skipped' => (int) $run->skipped,
|
|
||||||
],
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,9 @@
|
|||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Notifications\RunStatusChangedNotification;
|
use App\Notifications\RunStatusChangedNotification;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\RestoreService;
|
use App\Services\Intune\RestoreService;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
use App\Support\RestoreRunStatus;
|
use App\Support\RestoreRunStatus;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
@ -28,7 +28,7 @@ public function __construct(
|
|||||||
public ?string $actorName = null,
|
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);
|
$restoreRun = RestoreRun::with(['tenant', 'backupSet'])->find($this->restoreRunId);
|
||||||
|
|
||||||
@ -122,7 +122,7 @@ public function handle(RestoreService $restoreService, AuditLogger $auditLogger,
|
|||||||
} catch (Throwable $throwable) {
|
} catch (Throwable $throwable) {
|
||||||
$restoreRun->refresh();
|
$restoreRun->refresh();
|
||||||
|
|
||||||
$safeReason = $bulkOperationService->sanitizeFailureReason($throwable->getMessage());
|
$safeReason = RunFailureSanitizer::sanitizeMessage($throwable->getMessage());
|
||||||
|
|
||||||
if ($restoreRun->status === RestoreRunStatus::Running->value) {
|
if ($restoreRun->status === RestoreRunStatus::Running->value) {
|
||||||
$restoreRun->update([
|
$restoreRun->update([
|
||||||
|
|||||||
@ -2,17 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
|
||||||
use App\Models\BulkOperationRun;
|
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Drift\DriftFindingGenerator;
|
use App\Services\Drift\DriftFindingGenerator;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Services\Operations\TargetScopeConcurrencyLimiter;
|
||||||
use Filament\Actions\Action;
|
|
||||||
use Filament\Notifications\Notification;
|
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@ -28,62 +23,78 @@ class GenerateDriftFindingsJob implements ShouldQueue
|
|||||||
|
|
||||||
public ?OperationRun $operationRun = null;
|
public ?OperationRun $operationRun = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*/
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $tenantId,
|
public int $tenantId,
|
||||||
public int $userId,
|
public int $userId,
|
||||||
public int $baselineRunId,
|
public int $baselineRunId,
|
||||||
public int $currentRunId,
|
public int $currentRunId,
|
||||||
public string $scopeKey,
|
public string $scopeKey,
|
||||||
public int $bulkOperationRunId,
|
?OperationRun $operationRun = null,
|
||||||
?OperationRun $operationRun = null
|
public array $context = [],
|
||||||
) {
|
) {
|
||||||
$this->operationRun = $operationRun;
|
$this->operationRun = $operationRun;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function middleware(): array
|
public function handle(
|
||||||
{
|
DriftFindingGenerator $generator,
|
||||||
return [new TrackOperationRun];
|
OperationRunService $runs,
|
||||||
}
|
TargetScopeConcurrencyLimiter $limiter,
|
||||||
|
): void {
|
||||||
/**
|
|
||||||
* Execute the job.
|
|
||||||
*/
|
|
||||||
public function handle(DriftFindingGenerator $generator, BulkOperationService $bulkOperationService): void
|
|
||||||
{
|
|
||||||
Log::info('GenerateDriftFindingsJob: started', [
|
Log::info('GenerateDriftFindingsJob: started', [
|
||||||
'tenant_id' => $this->tenantId,
|
'tenant_id' => $this->tenantId,
|
||||||
'baseline_run_id' => $this->baselineRunId,
|
'baseline_run_id' => $this->baselineRunId,
|
||||||
'current_run_id' => $this->currentRunId,
|
'current_run_id' => $this->currentRunId,
|
||||||
'scope_key' => $this->scopeKey,
|
'scope_key' => $this->scopeKey,
|
||||||
'bulk_operation_run_id' => $this->bulkOperationRunId,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$tenant = Tenant::query()->find($this->tenantId);
|
if (! $this->operationRun instanceof OperationRun) {
|
||||||
if (! $tenant instanceof Tenant) {
|
throw new RuntimeException('OperationRun is required for drift generation.');
|
||||||
throw new RuntimeException('Tenant not found.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseline = InventorySyncRun::query()->find($this->baselineRunId);
|
$this->operationRun->refresh();
|
||||||
if (! $baseline instanceof InventorySyncRun) {
|
|
||||||
throw new RuntimeException('Baseline run not found.');
|
if ($this->operationRun->status === 'completed') {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$current = InventorySyncRun::query()->find($this->currentRunId);
|
$opContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||||
if (! $current instanceof InventorySyncRun) {
|
$targetScope = is_array($opContext['target_scope'] ?? null) ? $opContext['target_scope'] : [];
|
||||||
throw new RuntimeException('Current run not found.');
|
|
||||||
|
$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 {
|
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(
|
$created = $generator->generate(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
baseline: $baseline,
|
baseline: $baseline,
|
||||||
@ -96,118 +107,40 @@ public function handle(DriftFindingGenerator $generator, BulkOperationService $b
|
|||||||
'baseline_run_id' => $this->baselineRunId,
|
'baseline_run_id' => $this->baselineRunId,
|
||||||
'current_run_id' => $this->currentRunId,
|
'current_run_id' => $this->currentRunId,
|
||||||
'scope_key' => $this->scopeKey,
|
'scope_key' => $this->scopeKey,
|
||||||
'bulk_operation_run_id' => $this->bulkOperationRunId,
|
|
||||||
'created_findings_count' => $created,
|
'created_findings_count' => $created,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$bulkOperationService->recordSuccess($run);
|
$runs->incrementSummaryCounts($this->operationRun, [
|
||||||
$bulkOperationService->complete($run);
|
'processed' => 1,
|
||||||
|
'succeeded' => 1,
|
||||||
|
'created' => $created,
|
||||||
|
]);
|
||||||
|
|
||||||
if ($this->operationRun) {
|
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||||
/** @var OperationRunService $opService */
|
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opService->updateRun(
|
|
||||||
$this->operationRun,
|
|
||||||
'completed',
|
|
||||||
'succeeded',
|
|
||||||
['findings_created' => $created]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->notifyStatus($run->refresh());
|
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
Log::error('GenerateDriftFindingsJob: failed', [
|
Log::error('GenerateDriftFindingsJob: failed', [
|
||||||
'tenant_id' => $this->tenantId,
|
'tenant_id' => $this->tenantId,
|
||||||
'baseline_run_id' => $this->baselineRunId,
|
'baseline_run_id' => $this->baselineRunId,
|
||||||
'current_run_id' => $this->currentRunId,
|
'current_run_id' => $this->currentRunId,
|
||||||
'scope_key' => $this->scopeKey,
|
'scope_key' => $this->scopeKey,
|
||||||
'bulk_operation_run_id' => $this->bulkOperationRunId,
|
|
||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$bulkOperationService->recordFailure(
|
$runs->incrementSummaryCounts($this->operationRun, [
|
||||||
run: $run,
|
'processed' => 1,
|
||||||
itemId: $this->scopeKey,
|
'failed' => 1,
|
||||||
reason: $e->getMessage(),
|
]);
|
||||||
reasonCode: 'unknown',
|
|
||||||
);
|
|
||||||
|
|
||||||
$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
|
$runs->maybeCompleteBulkRun($this->operationRun);
|
||||||
if ($this->operationRun) {
|
|
||||||
/** @var OperationRunService $opService */
|
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opService->failRun($this->operationRun, $e);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->notifyStatus($run->refresh());
|
|
||||||
|
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
} finally {
|
||||||
}
|
$lock->release();
|
||||||
|
|
||||||
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(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -48,7 +48,6 @@ public function middleware(): array
|
|||||||
|
|
||||||
public function handle(
|
public function handle(
|
||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
BulkOperationService $bulkOperationService,
|
|
||||||
): void {
|
): void {
|
||||||
$backupSet = BackupSet::query()->with(['tenant'])->find($this->backupSetId);
|
$backupSet = BackupSet::query()->with(['tenant'])->find($this->backupSetId);
|
||||||
|
|
||||||
@ -60,7 +59,7 @@ public function handle(
|
|||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
'completed',
|
'completed',
|
||||||
'failed',
|
'failed',
|
||||||
['backup_set_id' => $this->backupSetId],
|
['failed' => 1],
|
||||||
[['code' => 'backup_set.not_found', 'message' => 'Backup set not found.']]
|
[['code' => 'backup_set.not_found', 'message' => 'Backup set not found.']]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -97,7 +96,7 @@ public function handle(
|
|||||||
foreach ($missingIds as $missingId) {
|
foreach ($missingIds as $missingId) {
|
||||||
$failures[] = [
|
$failures[] = [
|
||||||
'code' => 'backup_item.not_found',
|
'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 */
|
/** @var OperationRunService $opService */
|
||||||
$opService = app(OperationRunService::class);
|
$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';
|
$outcome = 'succeeded';
|
||||||
if ($removed === 0) {
|
if ($removed === 0) {
|
||||||
$outcome = 'failed';
|
$outcome = 'failed';
|
||||||
@ -160,11 +169,12 @@ public function handle(
|
|||||||
'completed',
|
'completed',
|
||||||
$outcome,
|
$outcome,
|
||||||
[
|
[
|
||||||
'backup_set_id' => (int) $backupSet->getKey(),
|
'total' => $requestedCount,
|
||||||
'requested' => $requestedCount,
|
'processed' => $requestedCount,
|
||||||
'removed' => $removed,
|
'succeeded' => $removed,
|
||||||
'missing' => count($missingIds),
|
'failed' => count($missingIds),
|
||||||
'remaining' => (int) $backupSet->item_count,
|
'deleted' => $removed,
|
||||||
|
'items' => $requestedCount,
|
||||||
],
|
],
|
||||||
$failures,
|
$failures,
|
||||||
);
|
);
|
||||||
@ -205,7 +215,7 @@ public function handle(
|
|||||||
$this->notifyFailed(
|
$this->notifyFailed(
|
||||||
initiator: $initiator,
|
initiator: $initiator,
|
||||||
tenant: $tenant instanceof Tenant ? $tenant : null,
|
tenant: $tenant instanceof Tenant ? $tenant : null,
|
||||||
reason: $bulkOperationService->sanitizeFailureReason($throwable->getMessage()),
|
reason: RunFailureSanitizer::sanitizeMessage($throwable->getMessage()),
|
||||||
);
|
);
|
||||||
|
|
||||||
throw $throwable;
|
throw $throwable;
|
||||||
|
|||||||
@ -5,13 +5,11 @@
|
|||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\BackupScheduleRun;
|
use App\Models\BackupScheduleRun;
|
||||||
use App\Models\BulkOperationRun;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||||
use App\Services\BackupScheduling\RunErrorMapper;
|
use App\Services\BackupScheduling\RunErrorMapper;
|
||||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Intune\BackupService;
|
use App\Services\Intune\BackupService;
|
||||||
use App\Services\Intune\PolicySyncService;
|
use App\Services\Intune\PolicySyncService;
|
||||||
@ -39,7 +37,6 @@ class RunBackupScheduleJob implements ShouldQueue
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
public int $backupScheduleRunId,
|
public int $backupScheduleRunId,
|
||||||
public ?int $bulkRunId = null,
|
|
||||||
?OperationRun $operationRun = null,
|
?OperationRun $operationRun = null,
|
||||||
) {
|
) {
|
||||||
$this->operationRun = $operationRun;
|
$this->operationRun = $operationRun;
|
||||||
@ -57,7 +54,6 @@ public function handle(
|
|||||||
ScheduleTimeService $scheduleTimeService,
|
ScheduleTimeService $scheduleTimeService,
|
||||||
AuditLogger $auditLogger,
|
AuditLogger $auditLogger,
|
||||||
RunErrorMapper $errorMapper,
|
RunErrorMapper $errorMapper,
|
||||||
BulkOperationService $bulkOperationService,
|
|
||||||
): void {
|
): void {
|
||||||
$run = BackupScheduleRun::query()
|
$run = BackupScheduleRun::query()
|
||||||
->with(['schedule', 'tenant', 'user'])
|
->with(['schedule', 'tenant', 'user'])
|
||||||
@ -67,9 +63,8 @@ public function handle(
|
|||||||
if ($this->operationRun) {
|
if ($this->operationRun) {
|
||||||
$this->markOperationRunFailed(
|
$this->markOperationRunFailed(
|
||||||
run: $this->operationRun,
|
run: $this->operationRun,
|
||||||
bulkOperationService: $bulkOperationService,
|
|
||||||
summaryCounts: [],
|
summaryCounts: [],
|
||||||
reasonCode: 'RUN_NOT_FOUND',
|
reasonCode: 'run_not_found',
|
||||||
reason: 'Backup schedule 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;
|
$schedule = $run->schedule;
|
||||||
|
|
||||||
if (! $schedule instanceof BackupSchedule) {
|
if (! $schedule instanceof BackupSchedule) {
|
||||||
@ -127,12 +107,12 @@ public function handle(
|
|||||||
if ($this->operationRun) {
|
if ($this->operationRun) {
|
||||||
$this->markOperationRunFailed(
|
$this->markOperationRunFailed(
|
||||||
run: $this->operationRun,
|
run: $this->operationRun,
|
||||||
bulkOperationService: $bulkOperationService,
|
|
||||||
summaryCounts: [
|
summaryCounts: [
|
||||||
'backup_schedule_id' => (int) $run->backup_schedule_id,
|
'total' => 0,
|
||||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
'processed' => 0,
|
||||||
|
'failed' => 1,
|
||||||
],
|
],
|
||||||
reasonCode: 'SCHEDULE_NOT_FOUND',
|
reasonCode: 'schedule_not_found',
|
||||||
reason: 'Schedule not found.',
|
reason: 'Schedule not found.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -151,12 +131,12 @@ public function handle(
|
|||||||
if ($this->operationRun) {
|
if ($this->operationRun) {
|
||||||
$this->markOperationRunFailed(
|
$this->markOperationRunFailed(
|
||||||
run: $this->operationRun,
|
run: $this->operationRun,
|
||||||
bulkOperationService: $bulkOperationService,
|
|
||||||
summaryCounts: [
|
summaryCounts: [
|
||||||
'backup_schedule_id' => (int) $run->backup_schedule_id,
|
'total' => 0,
|
||||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
'processed' => 0,
|
||||||
|
'failed' => 1,
|
||||||
],
|
],
|
||||||
reasonCode: 'TENANT_NOT_FOUND',
|
reasonCode: 'tenant_not_found',
|
||||||
reason: 'Tenant not found.',
|
reason: 'Tenant not found.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -175,14 +155,12 @@ public function handle(
|
|||||||
errorMessage: 'Another run is already in progress for this schedule.',
|
errorMessage: 'Another run is already in progress for this schedule.',
|
||||||
summary: ['reason' => 'concurrent_run'],
|
summary: ['reason' => 'concurrent_run'],
|
||||||
scheduleTimeService: $scheduleTimeService,
|
scheduleTimeService: $scheduleTimeService,
|
||||||
bulkRunId: $this->bulkRunId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->syncOperationRunFromRun(
|
$this->syncOperationRunFromRun(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
schedule: $schedule,
|
schedule: $schedule,
|
||||||
run: $run->refresh(),
|
run: $run->refresh(),
|
||||||
bulkOperationService: $bulkOperationService,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -228,14 +206,12 @@ public function handle(
|
|||||||
'unknown_policy_types' => $unknownTypes,
|
'unknown_policy_types' => $unknownTypes,
|
||||||
],
|
],
|
||||||
scheduleTimeService: $scheduleTimeService,
|
scheduleTimeService: $scheduleTimeService,
|
||||||
bulkRunId: $this->bulkRunId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->syncOperationRunFromRun(
|
$this->syncOperationRunFromRun(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
schedule: $schedule,
|
schedule: $schedule,
|
||||||
run: $run->refresh(),
|
run: $run->refresh(),
|
||||||
bulkOperationService: $bulkOperationService,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -294,14 +270,12 @@ public function handle(
|
|||||||
summary: $summary,
|
summary: $summary,
|
||||||
scheduleTimeService: $scheduleTimeService,
|
scheduleTimeService: $scheduleTimeService,
|
||||||
backupSetId: (string) $backupSet->id,
|
backupSetId: (string) $backupSet->id,
|
||||||
bulkRunId: $this->bulkRunId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->syncOperationRunFromRun(
|
$this->syncOperationRunFromRun(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
schedule: $schedule,
|
schedule: $schedule,
|
||||||
run: $run->refresh(),
|
run: $run->refresh(),
|
||||||
bulkOperationService: $bulkOperationService,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
@ -346,14 +320,12 @@ public function handle(
|
|||||||
'attempt' => $attempt,
|
'attempt' => $attempt,
|
||||||
],
|
],
|
||||||
scheduleTimeService: $scheduleTimeService,
|
scheduleTimeService: $scheduleTimeService,
|
||||||
bulkRunId: $this->bulkRunId,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->syncOperationRunFromRun(
|
$this->syncOperationRunFromRun(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
schedule: $schedule,
|
schedule: $schedule,
|
||||||
run: $run->refresh(),
|
run: $run->refresh(),
|
||||||
bulkOperationService: $bulkOperationService,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$auditLogger->log(
|
$auditLogger->log(
|
||||||
@ -438,7 +410,6 @@ private function syncOperationRunFromRun(
|
|||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
BackupSchedule $schedule,
|
BackupSchedule $schedule,
|
||||||
BackupScheduleRun $run,
|
BackupScheduleRun $run,
|
||||||
BulkOperationService $bulkOperationService,
|
|
||||||
): void {
|
): void {
|
||||||
if (! $this->operationRun) {
|
if (! $this->operationRun) {
|
||||||
return;
|
return;
|
||||||
@ -457,23 +428,29 @@ private function syncOperationRunFromRun(
|
|||||||
$summary = is_array($run->summary) ? $run->summary : [];
|
$summary = is_array($run->summary) ? $run->summary : [];
|
||||||
$syncFailures = $summary['sync_failures'] ?? [];
|
$syncFailures = $summary['sync_failures'] ?? [];
|
||||||
|
|
||||||
$summaryCounts = [
|
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
||||||
'backup_schedule_id' => (int) $schedule->getKey(),
|
$policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0);
|
||||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
$syncFailureCount = is_array($syncFailures) ? count($syncFailures) : 0;
|
||||||
'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,
|
|
||||||
];
|
|
||||||
|
|
||||||
$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 = [];
|
$failures = [];
|
||||||
|
|
||||||
if (filled($run->error_message) || filled($run->error_code)) {
|
if (filled($run->error_message) || filled($run->error_code)) {
|
||||||
$failures[] = [
|
$failures[] = [
|
||||||
'code' => (string) ($run->error_code ?: 'BACKUP_SCHEDULE_ERROR'),
|
'code' => strtolower((string) ($run->error_code ?: 'backup_schedule_error')),
|
||||||
'message' => $bulkOperationService->sanitizeFailureReason((string) ($run->error_message ?: 'Backup schedule run failed.')),
|
'message' => (string) ($run->error_message ?: 'Backup schedule run failed.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -501,8 +478,8 @@ private function syncOperationRunFromRun(
|
|||||||
}
|
}
|
||||||
|
|
||||||
$failures[] = [
|
$failures[] = [
|
||||||
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR',
|
'code' => $status !== null ? 'graph_http_'.(string) $status : 'graph_error',
|
||||||
'message' => $bulkOperationService->sanitizeFailureReason($message),
|
'message' => $message,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -514,6 +491,7 @@ private function syncOperationRunFromRun(
|
|||||||
'context' => array_merge($this->operationRun->context ?? [], [
|
'context' => array_merge($this->operationRun->context ?? [], [
|
||||||
'backup_schedule_id' => (int) $schedule->getKey(),
|
'backup_schedule_id' => (int) $schedule->getKey(),
|
||||||
'backup_schedule_run_id' => (int) $run->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(
|
private function markOperationRunFailed(
|
||||||
OperationRun $run,
|
OperationRun $run,
|
||||||
BulkOperationService $bulkOperationService,
|
|
||||||
array $summaryCounts,
|
array $summaryCounts,
|
||||||
string $reasonCode,
|
string $reasonCode,
|
||||||
string $reason,
|
string $reason,
|
||||||
@ -544,7 +521,7 @@ private function markOperationRunFailed(
|
|||||||
failures: [
|
failures: [
|
||||||
[
|
[
|
||||||
'code' => $reasonCode,
|
'code' => $reasonCode,
|
||||||
'message' => $bulkOperationService->sanitizeFailureReason($reason),
|
'message' => $reason,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@ -578,7 +555,6 @@ private function finishRun(
|
|||||||
array $summary,
|
array $summary,
|
||||||
ScheduleTimeService $scheduleTimeService,
|
ScheduleTimeService $scheduleTimeService,
|
||||||
?string $backupSetId = null,
|
?string $backupSetId = null,
|
||||||
?int $bulkRunId = null,
|
|
||||||
): void {
|
): void {
|
||||||
$nowUtc = CarbonImmutable::now('UTC');
|
$nowUtc = CarbonImmutable::now('UTC');
|
||||||
|
|
||||||
@ -599,48 +575,6 @@ private function finishRun(
|
|||||||
|
|
||||||
$this->notifyRunFinished($run, $schedule);
|
$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)) {
|
if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) {
|
||||||
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
|
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,16 +3,16 @@
|
|||||||
namespace App\Jobs;
|
namespace App\Jobs;
|
||||||
|
|
||||||
use App\Jobs\Middleware\TrackOperationRun;
|
use App\Jobs\Middleware\TrackOperationRun;
|
||||||
use App\Models\BulkOperationRun;
|
|
||||||
use App\Models\InventorySyncRun;
|
use App\Models\InventorySyncRun;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Services\Inventory\InventorySyncService;
|
use App\Services\Inventory\InventorySyncService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
@ -33,7 +33,6 @@ class RunInventorySyncJob implements ShouldQueue
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
public int $tenantId,
|
public int $tenantId,
|
||||||
public int $userId,
|
public int $userId,
|
||||||
public int $bulkRunId,
|
|
||||||
public int $inventorySyncRunId,
|
public int $inventorySyncRunId,
|
||||||
?OperationRun $operationRun = null
|
?OperationRun $operationRun = null
|
||||||
) {
|
) {
|
||||||
@ -53,7 +52,7 @@ public function middleware(): array
|
|||||||
/**
|
/**
|
||||||
* Execute the job.
|
* 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);
|
$tenant = Tenant::query()->find($this->tenantId);
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
@ -65,19 +64,19 @@ public function handle(BulkOperationService $bulkOperationService, InventorySync
|
|||||||
throw new RuntimeException('User not found.');
|
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);
|
$run = InventorySyncRun::query()->find($this->inventorySyncRunId);
|
||||||
if (! $run instanceof InventorySyncRun) {
|
if (! $run instanceof InventorySyncRun) {
|
||||||
throw new RuntimeException('InventorySyncRun not found.');
|
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 = [];
|
$processedPolicyTypes = [];
|
||||||
|
$successCount = 0;
|
||||||
|
$failedCount = 0;
|
||||||
|
|
||||||
// Note: The TrackOperationRun middleware will automatically set status to 'running' at start.
|
// Note: The TrackOperationRun middleware will automatically set status to 'running' at start.
|
||||||
// It will also handle success completion if no exceptions thrown.
|
// It will also handle success completion if no exceptions thrown.
|
||||||
@ -87,48 +86,36 @@ public function handle(BulkOperationService $bulkOperationService, InventorySync
|
|||||||
$run = $inventorySyncService->executePendingRun(
|
$run = $inventorySyncService->executePendingRun(
|
||||||
$run,
|
$run,
|
||||||
$tenant,
|
$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;
|
$processedPolicyTypes[] = $policyType;
|
||||||
|
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$bulkOperationService->recordSuccess($bulkRun);
|
$successCount++;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$bulkOperationService->recordFailure($bulkRun, $policyType, $errorCode ?? 'failed');
|
$failedCount++;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
$policyTypes = is_array($bulkRun->item_ids ?? null) ? $bulkRun->item_ids : [];
|
if ($run->status === InventorySyncRun::STATUS_SUCCESS) {
|
||||||
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 ($this->operationRun) {
|
if ($this->operationRun) {
|
||||||
/** @var OperationRunService $opService */
|
$operationRunService->updateRun(
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opService->updateRun(
|
|
||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
'completed',
|
status: OperationRunStatus::Completed->value,
|
||||||
$outcome,
|
outcome: OperationRunOutcome::Succeeded->value,
|
||||||
$counts,
|
summaryCounts: [
|
||||||
$failures
|
'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(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -136,7 +123,6 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
|||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'inventory_sync_run_id' => $run->id,
|
'inventory_sync_run_id' => $run->id,
|
||||||
'bulk_run_id' => $bulkRun->id,
|
|
||||||
'selection_hash' => $run->selection_hash,
|
'selection_hash' => $run->selection_hash,
|
||||||
'observed' => $run->items_observed_count,
|
'observed' => $run->items_observed_count,
|
||||||
'upserted' => $run->items_upserted_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) {
|
if ($run->status === InventorySyncRun::STATUS_PARTIAL) {
|
||||||
$bulkOperationService->complete($bulkRun);
|
if ($this->operationRun) {
|
||||||
|
$operationRunService->updateRun(
|
||||||
$updateOpRun('partially_succeeded', [
|
$this->operationRun,
|
||||||
'observed' => $run->items_observed_count,
|
status: OperationRunStatus::Completed->value,
|
||||||
'upserted' => $run->items_upserted_count,
|
outcome: OperationRunOutcome::PartiallySucceeded->value,
|
||||||
'errors' => $run->errors_count,
|
summaryCounts: [
|
||||||
], [
|
'total' => count($policyTypes),
|
||||||
// Minimal error summary
|
'processed' => count($policyTypes),
|
||||||
['code' => 'PARTIAL_SYNC', 'message' => "Errors: {$run->errors_count}"],
|
'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(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -182,7 +176,6 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
|||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'inventory_sync_run_id' => $run->id,
|
'inventory_sync_run_id' => $run->id,
|
||||||
'bulk_run_id' => $bulkRun->id,
|
|
||||||
'selection_hash' => $run->selection_hash,
|
'selection_hash' => $run->selection_hash,
|
||||||
'observed' => $run->items_observed_count,
|
'observed' => $run->items_observed_count,
|
||||||
'upserted' => $run->items_upserted_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) {
|
if ($run->status === InventorySyncRun::STATUS_SKIPPED) {
|
||||||
$reason = (string) (($run->error_codes ?? [])[0] ?? 'skipped');
|
$reason = (string) (($run->error_codes ?? [])[0] ?? 'skipped');
|
||||||
|
|
||||||
foreach ($policyTypes as $policyType) {
|
if ($this->operationRun) {
|
||||||
$bulkOperationService->recordSkippedWithReason($bulkRun, (string) $policyType, $reason);
|
$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(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
@ -228,7 +232,6 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
|||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'inventory_sync_run_id' => $run->id,
|
'inventory_sync_run_id' => $run->id,
|
||||||
'bulk_run_id' => $bulkRun->id,
|
|
||||||
'selection_hash' => $run->selection_hash,
|
'selection_hash' => $run->selection_hash,
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
],
|
],
|
||||||
@ -258,21 +261,30 @@ function (string $policyType, bool $success, ?string $errorCode) use ($bulkOpera
|
|||||||
$reason = (string) (($run->error_codes ?? [])[0] ?? 'failed');
|
$reason = (string) (($run->error_codes ?? [])[0] ?? 'failed');
|
||||||
|
|
||||||
$missingPolicyTypes = array_values(array_diff($policyTypes, array_unique($processedPolicyTypes)));
|
$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(
|
$auditLogger->log(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
action: 'inventory.sync.failed',
|
action: 'inventory.sync.failed',
|
||||||
context: [
|
context: [
|
||||||
'metadata' => [
|
'metadata' => [
|
||||||
'inventory_sync_run_id' => $run->id,
|
'inventory_sync_run_id' => $run->id,
|
||||||
'bulk_run_id' => $bulkRun->id,
|
|
||||||
'selection_hash' => $run->selection_hash,
|
'selection_hash' => $run->selection_hash,
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -6,9 +6,10 @@
|
|||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Intune\PolicySyncService;
|
use App\Services\Intune\PolicySyncService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Support\OperationRunOutcome;
|
||||||
|
use App\Support\OperationRunStatus;
|
||||||
use Illuminate\Bus\Queueable;
|
use Illuminate\Bus\Queueable;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
use Illuminate\Foundation\Bus\Dispatchable;
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
@ -39,7 +40,7 @@ public function middleware(): array
|
|||||||
return [new TrackOperationRun];
|
return [new TrackOperationRun];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function handle(PolicySyncService $service, BulkOperationService $bulkOperationService): void
|
public function handle(PolicySyncService $service, OperationRunService $operationRunService): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::findOrFail($this->tenantId);
|
$tenant = Tenant::findOrFail($this->tenantId);
|
||||||
|
|
||||||
@ -63,7 +64,7 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe
|
|||||||
if (! $policy) {
|
if (! $policy) {
|
||||||
$failureSummary[] = [
|
$failureSummary[] = [
|
||||||
'code' => 'policy.not_found',
|
'code' => 'policy.not_found',
|
||||||
'message' => $bulkOperationService->sanitizeFailureReason("Policy {$policyId} not found"),
|
'message' => "Policy {$policyId} not found",
|
||||||
];
|
];
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
@ -82,32 +83,31 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe
|
|||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$failureSummary[] = [
|
$failureSummary[] = [
|
||||||
'code' => 'policy.sync_failed',
|
'code' => 'policy.sync_failed',
|
||||||
'message' => $bulkOperationService->sanitizeFailureReason($e->getMessage()),
|
'message' => $e->getMessage(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$failureCount = count($failureSummary);
|
$failureCount = count($failureSummary);
|
||||||
$outcome = match (true) {
|
$outcome = match (true) {
|
||||||
$failureCount === 0 => 'succeeded',
|
$failureCount === 0 => OperationRunOutcome::Succeeded->value,
|
||||||
$syncedCount > 0 => 'partially_succeeded',
|
$syncedCount > 0 => OperationRunOutcome::PartiallySucceeded->value,
|
||||||
default => 'failed',
|
default => OperationRunOutcome::Failed->value,
|
||||||
};
|
};
|
||||||
|
|
||||||
if ($this->operationRun) {
|
if ($this->operationRun) {
|
||||||
/** @var OperationRunService $opService */
|
$operationRunService->updateRun(
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opService->updateRun(
|
|
||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
'completed',
|
status: OperationRunStatus::Completed->value,
|
||||||
$outcome,
|
outcome: $outcome,
|
||||||
[
|
summaryCounts: [
|
||||||
'policies_total' => $ids->count(),
|
'total' => $ids->count(),
|
||||||
'policies_synced' => $syncedCount,
|
'processed' => $ids->count(),
|
||||||
'policies_skipped' => $skippedCount,
|
'succeeded' => $syncedCount,
|
||||||
'policies_failed' => $failureCount,
|
'failed' => $failureCount,
|
||||||
|
'skipped' => $skippedCount,
|
||||||
],
|
],
|
||||||
$failureSummary
|
failures: $failureSummary,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,9 +126,9 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe
|
|||||||
$failureCount = count($failures);
|
$failureCount = count($failures);
|
||||||
|
|
||||||
$outcome = match (true) {
|
$outcome = match (true) {
|
||||||
$failureCount === 0 => 'succeeded',
|
$failureCount === 0 => OperationRunOutcome::Succeeded->value,
|
||||||
$syncedCount > 0 => 'partially_succeeded',
|
$syncedCount > 0 => OperationRunOutcome::PartiallySucceeded->value,
|
||||||
default => 'failed',
|
default => OperationRunOutcome::Failed->value,
|
||||||
};
|
};
|
||||||
|
|
||||||
$failureSummary = [];
|
$failureSummary = [];
|
||||||
@ -157,23 +157,24 @@ public function handle(PolicySyncService $service, BulkOperationService $bulkOpe
|
|||||||
|
|
||||||
$failureSummary[] = [
|
$failureSummary[] = [
|
||||||
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR',
|
'code' => $status !== null ? "GRAPH_HTTP_{$status}" : 'GRAPH_ERROR',
|
||||||
'message' => $bulkOperationService->sanitizeFailureReason($message),
|
'message' => $message,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->operationRun) {
|
if ($this->operationRun) {
|
||||||
/** @var OperationRunService $opService */
|
$total = $syncedCount + $failureCount;
|
||||||
$opService = app(OperationRunService::class);
|
|
||||||
$opService->updateRun(
|
$operationRunService->updateRun(
|
||||||
$this->operationRun,
|
$this->operationRun,
|
||||||
'completed',
|
status: OperationRunStatus::Completed->value,
|
||||||
$outcome,
|
outcome: $outcome,
|
||||||
[
|
summaryCounts: [
|
||||||
'policy_types_total' => count($supported),
|
'total' => $total,
|
||||||
'policies_synced' => $syncedCount,
|
'processed' => $total,
|
||||||
'policy_types_failed' => $failureCount,
|
'succeeded' => $syncedCount,
|
||||||
|
'failed' => $failureCount,
|
||||||
],
|
],
|
||||||
$failureSummary
|
failures: $failureSummary,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,11 +51,14 @@ public function handle(RestoreRun $restoreRun): void
|
|||||||
|
|
||||||
[$opStatus, $opOutcome, $failures] = $this->mapStatus($status);
|
[$opStatus, $opOutcome, $failures] = $this->mapStatus($status);
|
||||||
|
|
||||||
$summaryCounts = [
|
$summaryCounts = [];
|
||||||
'assignments_success' => $restoreRun->getSuccessfulAssignmentsCount(),
|
$metadata = is_array($restoreRun->metadata) ? $restoreRun->metadata : [];
|
||||||
'assignments_failed' => $restoreRun->getFailedAssignmentsCount(),
|
|
||||||
'assignments_skipped' => $restoreRun->getSkippedAssignmentsCount(),
|
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(
|
$this->service->updateRun(
|
||||||
$opRun,
|
$opRun,
|
||||||
@ -72,7 +75,7 @@ public function handle(RestoreRun $restoreRun): void
|
|||||||
protected function mapStatus(RestoreRunStatus $status): array
|
protected function mapStatus(RestoreRunStatus $status): array
|
||||||
{
|
{
|
||||||
return match ($status) {
|
return match ($status) {
|
||||||
RestoreRunStatus::Previewed => ['queued', 'pending', []],
|
RestoreRunStatus::Previewed => ['completed', 'succeeded', []],
|
||||||
RestoreRunStatus::Pending => ['queued', 'pending', []],
|
RestoreRunStatus::Pending => ['queued', 'pending', []],
|
||||||
RestoreRunStatus::Queued => ['queued', 'pending', []],
|
RestoreRunStatus::Queued => ['queued', 'pending', []],
|
||||||
RestoreRunStatus::Running => ['running', 'pending', []],
|
RestoreRunStatus::Running => ['running', 'pending', []],
|
||||||
|
|||||||
@ -4,16 +4,14 @@
|
|||||||
|
|
||||||
use App\Jobs\AddPoliciesToBackupSetJob;
|
use App\Jobs\AddPoliciesToBackupSetJob;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\BulkOperationRun;
|
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use App\Support\OpsUx\OperationUxPresenter;
|
use App\Support\OpsUx\OperationUxPresenter;
|
||||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||||
use App\Support\RunIdempotency;
|
|
||||||
use Filament\Actions\BulkAction;
|
use Filament\Actions\BulkAction;
|
||||||
use Filament\Notifications\Notification;
|
use Filament\Notifications\Notification;
|
||||||
use Filament\Tables\Columns\TextColumn;
|
use Filament\Tables\Columns\TextColumn;
|
||||||
@ -23,7 +21,6 @@
|
|||||||
use Filament\Tables\TableComponent;
|
use Filament\Tables\TableComponent;
|
||||||
use Illuminate\Contracts\View\View;
|
use Illuminate\Contracts\View\View;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\QueryException;
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
@ -203,7 +200,7 @@ public function table(Table $table): Table
|
|||||||
->where('tenant_id', $tenant->getKey())
|
->where('tenant_id', $tenant->getKey())
|
||||||
->exists();
|
->exists();
|
||||||
})
|
})
|
||||||
->action(function (Collection $records, BulkOperationService $bulkOperationService): void {
|
->action(function (Collection $records): void {
|
||||||
$backupSet = BackupSet::query()->findOrFail($this->backupSetId);
|
$backupSet = BackupSet::query()->findOrFail($this->backupSetId);
|
||||||
$tenant = null;
|
$tenant = null;
|
||||||
|
|
||||||
@ -269,131 +266,57 @@ public function table(Table $table): Table
|
|||||||
|
|
||||||
sort($policyIds);
|
sort($policyIds);
|
||||||
|
|
||||||
$idempotencyKey = RunIdempotency::buildKey(
|
/** @var BulkSelectionIdentity $selection */
|
||||||
tenantId: (int) $tenant->getKey(),
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
operationType: 'backup_set.add_policies',
|
$selectionIdentity = $selection->fromIds($policyIds);
|
||||||
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,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// --- Phase 3: Canonical Operation Run Start ---
|
|
||||||
/** @var OperationRunService $opService */
|
/** @var OperationRunService $opService */
|
||||||
$opService = app(OperationRunService::class);
|
$opService = app(OperationRunService::class);
|
||||||
$opRun = $opService->ensureRun(
|
$opRun = $opService->enqueueBulkOperation(
|
||||||
tenant: $tenant,
|
tenant: $tenant,
|
||||||
type: 'backup_set.add_policies',
|
type: 'backup_set.add_policies',
|
||||||
inputs: [
|
targetScope: [
|
||||||
'backup_set_id' => $backupSet->id,
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_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,
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
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'])) {
|
AddPoliciesToBackupSetJob::dispatch(
|
||||||
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(
|
|
||||||
tenantId: (int) $tenant->getKey(),
|
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) {
|
if (! $opRun->wasRecentlyCreated && in_array($opRun->status, ['queued', 'running'], true)) {
|
||||||
Notification::make()
|
Notification::make()
|
||||||
->title('Add policies already queued')
|
->title('Add policies already queued')
|
||||||
->body('A matching run is already queued or running. Open the run to monitor progress.')
|
->body('A matching run is already queued or running. Open the run to monitor progress.')
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\Filament\Actions\Action::make('view_run')
|
||||||
->label('View run')
|
->label('View run')
|
||||||
->url(OperationRunLinks::view($opRun, $tenant)),
|
->url(OperationRunLinks::view($opRun, $tenant)),
|
||||||
])
|
])
|
||||||
->info()
|
->info()
|
||||||
->send();
|
->send();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw $exception;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @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)
|
OperationUxPresenter::queuedToast((string) $opRun->type)
|
||||||
->actions([
|
->actions([
|
||||||
\Filament\Actions\Action::make('view_run')
|
\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)
|
return collect($results)
|
||||||
->pluck('assignment_outcomes')
|
->pluck('assignment_outcomes')
|
||||||
->flatten(1)
|
->flatten(1)
|
||||||
->filter()
|
->filter(static fn (mixed $outcome): bool => is_array($outcome))
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->all();
|
||||||
}
|
}
|
||||||
@ -130,7 +130,7 @@ public function getSuccessfulAssignmentsCount(): int
|
|||||||
{
|
{
|
||||||
return count(array_filter(
|
return count(array_filter(
|
||||||
$this->getAssignmentRestoreOutcomes(),
|
$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(
|
return count(array_filter(
|
||||||
$this->getAssignmentRestoreOutcomes(),
|
$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(
|
return count(array_filter(
|
||||||
$this->getAssignmentRestoreOutcomes(),
|
$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;
|
namespace App\Notifications;
|
||||||
|
|
||||||
use App\Filament\Resources\BulkOperationRunResource;
|
|
||||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
use App\Filament\Resources\EntraGroupSyncRunResource;
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Actions\Action;
|
use Filament\Actions\Action;
|
||||||
use Illuminate\Notifications\Notification;
|
use Illuminate\Notifications\Notification;
|
||||||
|
|
||||||
@ -66,7 +66,7 @@ public function toDatabase(object $notifiable): array
|
|||||||
|
|
||||||
if ($tenant) {
|
if ($tenant) {
|
||||||
$url = match ($runType) {
|
$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),
|
'restore' => RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
|
||||||
'directory_groups' => EntraGroupSyncRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
|
'directory_groups' => EntraGroupSyncRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
|
||||||
default => null,
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -6,6 +6,7 @@
|
|||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||||
|
use Illuminate\Auth\Access\Response;
|
||||||
|
|
||||||
class OperationRunPolicy
|
class OperationRunPolicy
|
||||||
{
|
{
|
||||||
@ -22,7 +23,7 @@ public function viewAny(User $user): bool
|
|||||||
return $user->canAccessTenant($tenant);
|
return $user->canAccessTenant($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function view(User $user, OperationRun $run): bool
|
public function view(User $user, OperationRun $run): Response|bool
|
||||||
{
|
{
|
||||||
$tenant = Tenant::current();
|
$tenant = Tenant::current();
|
||||||
|
|
||||||
@ -34,6 +35,10 @@ public function view(User $user, OperationRun $run): bool
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (int) $run->tenant_id === (int) $tenant->getKey();
|
if ((int) $run->tenant_id !== (int) $tenant->getKey()) {
|
||||||
|
return Response::denyAsNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\BulkOperationRun;
|
|
||||||
use App\Models\EntraGroup;
|
use App\Models\EntraGroup;
|
||||||
use App\Models\EntraGroupSyncRun;
|
use App\Models\EntraGroupSyncRun;
|
||||||
use App\Models\Finding;
|
use App\Models\Finding;
|
||||||
@ -14,7 +13,6 @@
|
|||||||
use App\Models\UserTenantPreference;
|
use App\Models\UserTenantPreference;
|
||||||
use App\Observers\RestoreRunObserver;
|
use App\Observers\RestoreRunObserver;
|
||||||
use App\Policies\BackupSchedulePolicy;
|
use App\Policies\BackupSchedulePolicy;
|
||||||
use App\Policies\BulkOperationRunPolicy;
|
|
||||||
use App\Policies\EntraGroupPolicy;
|
use App\Policies\EntraGroupPolicy;
|
||||||
use App\Policies\EntraGroupSyncRunPolicy;
|
use App\Policies\EntraGroupSyncRunPolicy;
|
||||||
use App\Policies\FindingPolicy;
|
use App\Policies\FindingPolicy;
|
||||||
@ -121,7 +119,6 @@ public function boot(): void
|
|||||||
});
|
});
|
||||||
|
|
||||||
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
|
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
|
||||||
Gate::policy(BulkOperationRun::class, BulkOperationRunPolicy::class);
|
|
||||||
Gate::policy(Finding::class, FindingPolicy::class);
|
Gate::policy(Finding::class, FindingPolicy::class);
|
||||||
Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class);
|
Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class);
|
||||||
Gate::policy(EntraGroup::class, EntraGroupPolicy::class);
|
Gate::policy(EntraGroup::class, EntraGroupPolicy::class);
|
||||||
|
|||||||
273
app/Services/AdapterRunReconciler.php
Normal file
273
app/Services/AdapterRunReconciler.php
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
<?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::Previewed,
|
||||||
|
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::Previewed => [OperationRunStatus::Completed->value, OperationRunOutcome::Succeeded->value, []],
|
||||||
|
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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -90,6 +90,29 @@ public function listPolicies(string $policyType, array $options = []): GraphResp
|
|||||||
|
|
||||||
$response = $this->send('GET', $endpoint, $sendOptions, $context);
|
$response = $this->send('GET', $endpoint, $sendOptions, $context);
|
||||||
|
|
||||||
|
// Some tenants intermittently return OData select parsing errors.
|
||||||
|
// Retry once with the same $select before applying the compatibility fallback.
|
||||||
|
if ($response->failed()) {
|
||||||
|
$graphResponse = $this->toGraphResponse(
|
||||||
|
action: 'list_policies',
|
||||||
|
response: $response,
|
||||||
|
transform: fn (array $json) => $json['value'] ?? (is_array($json) ? $json : []),
|
||||||
|
meta: [
|
||||||
|
'tenant' => $context['tenant'] ?? null,
|
||||||
|
'path' => $endpoint,
|
||||||
|
'full_path' => $fullPath,
|
||||||
|
'method' => 'GET',
|
||||||
|
'query' => $query ?: null,
|
||||||
|
'client_request_id' => $clientRequestId,
|
||||||
|
],
|
||||||
|
warnings: $warnings,
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($this->shouldApplySelectFallback($graphResponse, $query)) {
|
||||||
|
$response = $this->send('GET', $endpoint, $sendOptions, $context);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($response->failed()) {
|
if ($response->failed()) {
|
||||||
$graphResponse = $this->toGraphResponse(
|
$graphResponse = $this->toGraphResponse(
|
||||||
action: 'list_policies',
|
action: 'list_policies',
|
||||||
@ -626,7 +649,24 @@ private function send(string $method, string $path, array $options = [], array $
|
|||||||
$pending = Http::baseUrl($this->baseUrl)
|
$pending = Http::baseUrl($this->baseUrl)
|
||||||
->acceptJson()
|
->acceptJson()
|
||||||
->timeout($this->timeout)
|
->timeout($this->timeout)
|
||||||
->retry($this->retryTimes, $this->retrySleepMs)
|
->retry(
|
||||||
|
$this->retryTimes,
|
||||||
|
fn (int $attempt, Throwable $exception): int => $this->retryDelayMs($attempt),
|
||||||
|
function (Throwable $exception): bool {
|
||||||
|
if ($exception instanceof ConnectionException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($exception instanceof RequestException) {
|
||||||
|
$status = $exception->response?->status();
|
||||||
|
|
||||||
|
return in_array($status, [429, 503], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
throw: true,
|
||||||
|
)
|
||||||
->withToken($token)
|
->withToken($token)
|
||||||
->withHeaders([
|
->withHeaders([
|
||||||
'client-request-id' => $clientRequestId,
|
'client-request-id' => $clientRequestId,
|
||||||
@ -667,6 +707,23 @@ private function send(string $method, string $path, array $options = [], array $
|
|||||||
return $response;
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function retryDelayMs(int $attempt): int
|
||||||
|
{
|
||||||
|
$baseMs = max(0, $this->retrySleepMs);
|
||||||
|
|
||||||
|
if ($attempt <= 1 || $baseMs === 0) {
|
||||||
|
return $baseMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
$exponential = $baseMs * (2 ** ($attempt - 1));
|
||||||
|
$capped = min($exponential, 5000);
|
||||||
|
|
||||||
|
$jitterMax = (int) floor($capped * 0.2);
|
||||||
|
$jitter = $jitterMax > 0 ? random_int(0, $jitterMax) : 0;
|
||||||
|
|
||||||
|
return $capped + $jitter;
|
||||||
|
}
|
||||||
|
|
||||||
private function toGraphResponse(string $action, Response $response, callable $transform, array $meta = [], array $warnings = []): GraphResponse
|
private function toGraphResponse(string $action, Response $response, callable $transform, array $meta = [], array $warnings = []): GraphResponse
|
||||||
{
|
{
|
||||||
$json = $response->json() ?? [];
|
$json = $response->json() ?? [];
|
||||||
|
|||||||
@ -7,11 +7,17 @@
|
|||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification;
|
use App\Notifications\OperationRunCompleted as OperationRunCompletedNotification;
|
||||||
use App\Notifications\OperationRunQueued as OperationRunQueuedNotification;
|
use App\Notifications\OperationRunQueued as OperationRunQueuedNotification;
|
||||||
|
use App\Services\Operations\BulkIdempotencyFingerprint;
|
||||||
use App\Support\OperationRunOutcome;
|
use App\Support\OperationRunOutcome;
|
||||||
use App\Support\OperationRunStatus;
|
use App\Support\OperationRunStatus;
|
||||||
|
use App\Support\OpsUx\BulkRunContext;
|
||||||
|
use App\Support\OpsUx\RunFailureSanitizer;
|
||||||
use App\Support\OpsUx\SummaryCountsNormalizer;
|
use App\Support\OpsUx\SummaryCountsNormalizer;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
use ReflectionFunction;
|
||||||
|
use ReflectionMethod;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class OperationRunService
|
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(
|
public function updateRun(
|
||||||
OperationRun $run,
|
OperationRun $run,
|
||||||
string $status,
|
string $status,
|
||||||
@ -137,6 +261,51 @@ public function updateRun(
|
|||||||
return $run;
|
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.
|
* Dispatch a queued operation safely.
|
||||||
*
|
*
|
||||||
@ -146,7 +315,7 @@ public function updateRun(
|
|||||||
public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = true): void
|
public function dispatchOrFail(OperationRun $run, callable $dispatcher, bool $emitQueuedNotification = true): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$dispatcher();
|
$this->invokeDispatcher($dispatcher, $run);
|
||||||
|
|
||||||
if ($emitQueuedNotification && $run->wasRecentlyCreated && $run->user instanceof User) {
|
if ($emitQueuedNotification && $run->wasRecentlyCreated && $run->user instanceof User) {
|
||||||
$run->user->notify(new OperationRunQueuedNotification($run));
|
$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
|
public function failRun(OperationRun $run, Throwable $e): OperationRun
|
||||||
{
|
{
|
||||||
return $this->updateRun(
|
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
|
protected function calculateHash(int $tenantId, string $type, array $inputs): string
|
||||||
{
|
{
|
||||||
$normalizedInputs = $this->normalizeInputs($inputs);
|
$normalizedInputs = $this->normalizeInputs($inputs);
|
||||||
@ -237,7 +531,7 @@ protected function isListArray(array $array): bool
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, array{code?: mixed, message?: mixed}> $failures
|
* @param array<int, array{code?: mixed, message?: mixed}> $failures
|
||||||
* @return array<int, array{code: string, message: string}>
|
* @return array<int, array{code: string, reason_code: string, message: string}>
|
||||||
*/
|
*/
|
||||||
protected function sanitizeFailures(array $failures): array
|
protected function sanitizeFailures(array $failures): array
|
||||||
{
|
{
|
||||||
@ -245,10 +539,12 @@ protected function sanitizeFailures(array $failures): array
|
|||||||
|
|
||||||
foreach ($failures as $failure) {
|
foreach ($failures as $failure) {
|
||||||
$code = (string) ($failure['code'] ?? 'unknown');
|
$code = (string) ($failure['code'] ?? 'unknown');
|
||||||
|
$reasonCode = (string) ($failure['reason_code'] ?? $code);
|
||||||
$message = (string) ($failure['message'] ?? '');
|
$message = (string) ($failure['message'] ?? '');
|
||||||
|
|
||||||
$sanitized[] = [
|
$sanitized[] = [
|
||||||
'code' => $this->sanitizeFailureCode($code),
|
'code' => $this->sanitizeFailureCode($code),
|
||||||
|
'reason_code' => RunFailureSanitizer::normalizeReasonCode($reasonCode),
|
||||||
'message' => $this->sanitizeMessage($message),
|
'message' => $this->sanitizeMessage($message),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -258,27 +554,12 @@ protected function sanitizeFailures(array $failures): array
|
|||||||
|
|
||||||
protected function sanitizeFailureCode(string $code): string
|
protected function sanitizeFailureCode(string $code): string
|
||||||
{
|
{
|
||||||
$code = strtolower(trim($code));
|
return RunFailureSanitizer::sanitizeCode($code);
|
||||||
|
|
||||||
if ($code === '') {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
return substr($code, 0, 80);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function sanitizeMessage(string $message): string
|
protected function sanitizeMessage(string $message): string
|
||||||
{
|
{
|
||||||
$message = trim(str_replace(["\r", "\n"], ' ', $message));
|
return RunFailureSanitizer::sanitizeMessage($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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
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' => 'Policy sync',
|
||||||
'policy.sync_one' => 'Policy sync',
|
'policy.sync_one' => 'Policy sync',
|
||||||
'policy.capture_snapshot' => 'Policy snapshot',
|
'policy.capture_snapshot' => 'Policy snapshot',
|
||||||
|
'policy.delete' => 'Delete policies',
|
||||||
|
'policy.unignore' => 'Restore policies',
|
||||||
|
'policy.export' => 'Export policies to backup',
|
||||||
'inventory.sync' => 'Inventory sync',
|
'inventory.sync' => 'Inventory sync',
|
||||||
'directory_groups.sync' => 'Directory groups sync',
|
'directory_groups.sync' => 'Directory groups sync',
|
||||||
'drift.generate' => 'Drift generation',
|
'drift.generate' => 'Drift generation',
|
||||||
@ -26,6 +29,10 @@ public static function labels(): array
|
|||||||
'backup_schedule.run_now' => 'Backup schedule run',
|
'backup_schedule.run_now' => 'Backup schedule run',
|
||||||
'backup_schedule.retry' => 'Backup schedule retry',
|
'backup_schedule.retry' => 'Backup schedule retry',
|
||||||
'restore.execute' => 'Restore execution',
|
'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.prune' => 'Prune policy versions',
|
||||||
'policy_version.restore' => 'Restore policy versions',
|
'policy_version.restore' => 'Restore policy versions',
|
||||||
'policy_version.force_delete' => 'Delete policy versions',
|
'policy_version.force_delete' => 'Delete policy versions',
|
||||||
@ -47,6 +54,7 @@ public static function expectedDurationSeconds(string $operationType): ?int
|
|||||||
{
|
{
|
||||||
return match (trim($operationType)) {
|
return match (trim($operationType)) {
|
||||||
'policy.sync', 'policy.sync_one' => 90,
|
'policy.sync', 'policy.sync_one' => 90,
|
||||||
|
'policy.export' => 120,
|
||||||
'inventory.sync' => 180,
|
'inventory.sync' => 180,
|
||||||
'directory_groups.sync' => 120,
|
'directory_groups.sync' => 120,
|
||||||
'drift.generate' => 240,
|
'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;
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/Support/OpsUx/RunFailureSanitizer.php
Normal file
103
app/Support/OpsUx/RunFailureSanitizer.php
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Support\OpsUx;
|
||||||
|
|
||||||
|
final class RunFailureSanitizer
|
||||||
|
{
|
||||||
|
public const string REASON_GRAPH_THROTTLED = 'graph_throttled';
|
||||||
|
|
||||||
|
public const string REASON_GRAPH_TIMEOUT = 'graph_timeout';
|
||||||
|
|
||||||
|
public const string REASON_PERMISSION_DENIED = 'permission_denied';
|
||||||
|
|
||||||
|
public const string REASON_VALIDATION_ERROR = 'validation_error';
|
||||||
|
|
||||||
|
public const string REASON_CONFLICT_DETECTED = 'conflict_detected';
|
||||||
|
|
||||||
|
public const string REASON_UNKNOWN_ERROR = 'unknown_error';
|
||||||
|
|
||||||
|
public static function sanitizeCode(string $code): string
|
||||||
|
{
|
||||||
|
$code = strtolower(trim($code));
|
||||||
|
|
||||||
|
if ($code === '') {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return substr($code, 0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function normalizeReasonCode(string $candidate): string
|
||||||
|
{
|
||||||
|
$candidate = strtolower(trim($candidate));
|
||||||
|
|
||||||
|
if ($candidate === '') {
|
||||||
|
return self::REASON_UNKNOWN_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowed = [
|
||||||
|
self::REASON_GRAPH_THROTTLED,
|
||||||
|
self::REASON_GRAPH_TIMEOUT,
|
||||||
|
self::REASON_PERMISSION_DENIED,
|
||||||
|
self::REASON_VALIDATION_ERROR,
|
||||||
|
self::REASON_CONFLICT_DETECTED,
|
||||||
|
self::REASON_UNKNOWN_ERROR,
|
||||||
|
];
|
||||||
|
|
||||||
|
if (in_array($candidate, $allowed, true)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatibility mappings from existing codebase labels.
|
||||||
|
$candidate = match ($candidate) {
|
||||||
|
'graph_forbidden' => self::REASON_PERMISSION_DENIED,
|
||||||
|
'graph_transient' => self::REASON_GRAPH_TIMEOUT,
|
||||||
|
'unknown' => self::REASON_UNKNOWN_ERROR,
|
||||||
|
default => $candidate,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (in_array($candidate, $allowed, true)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heuristic normalization for ad-hoc codes used across jobs/services.
|
||||||
|
if (str_contains($candidate, 'throttle') || str_contains($candidate, '429')) {
|
||||||
|
return self::REASON_GRAPH_THROTTLED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($candidate, 'timeout') || str_contains($candidate, 'transient') || str_contains($candidate, '503') || str_contains($candidate, '504')) {
|
||||||
|
return self::REASON_GRAPH_TIMEOUT;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($candidate, 'forbidden') || str_contains($candidate, 'permission') || str_contains($candidate, 'unauthorized') || str_contains($candidate, '403')) {
|
||||||
|
return self::REASON_PERMISSION_DENIED;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($candidate, 'validation') || str_contains($candidate, 'not_found') || str_contains($candidate, 'bad_request') || str_contains($candidate, '400') || str_contains($candidate, '422')) {
|
||||||
|
return self::REASON_VALIDATION_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_contains($candidate, 'conflict') || str_contains($candidate, '409')) {
|
||||||
|
return self::REASON_CONFLICT_DETECTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::REASON_UNKNOWN_ERROR;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function sanitizeMessage(string $message): string
|
||||||
|
{
|
||||||
|
$message = trim(str_replace(["\r", "\n"], ' ', $message));
|
||||||
|
|
||||||
|
// Redact obvious PII (emails).
|
||||||
|
$message = preg_replace('/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i', '[REDACTED_EMAIL]', $message) ?? $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;
|
namespace App\Support;
|
||||||
|
|
||||||
use App\Models\BulkOperationRun;
|
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use Illuminate\Support\Arr;
|
use Illuminate\Support\Arr;
|
||||||
|
|
||||||
final class RunIdempotency
|
final class RestoreRunIdempotency
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $context
|
* @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));
|
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
|
public static function findActiveRestoreRun(int $tenantId, string $idempotencyKey): ?RestoreRun
|
||||||
{
|
{
|
||||||
return RestoreRun::query()
|
return RestoreRun::query()
|
||||||
@ -320,6 +320,10 @@
|
|||||||
'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3),
|
'poll_interval_seconds' => (int) env('TENANTPILOT_BULK_POLL_INTERVAL_SECONDS', 3),
|
||||||
'recent_finished_seconds' => (int) env('TENANTPILOT_BULK_RECENT_FINISHED_SECONDS', 12),
|
'recent_finished_seconds' => (int) env('TENANTPILOT_BULK_RECENT_FINISHED_SECONDS', 12),
|
||||||
'progress_widget_enabled' => (bool) env('TENANTPILOT_BULK_PROGRESS_WIDGET_ENABLED', true),
|
'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' => [
|
'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
|
<?php
|
||||||
|
|
||||||
use App\Jobs\PruneOldOperationRunsJob;
|
use App\Jobs\PruneOldOperationRunsJob;
|
||||||
|
use App\Jobs\ReconcileAdapterRunsJob;
|
||||||
use Illuminate\Foundation\Inspiring;
|
use Illuminate\Foundation\Inspiring;
|
||||||
use Illuminate\Support\Facades\Artisan;
|
use Illuminate\Support\Facades\Artisan;
|
||||||
use Illuminate\Support\Facades\Schedule;
|
use Illuminate\Support\Facades\Schedule;
|
||||||
@ -16,3 +17,8 @@
|
|||||||
->daily()
|
->daily()
|
||||||
->name(PruneOldOperationRunsJob::class)
|
->name(PruneOldOperationRunsJob::class)
|
||||||
->withoutOverlapping();
|
->withoutOverlapping();
|
||||||
|
|
||||||
|
Schedule::job(new ReconcileAdapterRunsJob)
|
||||||
|
->everyThirtyMinutes()
|
||||||
|
->name(ReconcileAdapterRunsJob::class)
|
||||||
|
->withoutOverlapping();
|
||||||
|
|||||||
@ -44,7 +44,8 @@ ### 2. Run Migrations
|
|||||||
### 3. Seed Test Data (Optional)
|
### 3. Seed Test Data (Optional)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./vendor/bin/sail artisan db:seed --class=BulkOperationsTestSeeder
|
# NOTE: Removed by Feature 056 (OperationRun migration).
|
||||||
|
# There is no BulkOperationsTestSeeder anymore.
|
||||||
```
|
```
|
||||||
|
|
||||||
Creates:
|
Creates:
|
||||||
@ -409,10 +410,10 @@ # Watch queue jobs in real-time
|
|||||||
|
|
||||||
# Monitor bulk operations
|
# Monitor bulk operations
|
||||||
./vendor/bin/sail artisan tinker
|
./vendor/bin/sail artisan tinker
|
||||||
>>> BulkOperationRun::inProgress()->get()
|
>>> \App\Models\OperationRun::query()->where('status', 'running')->get()
|
||||||
|
|
||||||
# Seed more test data
|
# Seed more test data
|
||||||
./vendor/bin/sail artisan db:seed --class=BulkOperationsTestSeeder
|
# NOTE: Removed by Feature 056 (OperationRun migration).
|
||||||
|
|
||||||
# Clear cache
|
# Clear cache
|
||||||
./vendor/bin/sail artisan optimize:clear
|
./vendor/bin/sail artisan optimize:clear
|
||||||
|
|||||||
35
specs/056-remove-legacy-bulkops/checklists/requirements.md
Normal file
35
specs/056-remove-legacy-bulkops/checklists/requirements.md
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
# Specification Quality Checklist: Remove Legacy BulkOperationRun & Canonicalize Operations (v1.0)
|
||||||
|
|
||||||
|
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||||
|
**Created**: 2026-01-18
|
||||||
|
**Feature**: [specs/056-remove-legacy-bulkops/spec.md](../spec.md)
|
||||||
|
|
||||||
|
## Content Quality
|
||||||
|
|
||||||
|
- [x] No implementation details (languages, frameworks, APIs)
|
||||||
|
- [x] Focused on user value and business needs
|
||||||
|
- [x] Written for non-technical stakeholders
|
||||||
|
- [x] All mandatory sections completed
|
||||||
|
|
||||||
|
## Requirement Completeness
|
||||||
|
|
||||||
|
- [x] No [NEEDS CLARIFICATION] markers remain
|
||||||
|
- [x] Requirements are testable and unambiguous
|
||||||
|
- [x] Success criteria are measurable
|
||||||
|
- [x] Success criteria are technology-agnostic (no implementation details)
|
||||||
|
- [x] All acceptance scenarios are defined
|
||||||
|
- [x] Edge cases are identified
|
||||||
|
- [x] Scope is clearly bounded
|
||||||
|
- [x] Dependencies and assumptions identified
|
||||||
|
|
||||||
|
## Feature Readiness
|
||||||
|
|
||||||
|
- [x] All functional requirements have clear acceptance criteria
|
||||||
|
- [x] User scenarios cover primary flows
|
||||||
|
- [x] Feature meets measurable outcomes defined in Success Criteria
|
||||||
|
- [x] No implementation details leak into specification
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Validation pass: Spec describes the single-run model, action taxonomy, and MSP safety expectations in testable terms.
|
||||||
|
- Implementation specifics (service names, job design, exact key registries) are intentionally omitted; they belong in the plan/tasks phase.
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "operation-run-context.bulk.schema.json",
|
||||||
|
"title": "OperationRun Context — Bulk Operation",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"operation": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"type": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"required": ["type"]
|
||||||
|
},
|
||||||
|
"target_scope": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"entra_tenant_id": { "type": "string", "minLength": 1 },
|
||||||
|
"directory_context_id": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"anyOf": [
|
||||||
|
{ "required": ["entra_tenant_id"] },
|
||||||
|
{ "required": ["directory_context_id"] }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"selection": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"kind": { "type": "string", "enum": ["ids", "query"] },
|
||||||
|
"ids_hash": { "type": "string", "minLength": 1 },
|
||||||
|
"query_hash": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"if": { "properties": { "kind": { "const": "ids" } }, "required": ["kind"] },
|
||||||
|
"then": { "required": ["ids_hash"], "not": { "required": ["query_hash"] } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"if": { "properties": { "kind": { "const": "query" } }, "required": ["kind"] },
|
||||||
|
"then": { "required": ["query_hash"], "not": { "required": ["ids_hash"] } }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"required": ["kind"]
|
||||||
|
},
|
||||||
|
"idempotency": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"properties": {
|
||||||
|
"fingerprint": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"required": ["fingerprint"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["operation", "selection", "idempotency"]
|
||||||
|
}
|
||||||
@ -0,0 +1,66 @@
|
|||||||
|
openapi: 3.0.3
|
||||||
|
info:
|
||||||
|
title: TenantAtlas Operations — Bulk Enqueue (Conceptual)
|
||||||
|
version: 0.1.0
|
||||||
|
description: |
|
||||||
|
Conceptual contract for enqueue-only bulk operations.
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- This contract describes the shape of inputs and outputs; it does not prescribe a specific Laravel route.
|
||||||
|
- Start surfaces are enqueue-only and must not perform remote work inline.
|
||||||
|
paths:
|
||||||
|
/operations/bulk/enqueue:
|
||||||
|
post:
|
||||||
|
summary: Enqueue a bulk operation (enqueue-only)
|
||||||
|
operationId: enqueueBulkOperation
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
properties:
|
||||||
|
operation_type:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
target_scope:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
properties:
|
||||||
|
entra_tenant_id:
|
||||||
|
type: string
|
||||||
|
directory_context_id:
|
||||||
|
type: string
|
||||||
|
selection:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
properties:
|
||||||
|
kind:
|
||||||
|
type: string
|
||||||
|
enum: [ids, query]
|
||||||
|
ids:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
query:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
required: [operation_type, selection]
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Enqueued or deduped to an existing active run
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
properties:
|
||||||
|
operation_run_id:
|
||||||
|
type: integer
|
||||||
|
status:
|
||||||
|
type: string
|
||||||
|
enum: [queued, running]
|
||||||
|
view_run_url:
|
||||||
|
type: string
|
||||||
|
required: [operation_run_id, status]
|
||||||
87
specs/056-remove-legacy-bulkops/data-model.md
Normal file
87
specs/056-remove-legacy-bulkops/data-model.md
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# Phase 1 — Data Model: Remove Legacy BulkOperationRun & Canonicalize Operations (v1.0)
|
||||||
|
|
||||||
|
This document describes the domain entities and data contracts impacted by Feature 056.
|
||||||
|
|
||||||
|
## Entities
|
||||||
|
|
||||||
|
### OperationRun (canonical)
|
||||||
|
|
||||||
|
Represents a single observable operation execution. Tenant-scoped.
|
||||||
|
|
||||||
|
**Core fields (existing):**
|
||||||
|
|
||||||
|
- `tenant_id`: Tenant scope for isolation.
|
||||||
|
- `user_id`: Initiator user (nullable for system).
|
||||||
|
- `initiator_name`: Human name for display.
|
||||||
|
- `type`: Operation type (stable identifier).
|
||||||
|
- `status`: queued | running | completed.
|
||||||
|
- `outcome`: pending | succeeded | partial | failed (exact naming per existing app semantics).
|
||||||
|
- `run_identity_hash`: Deterministic identity used for dedupe of active runs.
|
||||||
|
- `context`: JSON object containing operation inputs and metadata.
|
||||||
|
- `summary_counts`: JSON object with canonical metrics keys.
|
||||||
|
- `failure_summary`: JSON array/object with stable reason codes + sanitized messages.
|
||||||
|
- `started_at`, `completed_at`: Timestamps.
|
||||||
|
|
||||||
|
**Invariants:**
|
||||||
|
|
||||||
|
- Active-run dedupe is tenant-wide.
|
||||||
|
- Monitoring renders from DB only.
|
||||||
|
- `summary_counts` keys are constrained to the canonical registry.
|
||||||
|
- Failure messages are sanitized and do not include secrets.
|
||||||
|
|
||||||
|
### Operation Type (logical)
|
||||||
|
|
||||||
|
A stable identifier used to categorize runs, label them for admins, and enforce consistent UX.
|
||||||
|
|
||||||
|
**Attributes:**
|
||||||
|
|
||||||
|
- `type` (string): e.g., `policy_version.prune`, `backup_set.delete`, etc.
|
||||||
|
- `label` (string): human readable name.
|
||||||
|
- `expected_duration_seconds` (optional): typical duration.
|
||||||
|
- `allowed_summary_keys`: canonical registry.
|
||||||
|
|
||||||
|
### Target Scope (logical)
|
||||||
|
|
||||||
|
A directory/remote tenant scope that an operation targets.
|
||||||
|
|
||||||
|
**Context fields (when applicable):**
|
||||||
|
|
||||||
|
- `entra_tenant_id`: Azure AD / Entra tenant GUID.
|
||||||
|
- `directory_context_id`: Internal directory context identifier.
|
||||||
|
|
||||||
|
At least one of the above must be present for directory-targeted operations.
|
||||||
|
|
||||||
|
### Bulk Selection Identity (logical)
|
||||||
|
|
||||||
|
Deterministic definition of “what the bulk action applies to”, required for idempotency.
|
||||||
|
|
||||||
|
**Decision**: Hybrid identity.
|
||||||
|
|
||||||
|
- Explicit selection: `selection.ids_hash`
|
||||||
|
- Query/filter selection (“select all”): `selection.query_hash`
|
||||||
|
|
||||||
|
**Properties:**
|
||||||
|
|
||||||
|
- `selection.kind`: `ids` | `query`
|
||||||
|
- `selection.ids_hash`: sha256 of stable, sorted IDs.
|
||||||
|
- `selection.query_hash`: sha256 of normalized filter/query payload.
|
||||||
|
|
||||||
|
### Bulk Idempotency Fingerprint (logical)
|
||||||
|
|
||||||
|
Deterministic fingerprint used to dedupe active runs and prevent duplicated work.
|
||||||
|
|
||||||
|
**Components:**
|
||||||
|
|
||||||
|
- operation `type`
|
||||||
|
- target scope (`entra_tenant_id` or `directory_context_id`)
|
||||||
|
- selection identity (hybrid)
|
||||||
|
|
||||||
|
## Removed Entity
|
||||||
|
|
||||||
|
### BulkOperationRun (legacy)
|
||||||
|
|
||||||
|
This entity is removed by Feature 056.
|
||||||
|
|
||||||
|
- Legacy model/service/table are deleted.
|
||||||
|
- No new writes to legacy tables after cutover.
|
||||||
|
- Historical import into OperationRun is not performed.
|
||||||
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. |
|
||||||
163
specs/056-remove-legacy-bulkops/plan.md
Normal file
163
specs/056-remove-legacy-bulkops/plan.md
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
# Implementation Plan: Remove Legacy BulkOperationRun & Canonicalize Operations (v1.0)
|
||||||
|
|
||||||
|
**Branch**: `056-remove-legacy-bulkops` | **Date**: 2026-01-18 | **Spec**: [specs/056-remove-legacy-bulkops/spec.md](./spec.md)
|
||||||
|
**Input**: Feature specification from `/specs/056-remove-legacy-bulkops/spec.md`
|
||||||
|
|
||||||
|
**Note**: This template is filled in by the `/speckit.plan` command. See `.specify/scripts/` for helper scripts.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Unify all bulk/operational work onto `OperationRun` (canonical run model + Monitoring surface) by migrating all legacy `BulkOperationRun` workflows to OperationRun-backed orchestration, removing the legacy stack (model/service/table/UI), and adding guardrails that prevent reintroduction.
|
||||||
|
|
||||||
|
## Technical Context
|
||||||
|
|
||||||
|
**Language/Version**: PHP 8.4.x
|
||||||
|
**Primary Dependencies**: Laravel 12, Filament v4, Livewire v3
|
||||||
|
**Storage**: PostgreSQL (JSONB in `operation_runs.context`, `operation_runs.summary_counts`)
|
||||||
|
**Testing**: Pest (PHPUnit 12)
|
||||||
|
**Target Platform**: Docker via Laravel Sail (local); Dokploy (staging/prod)
|
||||||
|
**Project Type**: Web application (Laravel monolith)
|
||||||
|
**Performance Goals**: Calm polling UX for Monitoring; bulk orchestration chunked and resilient to throttling; per-scope concurrency default=1
|
||||||
|
**Constraints**: Tenant isolation; no secrets in run failures/notifications; no remote work during UI render; Monitoring is DB-only
|
||||||
|
**Scale/Scope**: Bulk actions may target large selections; orchestration must remain idempotent and debounced by run identity
|
||||||
|
|
||||||
|
## Constitution Check
|
||||||
|
|
||||||
|
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||||
|
|
||||||
|
- Inventory-first: N/A (this feature is operations plumbing; no inventory semantics change)
|
||||||
|
- Read/write separation: PASS (bulk actions remain enqueue-only; write paths are job-backed and auditable)
|
||||||
|
- Graph contract path: PASS (no new render-side Graph calls; any remote work stays behind queue + existing Graph client boundary)
|
||||||
|
- Deterministic capabilities: PASS (no capability derivation changes)
|
||||||
|
- Tenant isolation: PASS (OperationRun is tenant-scoped; bulk dedupe is tenant-wide; selection identity is deterministic)
|
||||||
|
- Run observability: PASS (bulk is always OperationRun-backed; DB-only <2s actions remain audit-only)
|
||||||
|
- Automation: PASS (locks + idempotency required; per-target concurrency is config-driven default=1)
|
||||||
|
- Data minimization: PASS (failure summaries stay sanitized; no secrets in run records)
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
### Documentation (this feature)
|
||||||
|
|
||||||
|
```text
|
||||||
|
specs/056-remove-legacy-bulkops/
|
||||||
|
├── plan.md # This file (/speckit.plan command output)
|
||||||
|
├── research.md # Phase 0 output (/speckit.plan command)
|
||||||
|
├── data-model.md # Phase 1 output (/speckit.plan command)
|
||||||
|
├── quickstart.md # Phase 1 output (/speckit.plan command)
|
||||||
|
├── contracts/ # Phase 1 output (/speckit.plan command)
|
||||||
|
└── tasks.md # Phase 2 output (/speckit.tasks command - NOT created by /speckit.plan)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Source Code (repository root)
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/
|
||||||
|
├── Filament/
|
||||||
|
│ ├── Resources/
|
||||||
|
│ └── Pages/
|
||||||
|
├── Jobs/
|
||||||
|
├── Models/
|
||||||
|
├── Notifications/
|
||||||
|
├── Services/
|
||||||
|
└── Support/
|
||||||
|
|
||||||
|
config/
|
||||||
|
├── tenantpilot.php
|
||||||
|
└── graph_contracts.php
|
||||||
|
|
||||||
|
database/
|
||||||
|
├── migrations/
|
||||||
|
└── factories/
|
||||||
|
|
||||||
|
resources/
|
||||||
|
└── views/
|
||||||
|
|
||||||
|
routes/
|
||||||
|
└── web.php
|
||||||
|
|
||||||
|
tests/
|
||||||
|
├── Feature/
|
||||||
|
└── Unit/
|
||||||
|
```
|
||||||
|
|
||||||
|
**Structure Decision**: Laravel web application (monolith). Feature changes are expected primarily under `app/` (runs, jobs, Filament actions), `database/migrations/` (dropping legacy tables), and `tests/` (Pest guardrails).
|
||||||
|
|
||||||
|
## Complexity Tracking
|
||||||
|
|
||||||
|
> **Fill ONLY if Constitution Check has violations that must be justified**
|
||||||
|
|
||||||
|
| Violation | Why Needed | Simpler Alternative Rejected Because |
|
||||||
|
|-----------|------------|-------------------------------------|
|
||||||
|
| None | N/A | N/A |
|
||||||
|
|
||||||
|
## Constitution Check (Post-Design)
|
||||||
|
|
||||||
|
*Re-check after Phase 1 outputs are complete.*
|
||||||
|
|
||||||
|
- Monitoring remains DB-only at render time.
|
||||||
|
- Start surfaces remain enqueue-only.
|
||||||
|
- Bulk work is always OperationRun-backed.
|
||||||
|
- Per-target scope concurrency is config-driven (default=1).
|
||||||
|
- Bulk idempotency uses hybrid selection identity.
|
||||||
|
|
||||||
|
## Phase 0 — Outline & Research (output: research.md)
|
||||||
|
|
||||||
|
### Discovery signals (repo sweep)
|
||||||
|
|
||||||
|
Legacy bulk-run usage exists and must be migrated/removed:
|
||||||
|
|
||||||
|
- Jobs using legacy run/service include:
|
||||||
|
- `app/Jobs/BulkPolicySyncJob.php`
|
||||||
|
- `app/Jobs/BulkPolicyDeleteJob.php`
|
||||||
|
- `app/Jobs/BulkBackupSetDeleteJob.php`
|
||||||
|
- `app/Jobs/BulkPolicyVersionPruneJob.php`
|
||||||
|
- `app/Jobs/BulkPolicyVersionForceDeleteJob.php`
|
||||||
|
- `app/Jobs/BulkRestoreRunDeleteJob.php`
|
||||||
|
- `app/Jobs/BulkTenantSyncJob.php`
|
||||||
|
- `app/Jobs/CapturePolicySnapshotJob.php`
|
||||||
|
- `app/Jobs/GenerateDriftFindingsJob.php` (mixed: legacy + OperationRun)
|
||||||
|
|
||||||
|
Legacy data layer present:
|
||||||
|
|
||||||
|
- Model: `app/Models/BulkOperationRun.php`
|
||||||
|
- Service: `app/Services/BulkOperationService.php`
|
||||||
|
- Migrations: `database/migrations/*_create_bulk_operation_runs_table.php`, `*_add_idempotency_key_to_bulk_operation_runs_table.php`, `*_increase_bulk_operation_runs_status_length.php`
|
||||||
|
- Factory/Seeder: `database/factories/BulkOperationRunFactory.php`, `database/seeders/BulkOperationsTestSeeder.php`
|
||||||
|
|
||||||
|
Existing canonical run patterns to reuse:
|
||||||
|
|
||||||
|
- `app/Services/OperationRunService.php` provides tenant-wide active-run dedupe and safe dispatch semantics.
|
||||||
|
- `app/Support/OperationCatalog.php` centralizes labels/durations and allowed summary keys.
|
||||||
|
- `app/Support/OpsUx/OperationSummaryKeys.php` + `SummaryCountsNormalizer` enforce summary_counts contract.
|
||||||
|
|
||||||
|
Concurrency/idempotency patterns already exist and should be adapted:
|
||||||
|
|
||||||
|
- `app/Services/Inventory/InventoryConcurrencyLimiter.php` uses per-scope cache locks with config-driven defaults.
|
||||||
|
- `app/Services/Inventory/InventorySyncService.php` uses selection hashing + selection locks to prevent duplicate work.
|
||||||
|
|
||||||
|
### Research outputs (decisions)
|
||||||
|
|
||||||
|
- Per-target scope concurrency is configuration-driven with default=1.
|
||||||
|
- Bulk selection identity is hybrid (IDs-hash when explicit IDs; query-hash when “select all via filter/query”).
|
||||||
|
- Legacy bulk-run history is not imported into OperationRun (default path).
|
||||||
|
|
||||||
|
## Phase 1 — Design & Contracts (outputs: data-model.md, contracts/*, quickstart.md)
|
||||||
|
|
||||||
|
### Design topics
|
||||||
|
|
||||||
|
- Define the canonical bulk orchestration shape around OperationRun (orchestrator + workers) while preserving the constitution feedback surfaces.
|
||||||
|
- Define the minimal run context contract for directory-targeted runs (target scope fields + idempotency fingerprint fields).
|
||||||
|
- Extend operation type catalog for newly migrated bulk operations.
|
||||||
|
|
||||||
|
### Contract artifacts
|
||||||
|
|
||||||
|
- JSON schema for `operation_runs.context` for bulk operations (target scope + selection identity + idempotency).
|
||||||
|
- OpenAPI sketch for internal operation-triggering endpoints (if applicable) as a stable contract for “enqueue-only” start surfaces.
|
||||||
|
|
||||||
|
## Phase 2 — Planning (implementation outline; detailed tasks live in tasks.md)
|
||||||
|
|
||||||
|
- Perform required discovery sweep and create a migration report.
|
||||||
|
- Migrate each legacy bulk workflow to OperationRun-backed orchestration.
|
||||||
|
- Remove legacy model/service/table/UI surfaces.
|
||||||
|
- Add hard guardrails (CI/test) to forbid legacy references and verify run-backed behavior.
|
||||||
|
- Add targeted Pest tests for bulk actions, per-scope throttling default=1, and summary key normalization.
|
||||||
52
specs/056-remove-legacy-bulkops/quickstart.md
Normal file
52
specs/056-remove-legacy-bulkops/quickstart.md
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# Quickstart: Feature 056 — Remove Legacy BulkOperationRun
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Local dev via Laravel Sail
|
||||||
|
- Database migrations up to date
|
||||||
|
|
||||||
|
## Common commands (Sail-first)
|
||||||
|
|
||||||
|
- Boot: `./vendor/bin/sail up -d`
|
||||||
|
- Run migrations: `./vendor/bin/sail artisan migrate`
|
||||||
|
- 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.
|
||||||
|
- Ensure all bulk actions are enqueue-only and visible in Monitoring → Operations.
|
||||||
|
- Enforce per-target scope concurrency limit (config-driven, default=1).
|
||||||
|
- Enforce bulk idempotency via deterministic fingerprinting.
|
||||||
|
- Remove legacy BulkOperationRun stack (model/service/table/UI).
|
||||||
|
- Add guardrails/tests to prevent reintroduction.
|
||||||
|
|
||||||
|
## Where to look first
|
||||||
|
|
||||||
|
- Legacy stack:
|
||||||
|
- `app/Models/BulkOperationRun.php`
|
||||||
|
- `app/Services/BulkOperationService.php`
|
||||||
|
- `database/migrations/*bulk_operation_runs*`
|
||||||
|
|
||||||
|
- Canonical run stack:
|
||||||
|
- `app/Models/OperationRun.php`
|
||||||
|
- `app/Services/OperationRunService.php`
|
||||||
|
- `app/Support/OperationCatalog.php`
|
||||||
|
- `app/Support/OpsUx/OperationSummaryKeys.php`
|
||||||
|
|
||||||
|
- Locking patterns to reuse:
|
||||||
|
- `app/Services/Inventory/InventoryConcurrencyLimiter.php`
|
||||||
|
- `app/Services/Inventory/InventorySyncService.php`
|
||||||
89
specs/056-remove-legacy-bulkops/research.md
Normal file
89
specs/056-remove-legacy-bulkops/research.md
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# Phase 0 — Research: Remove Legacy BulkOperationRun & Canonicalize Operations (v1.0)
|
||||||
|
|
||||||
|
**Branch**: 056-remove-legacy-bulkops
|
||||||
|
**Date**: 2026-01-18
|
||||||
|
|
||||||
|
## Goals for Research
|
||||||
|
|
||||||
|
- Resolve spec clarifications and translate them into concrete implementation constraints.
|
||||||
|
- Identify existing repo patterns for:
|
||||||
|
- run identity / dedupe
|
||||||
|
- summary_counts normalization
|
||||||
|
- locks / concurrency limiting
|
||||||
|
- idempotent selection hashing
|
||||||
|
- Enumerate known legacy usage locations to inform the discovery report.
|
||||||
|
|
||||||
|
## Findings & Decisions
|
||||||
|
|
||||||
|
### Decision 1 — Per-target scope concurrency
|
||||||
|
|
||||||
|
- **Decision**: Concurrency limits are configuration-driven **with default = 1** per target scope.
|
||||||
|
- **Rationale**: Default=1 is the safest choice against Graph throttling and reduces blast radius when many tenants/scope targets are active.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Default=2: more throughput but higher throttling risk.
|
||||||
|
- Default=5: increases throttling/incident risk.
|
||||||
|
- Hardcoded values: hard to tune per environment.
|
||||||
|
- **Implementation constraint**: Limit is enforced per `entra_tenant_id` or `directory_context_id`.
|
||||||
|
|
||||||
|
### Decision 2 — Selection Identity (idempotency fingerprint)
|
||||||
|
|
||||||
|
- **Decision**: Hybrid selection identity.
|
||||||
|
- If the user explicitly selects IDs: fingerprint includes an **IDs-hash**.
|
||||||
|
- If the user selects via filter/query (“select all”): fingerprint includes a **query/filter hash**.
|
||||||
|
- **Rationale**: Supports both UX patterns and avoids duplicate runs while remaining deterministic.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- IDs-hash only: cannot represent “select all by filter”.
|
||||||
|
- Query-hash only: cannot safely represent explicit selections.
|
||||||
|
- Always store both: increases complexity without clear value.
|
||||||
|
|
||||||
|
### Decision 3 — Legacy history handling
|
||||||
|
|
||||||
|
- **Decision**: Do not import legacy bulk-run history into OperationRun.
|
||||||
|
- **Rationale**: Minimizes migration risk and avoids polluting the canonical run surface with “synthetic” imported data.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- One-time import: adds complexity, new semantics, and additional testing burden.
|
||||||
|
|
||||||
|
### Decision 4 — Canonical summary metrics
|
||||||
|
|
||||||
|
- **Decision**: Summary metrics are derived and rendered from `operation_runs.summary_counts` using the canonical key registry.
|
||||||
|
- **Rationale**: Ensures consistent Monitoring UX and prevents ad-hoc keys.
|
||||||
|
- **Alternatives considered**:
|
||||||
|
- Per-operation bespoke metrics: causes UX drift and breaks shared widgets.
|
||||||
|
|
||||||
|
### Decision 5 — Reuse existing repo patterns
|
||||||
|
|
||||||
|
- **Decision**: Reuse existing run, lock, and selection hashing patterns already present in the repository.
|
||||||
|
- **Rationale**: Aligns with constitution and avoids divergent implementations.
|
||||||
|
- **Evidence in repo**:
|
||||||
|
- `OperationRunService` provides tenant-wide active-run dedupe and safe dispatch failure handling.
|
||||||
|
- `OperationCatalog` centralizes labels/durations and allowed summary keys.
|
||||||
|
- `InventoryConcurrencyLimiter` shows slot-based lock acquisition with config-driven maxima.
|
||||||
|
- `InventorySyncService` shows deterministic selection hashing and selection-level locks.
|
||||||
|
|
||||||
|
## Legacy Usage Inventory (initial)
|
||||||
|
|
||||||
|
This is a starting list derived from a repo search; the Phase 2 Discovery Report must expand/confirm.
|
||||||
|
|
||||||
|
- Legacy bulk-run model: `app/Models/BulkOperationRun.php`
|
||||||
|
- Legacy bulk-run service: `app/Services/BulkOperationService.php`
|
||||||
|
- Legacy jobs using BulkOperationRun/Service:
|
||||||
|
- `app/Jobs/BulkPolicySyncJob.php`
|
||||||
|
- `app/Jobs/BulkPolicyDeleteJob.php`
|
||||||
|
- `app/Jobs/BulkBackupSetDeleteJob.php`
|
||||||
|
- `app/Jobs/BulkPolicyVersionPruneJob.php`
|
||||||
|
- `app/Jobs/BulkPolicyVersionForceDeleteJob.php`
|
||||||
|
- `app/Jobs/BulkRestoreRunDeleteJob.php`
|
||||||
|
- `app/Jobs/BulkTenantSyncJob.php`
|
||||||
|
- `app/Jobs/CapturePolicySnapshotJob.php`
|
||||||
|
- `app/Jobs/GenerateDriftFindingsJob.php` (mixed usage)
|
||||||
|
- Legacy DB artifacts:
|
||||||
|
- `database/migrations/2025_12_23_215901_create_bulk_operation_runs_table.php`
|
||||||
|
- `database/migrations/2026_01_11_120001_add_idempotency_key_to_bulk_operation_runs_table.php`
|
||||||
|
- `database/migrations/2025_12_24_005055_increase_bulk_operation_runs_status_length.php`
|
||||||
|
- Legacy test data:
|
||||||
|
- `database/factories/BulkOperationRunFactory.php`
|
||||||
|
- `database/seeders/BulkOperationsTestSeeder.php`
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
None remaining for Phase 0 (spec clarifications resolved). Any additional unknowns found during discovery are to be added to the Phase 2 discovery report and/or tasks.
|
||||||
190
specs/056-remove-legacy-bulkops/spec.md
Normal file
190
specs/056-remove-legacy-bulkops/spec.md
Normal file
@ -0,0 +1,190 @@
|
|||||||
|
# Feature Specification: Remove Legacy BulkOperationRun & Canonicalize Operations (v1.0)
|
||||||
|
|
||||||
|
**Feature Branch**: `056-remove-legacy-bulkops`
|
||||||
|
**Created**: 2026-01-18
|
||||||
|
**Status**: Draft
|
||||||
|
**Input**: User description: "Feature 056 — Remove Legacy BulkOperationRun & Canonicalize Operations (v1.0)"
|
||||||
|
|
||||||
|
## Clarifications
|
||||||
|
|
||||||
|
### Session 2026-01-18
|
||||||
|
|
||||||
|
- Q: What should be the default max concurrency per target scope (entra_tenant_id / directory_context_id) for bulk operations? → A: Config-driven, default=1
|
||||||
|
- Q: How should Selection Identity be determined for idempotency fingerprinting? → A: Hybrid (IDs-hash for explicit selection; query-hash for “select all via filter/query”)
|
||||||
|
|
||||||
|
## User Scenarios & Testing *(mandatory)*
|
||||||
|
|
||||||
|
### User Story 1 - Run-backed bulk actions are always observable (Priority: P1)
|
||||||
|
|
||||||
|
An admin performs a bulk action (e.g., apply/ignore/restore/prune across many records). The system records a single canonical run that can be monitored end-to-end, including partial failures, and provides consistent user feedback.
|
||||||
|
|
||||||
|
**Why this priority**: Bulk changes are operationally significant and must be traceable, support partial outcomes, and have a consistent mental model for admins.
|
||||||
|
|
||||||
|
**Independent Test**: Trigger a representative bulk action and verify that a run record exists, appears in the Monitoring list, has a detail view, and emits the correct feedback surfaces.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** an admin selects multiple items for a bulk action, **When** the action is confirmed and submitted, **Then** a canonical run record is created or reused and the UI confirms the enqueue/queued state via a toast.
|
||||||
|
2. **Given** a bulk run is queued or running, **When** the admin opens Monitoring → Operations, **Then** the run appears in the list and can be opened via a canonical “View run” link.
|
||||||
|
3. **Given** a bulk run completes with a mix of successes and failures, **When** the run reaches a terminal state, **Then** the initiator receives a terminal notification and the run detail shows a summary of outcomes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 2 - Monitoring is the single source of run history (Priority: P2)
|
||||||
|
|
||||||
|
An admin (or operator) relies on Monitoring → Operations to see the full history of operational work (including bulk). There are no separate legacy run surfaces; links from anywhere in the app point to the canonical run detail.
|
||||||
|
|
||||||
|
**Why this priority**: Multiple run systems lead to missed incidents, inconsistent retention, and developer confusion. One canonical surface improves operational clarity and reduces support overhead.
|
||||||
|
|
||||||
|
**Independent Test**: Navigate from a bulk action result to “View run” and confirm it lands in Monitoring’s run detail; confirm there is no legacy “bulk runs” navigation or pages.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** any UI element offers a “View run” link, **When** it is clicked, **Then** it opens the canonical Monitoring → Operations → Run Detail page for that run.
|
||||||
|
2. **Given** the app navigation, **When** an admin searches for legacy bulk-run screens, **Then** no legacy bulk-run navigation or pages exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### User Story 3 - Developers can’t accidentally reintroduce legacy patterns (Priority: P3)
|
||||||
|
|
||||||
|
A developer adds or modifies an admin action. They can clearly determine whether it is an audit-only action or a run-backed operation, and the repository enforces the single-run model by preventing legacy references and UX drift.
|
||||||
|
|
||||||
|
**Why this priority**: Preventing regression is essential for suite readiness and long-term maintainability.
|
||||||
|
|
||||||
|
**Independent Test**: Introduce a legacy reference or a bulk action without a run-backed record and confirm CI/automated checks fail.
|
||||||
|
|
||||||
|
**Acceptance Scenarios**:
|
||||||
|
|
||||||
|
1. **Given** a change introduces any reference to the legacy bulk-run system, **When** tests/CI run, **Then** the pipeline fails with a clear message.
|
||||||
|
2. **Given** a security-relevant DB-only action that is eligible for audit-only classification, **When** the action runs, **Then** an audit log entry is recorded and no run record is created.
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
|
||||||
|
- Bulk selection is empty or resolves to zero items: the system does not start work and provides a clear non-destructive result.
|
||||||
|
- A bulk selection is very large: the system remains responsive and continues to show progress via run summary metrics.
|
||||||
|
- Target scope is required but missing: the system fails safely, records a terminal run with a stable reason code, and does not execute remote/bulk mutations.
|
||||||
|
- Remote calls experience throttling: the system applies bounded retries with jittered backoff and records failures without losing overall run visibility.
|
||||||
|
- Duplicate submissions (double click / retry / re-run): idempotency prevents duplicate processing and preserves a single canonical outcome per selection identity.
|
||||||
|
- Tenant isolation: no run, selection, summary, or notifications leak across tenants.
|
||||||
|
|
||||||
|
## Requirements *(mandatory)*
|
||||||
|
|
||||||
|
**Constitution alignment (required):** This feature consolidates operational work onto a single canonical run model and a single monitoring surface. It must preserve the defined user feedback surfaces (queued toast, active widget, terminal notification), ensure tenant-scoped observability, and maintain stable, sanitized messages and reason codes.
|
||||||
|
|
||||||
|
### Functional Requirements
|
||||||
|
|
||||||
|
- **FR-001 Single run model**: The system MUST use a single canonical run model (`OperationRun`) for all run-backed operations; the legacy bulk-run model MUST not exist after this feature.
|
||||||
|
- **FR-002 Bulk actions are run-backed**: Any bulk action (apply to N records, chunked work, mass ignore/restore/prune/delete) MUST create or reuse an `OperationRun` and MUST be visible in Monitoring → Operations.
|
||||||
|
- **FR-003 Action taxonomy**: Every admin action MUST be classified as exactly one of:
|
||||||
|
- **Audit-only DB action**: DB-only, no remote/external calls, no queued work, and bounded DB work; typically completes within ~2 seconds (guidance, not a hard rule). MUST write an audit log for security/ops-relevant state changes; MUST NOT create an `OperationRun`.
|
||||||
|
- **Run-backed operation**: queued/long-running/remote/bulk/scheduled or otherwise operationally significant; MUST create or reuse an `OperationRun`.
|
||||||
|
|
||||||
|
**Decision rule**: If classification is uncertain, default to **Run-backed operation**.
|
||||||
|
- **FR-004 Canonical UX surfaces**: For run-backed operations, the system MUST use only these feedback surfaces:
|
||||||
|
- **Queued**: toast-only
|
||||||
|
- **Active**: tenant-wide active widget
|
||||||
|
- **Terminal**: database-backed notification to the initiator only
|
||||||
|
- **FR-005 Canonical routing**: All “View run” links MUST route to Monitoring → Operations → Run Detail.
|
||||||
|
- **FR-006 Legacy removal**: The system MUST remove legacy bulk-run tables/models/services/routes/widgets/navigation and MUST prevent any new legacy writes.
|
||||||
|
- **FR-007 Canonical summary metrics**: The run’s summary metrics MUST use a single canonical set of keys and MUST be presented consistently in the run detail view.
|
||||||
|
- **FR-008 Target scope recording**: For operations targeting a directory/remote tenant, the run context MUST record the target scope (directory identifier) and Monitoring/Run Detail MUST display it in a human-friendly way when available.
|
||||||
|
- **FR-009 Per-target throttling**: Bulk orchestration MUST enforce concurrency limits per target scope to reduce throttling risk and provide predictable execution; the limit MUST be configuration-driven with a default of 1 per target scope.
|
||||||
|
- **FR-010 Idempotency for bulk**: Bulk operations MUST be idempotent using a deterministic fingerprint that includes operation type, target scope, and selection identity; retries MUST NOT duplicate work.
|
||||||
|
- **FR-011 Discovery completeness**: The implementation MUST include a repo-wide discovery sweep of legacy references and bulk-like actions; findings MUST be recorded in a discovery report with classification and migration/deferral decisions.
|
||||||
|
- **FR-012 Regression guardrails**: Automated checks MUST fail if legacy bulk-run references reappear or if bulk actions bypass the canonical run-backed model.
|
||||||
|
|
||||||
|
### Non-Functional Requirements (NFR)
|
||||||
|
|
||||||
|
#### NFR-01 Monitoring is DB-only at render time (Constitution Gate)
|
||||||
|
|
||||||
|
All Monitoring → Operations pages (index and run detail) MUST be DB-only at render time:
|
||||||
|
|
||||||
|
- No Graph/remote calls during initial render or reactive renders.
|
||||||
|
- No side-effectful work triggered by view rendering.
|
||||||
|
|
||||||
|
**Verification**:
|
||||||
|
|
||||||
|
- Add a regression test/guard that mocks the Graph client (or equivalent remote client) and asserts it is not called during Monitoring renders.
|
||||||
|
|
||||||
|
- Add a regression test/guard that mocks the Graph client (or equivalent remote client) and asserts it is not called during Monitoring renders.
|
||||||
|
|
||||||
|
#### NFR-02 Failure reason codes and message sanitization
|
||||||
|
|
||||||
|
Run-backed operations MUST store failures as stable, machine-readable `reason_code` values plus a sanitized, user-facing message.
|
||||||
|
|
||||||
|
**Minimal required reason_code set (baseline)**:
|
||||||
|
|
||||||
|
| reason_code | Meaning |
|
||||||
|
|------------|---------|
|
||||||
|
| graph_throttled | Remote service throttled (e.g., rate limited) |
|
||||||
|
| graph_timeout | Remote call timed out |
|
||||||
|
| permission_denied | Missing/insufficient permissions |
|
||||||
|
| validation_error | Input/selection validation failure |
|
||||||
|
| conflict_detected | Conflict detected (concurrency/version/resource state) |
|
||||||
|
| unknown_error | Fallback when no specific code applies |
|
||||||
|
|
||||||
|
**Rules**:
|
||||||
|
|
||||||
|
- `reason_code` is stable over time and safe to use in programmatic filters/alerts.
|
||||||
|
- Failure messages are sanitized and bounded in length; failures/notifications MUST NOT persist secrets/tokens/PII or raw payload dumps.
|
||||||
|
|
||||||
|
#### NFR-03 Retry/backoff/jitter for remote throttling
|
||||||
|
|
||||||
|
When worker jobs perform remote calls, they MUST handle transient failures (including 429/503) via a shared policy:
|
||||||
|
|
||||||
|
- bounded retries
|
||||||
|
- exponential backoff with jitter
|
||||||
|
- no hand-rolled `sleep()` loops or ad-hoc random retry logic in feature code
|
||||||
|
|
||||||
|
### Implementation Shape (decision)
|
||||||
|
|
||||||
|
**Decision: standard orchestrator + item workers**
|
||||||
|
|
||||||
|
- 1 orchestrator job per run:
|
||||||
|
- resolves selection deterministically
|
||||||
|
- chunks work
|
||||||
|
- dispatches item worker jobs (idempotent per item)
|
||||||
|
- Worker jobs update `operation_runs.summary_counts` via canonical normalization.
|
||||||
|
- Finalization sets terminal status once.
|
||||||
|
|
||||||
|
### Target Scope (canonical keys)
|
||||||
|
|
||||||
|
**Canonical context keys**:
|
||||||
|
|
||||||
|
- `entra_tenant_id` (Azure AD tenant GUID)
|
||||||
|
- optional `entra_tenant_name` (human-friendly; if available)
|
||||||
|
- optional `directory_context_id` (internal directory context identifier, if/when introduced)
|
||||||
|
|
||||||
|
For operations targeting a directory/remote tenant, the run context MUST record target scope using the canonical keys above, and Monitoring/Run Detail MUST display the target scope (human-friendly name if available).
|
||||||
|
|
||||||
|
#### Assumptions
|
||||||
|
|
||||||
|
- Existing run status semantics remain unchanged (queued/running/succeeded/partial/failed).
|
||||||
|
- Existing monitoring experience is not redesigned; it is aligned so that all operational work is represented consistently.
|
||||||
|
|
||||||
|
#### Dependencies
|
||||||
|
|
||||||
|
- Prior consolidation work establishing `OperationRun` as the canonical run model and Monitoring → Operations as the canonical surface.
|
||||||
|
- Existing audit logging conventions for security/ops-relevant DB-only actions.
|
||||||
|
|
||||||
|
#### Legacy History Decision (recorded)
|
||||||
|
|
||||||
|
- Default path: legacy bulk-run history is not migrated into the canonical run model. The legacy tables are removed after cutover, relying on database backups/exports if historical investigation is needed.
|
||||||
|
|
||||||
|
### Key Entities *(include if feature involves data)*
|
||||||
|
|
||||||
|
- **OperationRun**: A tenant-scoped record of operational work with status, timestamps, sanitized user-facing message/reason code, summary metrics, and context.
|
||||||
|
- **Operation Type**: A stable identifier describing the kind of operation (used for categorization, labeling, and governance).
|
||||||
|
- **Target Scope**: The directory / remote tenant scope that the operation targets (when applicable).
|
||||||
|
- **Selection Identity**: The deterministic definition of “what the bulk action applies to” used for idempotency and traceability.
|
||||||
|
- **Audit Log Entry**: A record of security/ops-relevant state changes for audit-only DB actions.
|
||||||
|
|
||||||
|
## Success Criteria *(mandatory)*
|
||||||
|
|
||||||
|
### Measurable Outcomes
|
||||||
|
|
||||||
|
- **SC-001**: 100% of bulk actions in the admin UI create or reuse a canonical run record and appear in Monitoring → Operations.
|
||||||
|
- **SC-002**: Repository contains 0 references to the legacy bulk-run system after completion, enforced by automated checks.
|
||||||
|
- **SC-003**: For directory-targeted operations, 100% of run records display a target scope in Monitoring/Run Detail.
|
||||||
|
- **SC-004**: For bulk operations, duplicate submissions do not increase processed item count beyond one idempotent execution per selection identity.
|
||||||
|
- **SC-005**: Admins can locate a completed bulk run in Monitoring within 30 seconds using standard navigation and filters, without relying on legacy pages.
|
||||||
259
specs/056-remove-legacy-bulkops/tasks.md
Normal file
259
specs/056-remove-legacy-bulkops/tasks.md
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
# Tasks: Remove Legacy BulkOperationRun & Canonicalize Operations (v1.0)
|
||||||
|
|
||||||
|
**Input**: Design documents from `/specs/056-remove-legacy-bulkops/`
|
||||||
|
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/, quickstart.md
|
||||||
|
|
||||||
|
**Tests**: Required (Pest)
|
||||||
|
**Operations**: This feature consolidates queued/bulk work onto canonical `OperationRun` and removes the legacy `BulkOperationRun` system.
|
||||||
|
|
||||||
|
## Phase 1: Setup (Shared Infrastructure)
|
||||||
|
|
||||||
|
**Purpose**: Ensure feature docs/paths are ready for implementation and review
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: Foundational (Blocking Prerequisites)
|
||||||
|
|
||||||
|
**Purpose**: Shared primitives required for all bulk migrations and hardening
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: User Story 1 - Run-backed bulk actions are always observable (Priority: P1) 🎯 MVP
|
||||||
|
|
||||||
|
**Goal**: All bulk actions are OperationRun-backed and observable end-to-end
|
||||||
|
|
||||||
|
**Independent Test**: Trigger one migrated bulk action and verify OperationRun is created/reused, queued toast occurs, and Monitoring → Operations shows it.
|
||||||
|
|
||||||
|
### Tests for User Story 1
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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.
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4: User Story 2 - Monitoring is the single source of run history (Priority: P2)
|
||||||
|
|
||||||
|
**Goal**: No legacy “Bulk Operation Runs” surfaces exist; all view-run links route to Monitoring’s canonical run detail
|
||||||
|
|
||||||
|
**Independent Test**: From any bulk action, “View run” navigates to OperationRun detail and there is no legacy BulkOperationRun resource.
|
||||||
|
|
||||||
|
### Tests for User Story 2
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 5: User Story 3 - Developers can’t accidentally reintroduce legacy patterns (Priority: P3)
|
||||||
|
|
||||||
|
**Goal**: Guardrails enforce single-run model and prevent UX drift / legacy reintroduction
|
||||||
|
|
||||||
|
**Independent Test**: Introduce a forbidden legacy reference or bulk start surface without OperationRun and confirm automated tests fail.
|
||||||
|
|
||||||
|
### Tests for User Story 3
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 6: Polish & Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Purpose**: Final removal of legacy DB artifacts and cleanup
|
||||||
|
|
||||||
|
- [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)
|
||||||
|
|
||||||
|
- [ ] 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
|
||||||
|
- [X] T056 Validate feature via targeted test run list and update notes in specs/056-remove-legacy-bulkops/quickstart.md
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring DB-only render guard (NFR-01)
|
||||||
|
|
||||||
|
- [X] T061 Add a regression test ensuring Monitoring → Operations pages do not invoke Graph/remote calls during render
|
||||||
|
- Approach:
|
||||||
|
- Mock/spy Graph client (or equivalent remote client)
|
||||||
|
- Render Operations index and OperationRun detail pages
|
||||||
|
- Assert no remote calls were made
|
||||||
|
- DoD: test fails if any Graph client is called from Monitoring render paths
|
||||||
|
|
||||||
|
## Legacy removal (FR-006)
|
||||||
|
|
||||||
|
- [X] T062 Remove BulkOperationRun model + related database artifacts (after cutover)
|
||||||
|
- Delete app/Models/BulkOperationRun.php (and any related models)
|
||||||
|
- Ensure no runtime references remain
|
||||||
|
|
||||||
|
- [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
|
||||||
|
|
||||||
|
- [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)
|
||||||
|
|
||||||
|
- [X] T065 Update OperationRun run detail view to display target scope consistently
|
||||||
|
- Show entra_tenant_name if present, else show entra_tenant_id
|
||||||
|
- If directory_context_id exists, optionally show it as secondary info
|
||||||
|
- Ensure this is visible in Monitoring → Operations → Run Detail
|
||||||
|
- DoD: reviewers can start a run for a specific Entra tenant and see the target clearly on Run Detail
|
||||||
|
|
||||||
|
## Failure semantics hardening (NFR-02)
|
||||||
|
|
||||||
|
- [X] T066 Define/standardize reason codes for migrated bulk operations and enforce message sanitization bounds
|
||||||
|
- Baseline reason_code set: graph_throttled, graph_timeout, permission_denied, validation_error, conflict_detected, unknown_error
|
||||||
|
- Ensure reason_code is stable and machine-readable
|
||||||
|
- Ensure failure message is sanitized + bounded (no secrets/tokens/PII/raw payload dumps)
|
||||||
|
- DoD: for each new/migrated bulk operation type, expected reason_code usage is clear and consistent
|
||||||
|
|
||||||
|
- [X] T067 Add a regression test asserting failures/notifications never persist secrets/PII
|
||||||
|
- Approach: create a run failure with sensitive-looking strings and assert persisted failures/notifications are sanitized
|
||||||
|
- DoD: test fails if sensitive patterns appear in stored failures/notifications
|
||||||
|
|
||||||
|
## Remote retry/backoff/jitter policy (NFR-03)
|
||||||
|
|
||||||
|
- [X] T068 Ensure migrated remote calls use the shared retry/backoff policy (429/503) and forbid ad-hoc retry loops
|
||||||
|
- Use bounded retries + exponential backoff with jitter
|
||||||
|
- DoD: no hand-rolled sleep/random retry logic in bulk workers; one test or assertion proves shared policy is used
|
||||||
|
|
||||||
|
## Canonical “View run” sweep and guard (FR-005)
|
||||||
|
|
||||||
|
- [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
|
||||||
|
- DoD: documented check/list shows no legacy “View run” links remain
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- **US1 (P1)**: Foundation for cutover; must complete before deleting legacy UI/DB.
|
||||||
|
- **US2 (P2)**: Depends on US1 (cutover) so links can be fully canonicalized.
|
||||||
|
- **US3 (P3)**: Can start after Foundational and run in parallel with US2, but should be finalized after US1 cutover.
|
||||||
|
|
||||||
|
### Parallel Opportunities
|
||||||
|
|
||||||
|
- Tasks marked **[P]** are safe to do in parallel (new files or isolated edits).
|
||||||
|
- Within US1, jobs and Filament start-surface migrations can be split by resource (Policies vs BackupSets vs PolicyVersions vs RestoreRuns).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Parallel Example: User Story 1
|
||||||
|
|
||||||
|
- Task: T012 [US1] tests/Feature/OpsUx/BulkEnqueueIdempotencyTest.php
|
||||||
|
- Task: T013 [US1] tests/Feature/OpsUx/TargetScopeConcurrencyLimiterTest.php
|
||||||
|
- Task: T014 [US1] tests/Unit/Operations/BulkSelectionIdentityTest.php
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### MVP First (User Story 1 Only)
|
||||||
|
|
||||||
|
1. Complete Setup + Foundational
|
||||||
|
2. Complete US1 migrations for at least one representative bulk action (end-to-end)
|
||||||
|
3. Validate Monitoring visibility + queued toast + terminal notification
|
||||||
|
|
||||||
|
### Incremental Delivery
|
||||||
|
|
||||||
|
- Migrate bulk workflows in small, independently testable slices (one resource at a time) while keeping Monitoring canonical.
|
||||||
|
- Remove legacy surfaces only after all start surfaces are migrated.
|
||||||
@ -4,7 +4,6 @@
|
|||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\BackupScheduleRun;
|
use App\Models\BackupScheduleRun;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Intune\BackupService;
|
use App\Services\Intune\BackupService;
|
||||||
use App\Services\Intune\PolicySyncService;
|
use App\Services\Intune\PolicySyncService;
|
||||||
use App\Services\OperationRunService;
|
use App\Services\OperationRunService;
|
||||||
@ -75,14 +74,13 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
|
|||||||
|
|
||||||
Cache::flush();
|
Cache::flush();
|
||||||
|
|
||||||
(new RunBackupScheduleJob($run->id, null, $operationRun))->handle(
|
(new RunBackupScheduleJob($run->id, $operationRun))->handle(
|
||||||
app(PolicySyncService::class),
|
app(PolicySyncService::class),
|
||||||
app(BackupService::class),
|
app(BackupService::class),
|
||||||
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
|
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
|
||||||
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
|
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
|
||||||
app(\App\Services\Intune\AuditLogger::class),
|
app(\App\Services\Intune\AuditLogger::class),
|
||||||
app(\App\Services\BackupScheduling\RunErrorMapper::class),
|
app(\App\Services\BackupScheduling\RunErrorMapper::class),
|
||||||
app(BulkOperationService::class),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$run->refresh();
|
$run->refresh();
|
||||||
@ -92,11 +90,14 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
|
|||||||
$operationRun->refresh();
|
$operationRun->refresh();
|
||||||
expect($operationRun->status)->toBe('completed');
|
expect($operationRun->status)->toBe('completed');
|
||||||
expect($operationRun->outcome)->toBe('succeeded');
|
expect($operationRun->outcome)->toBe('succeeded');
|
||||||
expect($operationRun->summary_counts)->toMatchArray([
|
expect($operationRun->context)->toMatchArray([
|
||||||
'backup_schedule_id' => (int) $schedule->id,
|
'backup_schedule_id' => (int) $schedule->id,
|
||||||
'backup_schedule_run_id' => (int) $run->id,
|
'backup_schedule_run_id' => (int) $run->id,
|
||||||
'backup_set_id' => (int) $backupSet->id,
|
'backup_set_id' => (int) $backupSet->id,
|
||||||
]);
|
]);
|
||||||
|
expect($operationRun->summary_counts)->toMatchArray([
|
||||||
|
'created' => 1,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips runs when all policy types are unknown', function () {
|
it('skips runs when all policy types are unknown', function () {
|
||||||
@ -137,14 +138,13 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
|
|||||||
initiator: $user,
|
initiator: $user,
|
||||||
);
|
);
|
||||||
|
|
||||||
(new RunBackupScheduleJob($run->id, null, $operationRun))->handle(
|
(new RunBackupScheduleJob($run->id, $operationRun))->handle(
|
||||||
app(PolicySyncService::class),
|
app(PolicySyncService::class),
|
||||||
app(BackupService::class),
|
app(BackupService::class),
|
||||||
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
|
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
|
||||||
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
|
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
|
||||||
app(\App\Services\Intune\AuditLogger::class),
|
app(\App\Services\Intune\AuditLogger::class),
|
||||||
app(\App\Services\BackupScheduling\RunErrorMapper::class),
|
app(\App\Services\BackupScheduling\RunErrorMapper::class),
|
||||||
app(BulkOperationService::class),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$run->refresh();
|
$run->refresh();
|
||||||
@ -156,7 +156,7 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
|
|||||||
expect($operationRun->status)->toBe('completed');
|
expect($operationRun->status)->toBe('completed');
|
||||||
expect($operationRun->outcome)->toBe('failed');
|
expect($operationRun->outcome)->toBe('failed');
|
||||||
expect($operationRun->failure_summary)->toMatchArray([
|
expect($operationRun->failure_summary)->toMatchArray([
|
||||||
['code' => 'unknown_policy_type', 'message' => $run->error_message],
|
['code' => 'unknown_policy_type', 'message' => $run->error_message, 'reason_code' => 'unknown_error'],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -237,15 +237,16 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
|
|||||||
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
|
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
|
||||||
app(\App\Services\Intune\AuditLogger::class),
|
app(\App\Services\Intune\AuditLogger::class),
|
||||||
app(\App\Services\BackupScheduling\RunErrorMapper::class),
|
app(\App\Services\BackupScheduling\RunErrorMapper::class),
|
||||||
app(BulkOperationService::class),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
$operationRun->refresh();
|
$operationRun->refresh();
|
||||||
expect($operationRun->status)->toBe('completed');
|
expect($operationRun->status)->toBe('completed');
|
||||||
expect($operationRun->outcome)->toBe('succeeded');
|
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_schedule_run_id' => (int) $run->id,
|
||||||
'backup_set_id' => (int) $backupSet->id,
|
'backup_set_id' => (int) $backupSet->id,
|
||||||
]);
|
]);
|
||||||
|
expect($operationRun->summary_counts)->toMatchArray([
|
||||||
|
'created' => 1,
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
use App\Jobs\RunBackupScheduleJob;
|
use App\Jobs\RunBackupScheduleJob;
|
||||||
use App\Models\BackupSchedule;
|
use App\Models\BackupSchedule;
|
||||||
use App\Models\BackupScheduleRun;
|
use App\Models\BackupScheduleRun;
|
||||||
use App\Models\BulkOperationRun;
|
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Notifications\OperationRunQueued;
|
use App\Notifications\OperationRunQueued;
|
||||||
@ -68,14 +67,6 @@
|
|||||||
'backup_schedule_run_id' => (int) $run->id,
|
'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 {
|
Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
|
||||||
return $job->backupScheduleRunId === (int) $run->id
|
return $job->backupScheduleRunId === (int) $run->id
|
||||||
&& $job->operationRun instanceof OperationRun
|
&& $job->operationRun instanceof OperationRun
|
||||||
@ -139,14 +130,6 @@
|
|||||||
'backup_schedule_run_id' => (int) $run->id,
|
'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 {
|
Queue::assertPushed(RunBackupScheduleJob::class, function (RunBackupScheduleJob $job) use ($run, $operationRun): bool {
|
||||||
return $job->backupScheduleRunId === (int) $run->id
|
return $job->backupScheduleRunId === (int) $run->id
|
||||||
&& $job->operationRun instanceof OperationRun
|
&& $job->operationRun instanceof OperationRun
|
||||||
@ -259,14 +242,6 @@
|
|||||||
->count())
|
->count())
|
||||||
->toBe(2);
|
->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);
|
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||||
$this->assertDatabaseCount('notifications', 1);
|
$this->assertDatabaseCount('notifications', 1);
|
||||||
$this->assertDatabaseHas('notifications', [
|
$this->assertDatabaseHas('notifications', [
|
||||||
@ -330,14 +305,6 @@
|
|||||||
->count())
|
->count())
|
||||||
->toBe(2);
|
->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);
|
Queue::assertPushed(RunBackupScheduleJob::class, 2);
|
||||||
$this->assertDatabaseCount('notifications', 1);
|
$this->assertDatabaseCount('notifications', 1);
|
||||||
$this->assertDatabaseHas('notifications', [
|
$this->assertDatabaseHas('notifications', [
|
||||||
|
|||||||
@ -3,13 +3,13 @@
|
|||||||
use App\Jobs\AddPoliciesToBackupSetJob;
|
use App\Jobs\AddPoliciesToBackupSetJob;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Intune\FoundationSnapshotService;
|
use App\Services\Intune\FoundationSnapshotService;
|
||||||
use App\Services\Intune\PolicyCaptureOrchestrator;
|
use App\Services\Intune\PolicyCaptureOrchestrator;
|
||||||
use App\Services\Intune\SnapshotValidator;
|
use App\Services\Intune\SnapshotValidator;
|
||||||
|
use App\Services\OperationRunService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Mockery\MockInterface;
|
use Mockery\MockInterface;
|
||||||
|
|
||||||
@ -44,23 +44,19 @@
|
|||||||
'snapshot' => ['id' => $policyA->external_id],
|
'snapshot' => ['id' => $policyA->external_id],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$run = BulkOperationRun::factory()->create([
|
$run = OperationRun::factory()->create([
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
'user_id' => $user->id,
|
'user_id' => $user->id,
|
||||||
'resource' => 'backup_set',
|
'initiator_name' => $user->name,
|
||||||
'action' => 'add_policies',
|
'type' => 'backup_set.add_policies',
|
||||||
'status' => 'pending',
|
'status' => 'queued',
|
||||||
'total_items' => 2,
|
'outcome' => 'pending',
|
||||||
'item_ids' => [
|
'context' => [
|
||||||
'backup_set_id' => $backupSet->id,
|
'backup_set_id' => (int) $backupSet->getKey(),
|
||||||
'policy_ids' => [$policyA->id, $policyB->id],
|
'policy_ids' => [(int) $policyA->getKey(), (int) $policyB->getKey()],
|
||||||
'options' => [
|
|
||||||
'include_assignments' => true,
|
|
||||||
'include_scope_tags' => true,
|
|
||||||
'include_foundations' => false,
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
'failures' => [],
|
'summary_counts' => [],
|
||||||
|
'failure_summary' => [],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($policyA, $policyB, $tenant, $versionA) {
|
$this->mock(PolicyCaptureOrchestrator::class, function (MockInterface $mock) use ($policyA, $policyB, $tenant, $versionA) {
|
||||||
@ -107,15 +103,21 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
$job = new AddPoliciesToBackupSetJob(
|
$job = new AddPoliciesToBackupSetJob(
|
||||||
bulkRunId: (int) $run->getKey(),
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
backupSetId: (int) $backupSet->getKey(),
|
backupSetId: (int) $backupSet->getKey(),
|
||||||
includeAssignments: true,
|
policyIds: [(int) $policyA->getKey(), (int) $policyB->getKey()],
|
||||||
includeScopeTags: true,
|
options: [
|
||||||
includeFoundations: false,
|
'include_assignments' => true,
|
||||||
|
'include_scope_tags' => true,
|
||||||
|
'include_foundations' => false,
|
||||||
|
],
|
||||||
|
idempotencyKey: 'test-idempotency-key',
|
||||||
|
operationRun: $run,
|
||||||
);
|
);
|
||||||
|
|
||||||
$job->handle(
|
$job->handle(
|
||||||
bulkOperationService: app(BulkOperationService::class),
|
operationRunService: app(OperationRunService::class),
|
||||||
captureOrchestrator: app(PolicyCaptureOrchestrator::class),
|
captureOrchestrator: app(PolicyCaptureOrchestrator::class),
|
||||||
foundationSnapshots: $this->mock(FoundationSnapshotService::class),
|
foundationSnapshots: $this->mock(FoundationSnapshotService::class),
|
||||||
snapshotValidator: app(SnapshotValidator::class),
|
snapshotValidator: app(SnapshotValidator::class),
|
||||||
@ -124,23 +126,23 @@
|
|||||||
$run->refresh();
|
$run->refresh();
|
||||||
$backupSet->refresh();
|
$backupSet->refresh();
|
||||||
|
|
||||||
expect($run->status)->toBe('completed_with_errors');
|
expect($run->status)->toBe('completed');
|
||||||
expect($run->total_items)->toBe(2);
|
expect($run->outcome)->toBe('partially_succeeded');
|
||||||
expect($run->processed_items)->toBe(2);
|
expect((int) ($run->summary_counts['total'] ?? 0))->toBe(2);
|
||||||
expect($run->succeeded)->toBe(1);
|
expect((int) ($run->summary_counts['processed'] ?? 0))->toBe(2);
|
||||||
expect($run->failed)->toBe(1);
|
expect((int) ($run->summary_counts['succeeded'] ?? 0))->toBe(1);
|
||||||
expect($run->skipped)->toBe(0);
|
expect((int) ($run->summary_counts['failed'] ?? 0))->toBe(1);
|
||||||
|
expect((int) ($run->summary_counts['skipped'] ?? 0))->toBe(0);
|
||||||
|
|
||||||
expect(BackupItem::query()
|
expect(BackupItem::query()
|
||||||
->where('backup_set_id', $backupSet->id)
|
->where('backup_set_id', $backupSet->id)
|
||||||
->where('policy_id', $policyA->id)
|
->where('policy_id', $policyA->id)
|
||||||
->exists())->toBeTrue();
|
->exists())->toBeTrue();
|
||||||
|
|
||||||
$failureEntry = collect($run->failures ?? [])
|
$failureEntry = collect($run->failure_summary ?? [])
|
||||||
->firstWhere('item_id', (string) $policyB->id);
|
->first(fn ($entry): bool => is_array($entry) && (($entry['code'] ?? null) === 'graph.graph_forbidden'));
|
||||||
|
|
||||||
expect($failureEntry)->not->toBeNull();
|
expect($failureEntry)->not->toBeNull();
|
||||||
expect($failureEntry['reason_code'] ?? null)->toBe('graph_forbidden');
|
|
||||||
|
|
||||||
expect($backupSet->status)->toBe('partial');
|
expect($backupSet->status)->toBe('partial');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -4,7 +4,6 @@
|
|||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\OperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Services\BulkOperationService;
|
|
||||||
use App\Services\Intune\AuditLogger;
|
use App\Services\Intune\AuditLogger;
|
||||||
use App\Support\OperationRunLinks;
|
use App\Support\OperationRunLinks;
|
||||||
use Filament\Notifications\DatabaseNotification;
|
use Filament\Notifications\DatabaseNotification;
|
||||||
@ -50,7 +49,7 @@
|
|||||||
operationRun: $opRun,
|
operationRun: $opRun,
|
||||||
);
|
);
|
||||||
|
|
||||||
$job->handle(app(AuditLogger::class), app(BulkOperationService::class));
|
$job->handle(app(AuditLogger::class));
|
||||||
|
|
||||||
$this->assertDatabaseHas('notifications', [
|
$this->assertDatabaseHas('notifications', [
|
||||||
'notifiable_id' => $user->getKey(),
|
'notifiable_id' => $user->getKey(),
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
use App\Filament\Resources\BackupSetResource;
|
use App\Filament\Resources\BackupSetResource;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -51,14 +51,15 @@
|
|||||||
|
|
||||||
$sets->each(fn (BackupSet $set) => expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue());
|
$sets->each(fn (BackupSet $set) => expect(BackupSet::withTrashed()->find($set->id)?->trashed())->toBeTrue());
|
||||||
|
|
||||||
$bulkRun = BulkOperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
->where('resource', 'backup_set')
|
->where('tenant_id', $tenant->id)
|
||||||
->where('action', 'delete')
|
->where('user_id', $user->id)
|
||||||
|
->where('type', 'backup_set.delete')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
expect($bulkRun)->not->toBeNull();
|
expect($opRun)->not->toBeNull();
|
||||||
expect($bulkRun->status)->toBe('completed');
|
expect($opRun->status)->toBe('completed');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('backup sets can be archived even when referenced by restore runs', function () {
|
test('backup sets can be archived even when referenced by restore runs', function () {
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -56,12 +56,14 @@
|
|||||||
$completedRuns->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue());
|
$completedRuns->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue());
|
||||||
expect(RestoreRun::withTrashed()->find($running->id)?->trashed())->toBeFalse();
|
expect(RestoreRun::withTrashed()->find($running->id)?->trashed())->toBeFalse();
|
||||||
|
|
||||||
$bulkRun = BulkOperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
->where('resource', 'restore_run')
|
->where('tenant_id', $tenant->id)
|
||||||
->where('action', 'delete')
|
->where('user_id', $user->id)
|
||||||
|
->where('type', 'restore_run.delete')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
expect($bulkRun)->not->toBeNull();
|
expect($opRun)->not->toBeNull();
|
||||||
expect($bulkRun->skipped)->toBeGreaterThanOrEqual(1);
|
$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\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\OperationRunService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Facades\Queue;
|
use Illuminate\Support\Facades\Queue;
|
||||||
|
|
||||||
@ -18,13 +18,31 @@
|
|||||||
$policies = Policy::factory()->count(25)->create(['tenant_id' => $tenant->id]);
|
$policies = Policy::factory()->count(25)->create(['tenant_id' => $tenant->id]);
|
||||||
$policyIds = $policies->pluck('id')->toArray();
|
$policyIds = $policies->pluck('id')->toArray();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
/** @var OperationRunService $service */
|
||||||
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 25);
|
$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)
|
// 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) {
|
Queue::assertPushed(BulkPolicyDeleteJob::class, function ($job) use ($tenant, $user, $opRun, $policyIds) {
|
||||||
return $job->bulkRunId === $run->id;
|
return $job->tenantId === (int) $tenant->getKey()
|
||||||
|
&& $job->userId === (int) $user->getKey()
|
||||||
|
&& $job->operationRun?->getKey() === $opRun->getKey()
|
||||||
|
&& $job->policyIds === $policyIds;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Jobs\BulkPolicyDeleteJob;
|
use App\Jobs\BulkPolicyDeleteJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\OperationRunService;
|
||||||
|
use App\Services\Operations\BulkSelectionIdentity;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@ -15,18 +17,45 @@
|
|||||||
$policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]);
|
$policies = Policy::factory()->count(10)->create(['tenant_id' => $tenant->id]);
|
||||||
$policyIds = $policies->pluck('id')->toArray();
|
$policyIds = $policies->pluck('id')->toArray();
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
/** @var OperationRunService $service */
|
||||||
$run = $service->createRun($tenant, $user, 'policy', 'delete', $policyIds, 10);
|
$service = app(OperationRunService::class);
|
||||||
|
|
||||||
// Simulate Sync execution
|
/** @var BulkSelectionIdentity $selection */
|
||||||
BulkPolicyDeleteJob::dispatchSync($run->id);
|
$selection = app(BulkSelectionIdentity::class);
|
||||||
|
|
||||||
$run->refresh();
|
$selectionIdentity = $selection->fromIds($policyIds);
|
||||||
expect($run->status)->toBe('completed')
|
|
||||||
->and($run->processed_items)->toBe(10)
|
|
||||||
->and($run->audit_log_id)->not->toBeNull();
|
|
||||||
|
|
||||||
expect(\App\Models\AuditLog::where('action', 'bulk.policy.delete.completed')->exists())->toBeTrue();
|
$opRun = $service->enqueueBulkOperation(
|
||||||
|
tenant: $tenant,
|
||||||
|
type: 'policy.delete',
|
||||||
|
targetScope: [
|
||||||
|
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id ?? $tenant->getKey()),
|
||||||
|
],
|
||||||
|
selectionIdentity: $selectionIdentity,
|
||||||
|
dispatcher: function ($operationRun) use ($tenant, $user, $policyIds): void {
|
||||||
|
// Simulate sync execution (workers will run immediately on sync queue)
|
||||||
|
BulkPolicyDeleteJob::dispatchSync(
|
||||||
|
tenantId: (int) $tenant->getKey(),
|
||||||
|
userId: (int) $user->getKey(),
|
||||||
|
policyIds: $policyIds,
|
||||||
|
operationRun: $operationRun,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
initiator: $user,
|
||||||
|
emitQueuedNotification: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
$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,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(($opRun->summary_counts['failed'] ?? 0))->toBe(0);
|
||||||
|
|
||||||
$policies->each(function ($policy) {
|
$policies->each(function ($policy) {
|
||||||
expect($policy->refresh()->ignored_at)->not->toBeNull();
|
expect($policy->refresh()->ignored_at)->not->toBeNull();
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use App\Filament\Resources\RestoreRunResource;
|
use App\Filament\Resources\RestoreRunResource;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\RestoreRun;
|
use App\Models\RestoreRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@ -45,12 +45,13 @@
|
|||||||
|
|
||||||
$runs->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue());
|
$runs->each(fn (RestoreRun $run) => expect(RestoreRun::withTrashed()->find($run->id)?->trashed())->toBeTrue());
|
||||||
|
|
||||||
$bulkRun = BulkOperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
->where('resource', 'restore_run')
|
->where('tenant_id', $tenant->id)
|
||||||
->where('action', 'delete')
|
->where('user_id', $user->id)
|
||||||
|
->where('type', 'restore_run.delete')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
expect($bulkRun)->not->toBeNull();
|
expect($opRun)->not->toBeNull();
|
||||||
expect($bulkRun->status)->toBe('completed');
|
expect($opRun->status)->toBe('completed');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Jobs\BulkPolicyExportJob;
|
use App\Jobs\BulkPolicyExportJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\OperationRunService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@ -26,23 +27,38 @@
|
|||||||
|
|
||||||
$missingVersionPolicy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
$missingVersionPolicy = Policy::factory()->create(['tenant_id' => $tenant->id]);
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
/** @var OperationRunService $service */
|
||||||
$run = $service->createRun(
|
$service = app(OperationRunService::class);
|
||||||
$tenant,
|
|
||||||
$user,
|
$opRun = $service->ensureRun(
|
||||||
'policy',
|
tenant: $tenant,
|
||||||
'export',
|
type: 'policy.export',
|
||||||
[$okPolicy->id, $missingVersionPolicy->id],
|
inputs: [
|
||||||
2
|
'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();
|
$opRun->refresh();
|
||||||
expect($run->status)->toBe('completed_with_errors')
|
expect($opRun)->toBeInstanceOf(OperationRun::class);
|
||||||
->and($run->succeeded)->toBe(1)
|
expect($opRun->status)->toBe('completed');
|
||||||
->and($run->failed)->toBe(1)
|
expect($opRun->outcome)->toBe('partially_succeeded');
|
||||||
->and($run->processed_items)->toBe(2);
|
expect($opRun->summary_counts)->toMatchArray([
|
||||||
|
'total' => 2,
|
||||||
|
'processed' => 2,
|
||||||
|
'succeeded' => 1,
|
||||||
|
'failed' => 1,
|
||||||
|
'created' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
$this->assertDatabaseHas('backup_sets', [
|
$this->assertDatabaseHas('backup_sets', [
|
||||||
'tenant_id' => $tenant->id,
|
'tenant_id' => $tenant->id,
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Jobs\BulkPolicyExportJob;
|
use App\Jobs\BulkPolicyExportJob;
|
||||||
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\BulkOperationService;
|
use App\Services\OperationRunService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
@ -24,15 +25,33 @@
|
|||||||
'captured_at' => now(),
|
'captured_at' => now(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$service = app(BulkOperationService::class);
|
$opRun = OperationRun::create([
|
||||||
$run = $service->createRun($tenant, $user, 'policy', 'export', [$policy->id], 1);
|
'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
|
// Simulate Sync
|
||||||
$job = new BulkPolicyExportJob($run->id, 'Feature Backup');
|
$job = new BulkPolicyExportJob(
|
||||||
$job->handle($service);
|
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();
|
$opRun->refresh();
|
||||||
expect($run->status)->toBe('completed');
|
expect($opRun->status)->toBe('completed');
|
||||||
|
|
||||||
$this->assertDatabaseHas('backup_sets', [
|
$this->assertDatabaseHas('backup_sets', [
|
||||||
'name' => 'Feature Backup',
|
'name' => 'Feature Backup',
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
use App\Filament\Resources\BackupSetResource;
|
use App\Filament\Resources\BackupSetResource;
|
||||||
use App\Models\BackupItem;
|
use App\Models\BackupItem;
|
||||||
use App\Models\BackupSet;
|
use App\Models\BackupSet;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Filament\Facades\Filament;
|
use Filament\Facades\Filament;
|
||||||
@ -50,12 +50,13 @@
|
|||||||
expect(BackupSet::withTrashed()->find($set->id))->toBeNull();
|
expect(BackupSet::withTrashed()->find($set->id))->toBeNull();
|
||||||
expect(BackupItem::withTrashed()->find($item->id))->toBeNull();
|
expect(BackupItem::withTrashed()->find($item->id))->toBeNull();
|
||||||
|
|
||||||
$bulkRun = BulkOperationRun::query()
|
$opRun = OperationRun::query()
|
||||||
->where('resource', 'backup_set')
|
->where('tenant_id', $tenant->id)
|
||||||
->where('action', 'force_delete')
|
->where('user_id', $user->id)
|
||||||
|
->where('type', 'backup_set.force_delete')
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
expect($bulkRun)->not->toBeNull();
|
expect($opRun)->not->toBeNull();
|
||||||
expect($bulkRun->status)->toBe('completed');
|
expect($opRun->status)->toBe('completed');
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Filament\Resources\PolicyVersionResource;
|
use App\Filament\Resources\PolicyVersionResource;
|
||||||
use App\Models\BulkOperationRun;
|
use App\Models\OperationRun;
|
||||||
use App\Models\Policy;
|
use App\Models\Policy;
|
||||||
use App\Models\PolicyVersion;
|
use App\Models\PolicyVersion;
|
||||||
use App\Models\Tenant;
|
use App\Models\Tenant;
|
||||||
@ -38,18 +38,19 @@
|
|||||||
])
|
])
|
||||||
->assertHasNoTableBulkActionErrors();
|
->assertHasNoTableBulkActionErrors();
|
||||||
|
|
||||||
$run = BulkOperationRun::query()
|
$run = OperationRun::query()
|
||||||
->where('tenant_id', $tenant->id)
|
->where('tenant_id', $tenant->id)
|
||||||
->where('user_id', $user->id)
|
->where('user_id', $user->id)
|
||||||
->where('resource', 'policy_version')
|
->where('type', 'policy_version.force_delete')
|
||||||
->where('action', 'force_delete')
|
|
||||||
->latest('id')
|
->latest('id')
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
expect($run)->not->toBeNull();
|
expect($run)->not->toBeNull();
|
||||||
expect($run->succeeded)->toBe(1)
|
|
||||||
->and($run->skipped)->toBe(0)
|
$counts = is_array($run->summary_counts) ? $run->summary_counts : [];
|
||||||
->and($run->failed)->toBe(0);
|
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();
|
expect(PolicyVersion::withTrashed()->whereKey($version->id)->exists())->toBeFalse();
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user