feat(spec-087): remove legacy runs
This commit is contained in:
parent
1acbf8cc54
commit
681e27c0bf
3
.github/agents/copilot-instructions.md
vendored
3
.github/agents/copilot-instructions.md
vendored
@ -27,6 +27,7 @@ ## Active Technologies
|
||||
- PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4, Tailwind CSS v4, Laravel Sail (085-tenant-operate-hub)
|
||||
- PostgreSQL (primary) + session (workspace context + last-tenant memory) (085-tenant-operate-hub)
|
||||
- PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4 (085-tenant-operate-hub)
|
||||
- PostgreSQL (Sail), SQLite in tests (087-legacy-runs-removal)
|
||||
|
||||
- PHP 8.4.15 (feat/005-bulk-operations)
|
||||
|
||||
@ -46,8 +47,8 @@ ## Code Style
|
||||
PHP 8.4.15: Follow standard conventions
|
||||
|
||||
## Recent Changes
|
||||
- 087-legacy-runs-removal: Added PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4
|
||||
- 088-remove-tenant-graphoptions-legacy: Added PHP 8.4.15 (Laravel 12) + Filament v5, Livewire v4, Pest v4
|
||||
- 086-retire-legacy-runs-into-operation-runs: Spec docs updated (PHP 8.4.15 + Laravel 12, Filament v5, Livewire v4)
|
||||
- 085-tenant-operate-hub: Added PHP 8.4 (Laravel 12) + Filament v5, Livewire v4, Laravel Sail, Tailwind CSS v4
|
||||
<!-- MANUAL ADDITIONS START -->
|
||||
<!-- MANUAL ADDITIONS END -->
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\OperationRunService;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
@ -50,7 +50,7 @@ public function handle(): int
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRunWithIdentityStrict(
|
||||
tenant: $tenant,
|
||||
type: 'directory_groups.sync',
|
||||
type: 'entra_group_sync',
|
||||
identityInputs: [
|
||||
'selection_key' => $selectionKey,
|
||||
'slot_key' => $slotKey,
|
||||
@ -65,6 +65,7 @@ public function handle(): int
|
||||
|
||||
if (! $opRun->wasRecentlyCreated) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
@ -14,6 +13,7 @@
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use RuntimeException;
|
||||
|
||||
class TenantpilotPurgeNonPersistentData extends Command
|
||||
@ -80,10 +80,6 @@ public function handle(): int
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($tenant): void {
|
||||
BackupScheduleRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
|
||||
BackupSchedule::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->delete();
|
||||
@ -117,6 +113,8 @@ public function handle(): int
|
||||
->delete();
|
||||
});
|
||||
|
||||
$this->recordPurgeOperationRun($tenant, $counts);
|
||||
|
||||
$this->info('Purged.');
|
||||
}
|
||||
|
||||
@ -150,7 +148,6 @@ private function resolveTenants()
|
||||
private function countsForTenant(Tenant $tenant): array
|
||||
{
|
||||
return [
|
||||
'backup_schedule_runs' => BackupScheduleRun::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'backup_schedules' => BackupSchedule::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'operation_runs' => OperationRun::query()->where('tenant_id', $tenant->id)->count(),
|
||||
'audit_logs' => AuditLog::query()->where('tenant_id', $tenant->id)->count(),
|
||||
@ -161,4 +158,39 @@ private function countsForTenant(Tenant $tenant): array
|
||||
'policies' => Policy::query()->where('tenant_id', $tenant->id)->count(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $counts
|
||||
*/
|
||||
private function recordPurgeOperationRun(Tenant $tenant, array $counts): void
|
||||
{
|
||||
OperationRun::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->id,
|
||||
'user_id' => null,
|
||||
'initiator_name' => 'System',
|
||||
'type' => 'backup_schedule_purge',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'run_identity_hash' => hash('sha256', implode(':', [
|
||||
(string) $tenant->id,
|
||||
'backup_schedule_purge',
|
||||
now()->toISOString(),
|
||||
Str::uuid()->toString(),
|
||||
])),
|
||||
'summary_counts' => [
|
||||
'total' => array_sum($counts),
|
||||
'processed' => array_sum($counts),
|
||||
'succeeded' => array_sum($counts),
|
||||
'failed' => 0,
|
||||
],
|
||||
'failure_summary' => [],
|
||||
'context' => [
|
||||
'source' => 'tenantpilot:purge-nonpersistent',
|
||||
'deleted_rows' => $counts,
|
||||
],
|
||||
'started_at' => now(),
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,11 +2,11 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OpsUx\RunFailureSanitizer;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||
@ -16,7 +16,7 @@ class TenantpilotReconcileBackupScheduleOperationRuns extends Command
|
||||
{--older-than=5 : Only reconcile runs older than N minutes}
|
||||
{--dry-run : Do not write changes}';
|
||||
|
||||
protected $description = 'Reconcile stuck backup schedule OperationRuns against BackupScheduleRun status.';
|
||||
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
|
||||
|
||||
public function handle(OperationRunService $operationRunService): int
|
||||
{
|
||||
@ -25,7 +25,7 @@ public function handle(OperationRunService $operationRunService): int
|
||||
$dryRun = (bool) $this->option('dry-run');
|
||||
|
||||
$query = OperationRun::query()
|
||||
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
|
||||
->where('type', 'backup_schedule_run')
|
||||
->whereIn('status', ['queued', 'running']);
|
||||
|
||||
if ($olderThanMinutes > 0) {
|
||||
@ -49,29 +49,18 @@ public function handle(OperationRunService $operationRunService): int
|
||||
$failed = 0;
|
||||
|
||||
foreach ($query->cursor() as $operationRun) {
|
||||
$backupScheduleRunId = data_get($operationRun->context, 'backup_schedule_run_id');
|
||||
$backupScheduleId = data_get($operationRun->context, 'backup_schedule_id');
|
||||
|
||||
if (! is_numeric($backupScheduleRunId)) {
|
||||
$skipped++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$scheduleRun = BackupScheduleRun::query()
|
||||
->whereKey((int) $backupScheduleRunId)
|
||||
->where('tenant_id', $operationRun->tenant_id)
|
||||
->first();
|
||||
|
||||
if (! $scheduleRun) {
|
||||
if (! is_numeric($backupScheduleId)) {
|
||||
if (! $dryRun) {
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: 'completed',
|
||||
outcome: 'failed',
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'backup_schedule_run.not_found',
|
||||
'message' => RunFailureSanitizer::sanitizeMessage('Backup schedule run not found.'),
|
||||
'code' => 'backup_schedule.missing_context',
|
||||
'message' => 'Backup schedule context is missing from this operation run.',
|
||||
],
|
||||
],
|
||||
);
|
||||
@ -82,13 +71,34 @@ public function handle(OperationRunService $operationRunService): int
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($scheduleRun->status === BackupScheduleRun::STATUS_RUNNING) {
|
||||
if (! $dryRun) {
|
||||
$operationRunService->updateRun($operationRun, 'running', 'pending');
|
||||
$schedule = BackupSchedule::query()
|
||||
->whereKey((int) $backupScheduleId)
|
||||
->where('tenant_id', (int) $operationRun->tenant_id)
|
||||
->first();
|
||||
|
||||
if ($scheduleRun->started_at) {
|
||||
$operationRun->forceFill(['started_at' => $scheduleRun->started_at])->save();
|
||||
}
|
||||
if (! $schedule instanceof BackupSchedule) {
|
||||
if (! $dryRun) {
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: 'completed',
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'backup_schedule.not_found',
|
||||
'message' => 'Backup schedule not found for this operation run.',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$failed++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($operationRun->status === 'queued' && $operationRunService->isStaleQueuedRun($operationRun, max(1, $olderThanMinutes))) {
|
||||
if (! $dryRun) {
|
||||
$operationRunService->failStaleQueuedRun($operationRun, 'Backup schedule run was queued but never started.');
|
||||
}
|
||||
|
||||
$reconciled++;
|
||||
@ -96,104 +106,27 @@ public function handle(OperationRunService $operationRunService): int
|
||||
continue;
|
||||
}
|
||||
|
||||
$outcome = match ($scheduleRun->status) {
|
||||
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
|
||||
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
|
||||
BackupScheduleRun::STATUS_SKIPPED => 'succeeded',
|
||||
BackupScheduleRun::STATUS_CANCELED => 'failed',
|
||||
default => 'failed',
|
||||
};
|
||||
|
||||
$summary = is_array($scheduleRun->summary) ? $scheduleRun->summary : [];
|
||||
$syncFailures = $summary['sync_failures'] ?? [];
|
||||
|
||||
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
||||
$policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0);
|
||||
$syncFailuresCount = is_array($syncFailures) ? count($syncFailures) : 0;
|
||||
|
||||
$processed = $policiesBackedUp + $syncFailuresCount;
|
||||
if ($policiesTotal > 0) {
|
||||
$processed = min($policiesTotal, $processed);
|
||||
}
|
||||
|
||||
$summaryCounts = array_filter([
|
||||
'total' => $policiesTotal,
|
||||
'processed' => $processed,
|
||||
'succeeded' => $policiesBackedUp,
|
||||
'failed' => $syncFailuresCount,
|
||||
'skipped' => $scheduleRun->status === BackupScheduleRun::STATUS_SKIPPED ? 1 : 0,
|
||||
'items' => $policiesTotal,
|
||||
], fn (mixed $value): bool => is_int($value) && $value !== 0);
|
||||
|
||||
$failures = [];
|
||||
|
||||
if ($scheduleRun->status === BackupScheduleRun::STATUS_CANCELED) {
|
||||
$failures[] = [
|
||||
'code' => 'backup_schedule_run.cancelled',
|
||||
'message' => 'Backup schedule run was cancelled.',
|
||||
];
|
||||
}
|
||||
|
||||
if (filled($scheduleRun->error_message) || filled($scheduleRun->error_code)) {
|
||||
$failures[] = [
|
||||
'code' => (string) ($scheduleRun->error_code ?: 'backup_schedule_run.error'),
|
||||
'message' => RunFailureSanitizer::sanitizeMessage((string) ($scheduleRun->error_message ?: 'Backup schedule run failed.')),
|
||||
];
|
||||
}
|
||||
|
||||
if (is_array($syncFailures)) {
|
||||
foreach ($syncFailures as $failure) {
|
||||
if (! is_array($failure)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$policyType = (string) ($failure['policy_type'] ?? 'unknown');
|
||||
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
|
||||
$errors = $failure['errors'] ?? null;
|
||||
|
||||
$firstErrorMessage = null;
|
||||
if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) {
|
||||
$firstErrorMessage = $errors[0]['message'] ?? null;
|
||||
}
|
||||
|
||||
$message = $status !== null
|
||||
? "{$policyType}: Graph returned {$status}"
|
||||
: "{$policyType}: Graph request failed";
|
||||
|
||||
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
|
||||
$message .= ' - '.trim($firstErrorMessage);
|
||||
}
|
||||
|
||||
$failures[] = [
|
||||
'code' => $status !== null ? "graph.http_{$status}" : 'graph.error',
|
||||
'message' => RunFailureSanitizer::sanitizeMessage($message),
|
||||
];
|
||||
if ($operationRun->status === 'running') {
|
||||
if (! $dryRun) {
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: 'completed',
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
failures: [
|
||||
[
|
||||
'code' => 'backup_schedule.stalled',
|
||||
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
$reconciled++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $dryRun) {
|
||||
$operationRun->update([
|
||||
'context' => array_merge($operationRun->context ?? [], [
|
||||
'backup_schedule_id' => (int) $scheduleRun->backup_schedule_id,
|
||||
'backup_schedule_run_id' => (int) $scheduleRun->getKey(),
|
||||
]),
|
||||
]);
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$operationRun,
|
||||
status: 'completed',
|
||||
outcome: $outcome,
|
||||
summaryCounts: $summaryCounts,
|
||||
failures: $failures,
|
||||
);
|
||||
|
||||
$operationRun->forceFill([
|
||||
'started_at' => $scheduleRun->started_at ?? $operationRun->started_at,
|
||||
'completed_at' => $scheduleRun->finished_at ?? $operationRun->completed_at,
|
||||
])->save();
|
||||
}
|
||||
|
||||
$reconciled++;
|
||||
$skipped++;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
|
||||
@ -3,10 +3,8 @@
|
||||
namespace App\Filament\Pages;
|
||||
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Filament\Resources\InventorySyncRunResource;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -16,6 +14,8 @@
|
||||
use App\Services\Operations\BulkSelectionIdentity;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use BackedEnum;
|
||||
@ -67,21 +67,35 @@ public function mount(): void
|
||||
abort(403, 'Not allowed');
|
||||
}
|
||||
|
||||
$latestSuccessful = InventorySyncRun::query()
|
||||
$latestSuccessful = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('status', InventorySyncRun::STATUS_SUCCESS)
|
||||
->whereNotNull('finished_at')
|
||||
->orderByDesc('finished_at')
|
||||
->where('type', 'inventory_sync')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereIn('outcome', [
|
||||
OperationRunOutcome::Succeeded->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
])
|
||||
->whereNotNull('completed_at')
|
||||
->orderByDesc('completed_at')
|
||||
->first();
|
||||
|
||||
if (! $latestSuccessful instanceof InventorySyncRun) {
|
||||
if (! $latestSuccessful instanceof OperationRun) {
|
||||
$this->state = 'blocked';
|
||||
$this->message = 'No successful inventory runs found yet.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$scopeKey = (string) $latestSuccessful->selection_hash;
|
||||
$latestContext = is_array($latestSuccessful->context) ? $latestSuccessful->context : [];
|
||||
$scopeKey = (string) ($latestContext['selection_hash'] ?? '');
|
||||
|
||||
if ($scopeKey === '') {
|
||||
$this->state = 'blocked';
|
||||
$this->message = 'No inventory scope key was found on the latest successful inventory run.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->scopeKey = $scopeKey;
|
||||
|
||||
$selector = app(DriftRunSelector::class);
|
||||
@ -100,15 +114,15 @@ public function mount(): void
|
||||
$this->baselineRunId = (int) $baseline->getKey();
|
||||
$this->currentRunId = (int) $current->getKey();
|
||||
|
||||
$this->baselineFinishedAt = $baseline->finished_at?->toDateTimeString();
|
||||
$this->currentFinishedAt = $current->finished_at?->toDateTimeString();
|
||||
$this->baselineFinishedAt = $baseline->completed_at?->toDateTimeString();
|
||||
$this->currentFinishedAt = $current->completed_at?->toDateTimeString();
|
||||
|
||||
$existingOperationRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'drift.generate')
|
||||
->where('type', 'drift_generate_findings')
|
||||
->where('context->scope_key', $scopeKey)
|
||||
->where('context->baseline_run_id', (int) $baseline->getKey())
|
||||
->where('context->current_run_id', (int) $current->getKey())
|
||||
->where('context->baseline_operation_run_id', (int) $baseline->getKey())
|
||||
->where('context->current_operation_run_id', (int) $current->getKey())
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
@ -120,8 +134,8 @@ public function mount(): void
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('scope_key', $scopeKey)
|
||||
->where('baseline_run_id', $baseline->getKey())
|
||||
->where('current_run_id', $current->getKey())
|
||||
->where('baseline_operation_run_id', $baseline->getKey())
|
||||
->where('current_operation_run_id', $current->getKey())
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
@ -130,8 +144,8 @@ public function mount(): void
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('finding_type', Finding::FINDING_TYPE_DRIFT)
|
||||
->where('scope_key', $scopeKey)
|
||||
->where('baseline_run_id', $baseline->getKey())
|
||||
->where('current_run_id', $current->getKey())
|
||||
->where('baseline_operation_run_id', $baseline->getKey())
|
||||
->where('current_operation_run_id', $current->getKey())
|
||||
->where('status', Finding::STATUS_NEW)
|
||||
->count();
|
||||
|
||||
@ -189,8 +203,8 @@ public function mount(): void
|
||||
$selection = app(BulkSelectionIdentity::class);
|
||||
$selectionIdentity = $selection->fromQuery([
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => (int) $baseline->getKey(),
|
||||
'current_run_id' => (int) $current->getKey(),
|
||||
'baseline_operation_run_id' => (int) $baseline->getKey(),
|
||||
'current_operation_run_id' => (int) $current->getKey(),
|
||||
]);
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
@ -198,7 +212,7 @@ public function mount(): void
|
||||
|
||||
$opRun = $opService->enqueueBulkOperation(
|
||||
tenant: $tenant,
|
||||
type: 'drift.generate',
|
||||
type: 'drift_generate_findings',
|
||||
targetScope: [
|
||||
'entra_tenant_id' => (string) ($tenant->tenant_id ?? $tenant->external_id),
|
||||
],
|
||||
@ -216,8 +230,8 @@ public function mount(): void
|
||||
initiator: $user,
|
||||
extraContext: [
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => (int) $baseline->getKey(),
|
||||
'current_run_id' => (int) $current->getKey(),
|
||||
'baseline_operation_run_id' => (int) $baseline->getKey(),
|
||||
'current_operation_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
emitQueuedNotification: false,
|
||||
);
|
||||
@ -261,7 +275,7 @@ public function getBaselineRunUrl(): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
return InventorySyncRunResource::getUrl('view', ['record' => $this->baselineRunId], tenant: Tenant::current());
|
||||
return route('admin.operations.view', ['run' => $this->baselineRunId]);
|
||||
}
|
||||
|
||||
public function getCurrentRunUrl(): ?string
|
||||
@ -270,7 +284,7 @@ public function getCurrentRunUrl(): ?string
|
||||
return null;
|
||||
}
|
||||
|
||||
return InventorySyncRunResource::getUrl('view', ['record' => $this->currentRunId], tenant: Tenant::current());
|
||||
return route('admin.operations.view', ['run' => $this->currentRunId]);
|
||||
}
|
||||
|
||||
public function getOperationRunUrl(): ?string
|
||||
|
||||
@ -1707,7 +1707,7 @@ private function dispatchBootstrapJob(
|
||||
OperationRun $run,
|
||||
): void {
|
||||
match ($operationType) {
|
||||
'inventory.sync' => ProviderInventorySyncJob::dispatch(
|
||||
'inventory_sync' => ProviderInventorySyncJob::dispatch(
|
||||
tenantId: $tenantId,
|
||||
userId: $userId,
|
||||
providerConnectionId: $providerConnectionId,
|
||||
@ -1726,7 +1726,7 @@ private function dispatchBootstrapJob(
|
||||
private function resolveBootstrapCapability(string $operationType): ?string
|
||||
{
|
||||
return match ($operationType) {
|
||||
'inventory.sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||
'inventory_sync' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_INVENTORY_SYNC,
|
||||
'compliance.snapshot' => Capabilities::WORKSPACE_MANAGED_TENANT_ONBOARD_BOOTSTRAP_POLICY_SYNC,
|
||||
default => null,
|
||||
};
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
use App\Exceptions\InvalidPolicyTypeException;
|
||||
use App\Filament\Resources\BackupScheduleResource\Pages;
|
||||
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleOperationRunsRelationManager;
|
||||
use App\Filament\Resources\BackupScheduleResource\RelationManagers\BackupScheduleRunsRelationManager;
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\Tenant;
|
||||
@ -22,6 +21,7 @@
|
||||
use App\Support\Badges\TagBadgeDomain;
|
||||
use App\Support\Badges\TagBadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OpsUx\OperationUxPresenter;
|
||||
use App\Support\OpsUx\OpsUxBrowserEvents;
|
||||
use App\Support\Rbac\UiEnforcement;
|
||||
@ -282,32 +282,40 @@ public static function table(Table $table): Table
|
||||
->label('Last run status')
|
||||
->badge()
|
||||
->formatStateUsing(function (?string $state): string {
|
||||
if (! filled($state)) {
|
||||
$outcome = static::scheduleStatusToOutcome($state);
|
||||
|
||||
if (! filled($outcome)) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->label;
|
||||
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->label;
|
||||
})
|
||||
->color(function (?string $state): string {
|
||||
if (! filled($state)) {
|
||||
$outcome = static::scheduleStatusToOutcome($state);
|
||||
|
||||
if (! filled($outcome)) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->color;
|
||||
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->color;
|
||||
})
|
||||
->icon(function (?string $state): ?string {
|
||||
if (! filled($state)) {
|
||||
$outcome = static::scheduleStatusToOutcome($state);
|
||||
|
||||
if (! filled($outcome)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state)->icon;
|
||||
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome)->icon;
|
||||
})
|
||||
->iconColor(function (?string $state): string {
|
||||
if (! filled($state)) {
|
||||
$outcome = static::scheduleStatusToOutcome($state);
|
||||
|
||||
if (! filled($outcome)) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
$spec = BadgeRenderer::spec(BadgeDomain::BackupScheduleRunStatus, $state);
|
||||
$spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
|
||||
|
||||
return $spec->iconColor ?? $spec->color;
|
||||
}),
|
||||
@ -389,7 +397,7 @@ public static function table(Table $table): Table
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'backup_schedule.run_now',
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
@ -458,7 +466,7 @@ public static function table(Table $table): Table
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'backup_schedule.retry',
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
@ -552,7 +560,7 @@ public static function table(Table $table): Table
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'backup_schedule.run_now',
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
@ -649,7 +657,7 @@ public static function table(Table $table): Table
|
||||
$nonce = (string) Str::uuid();
|
||||
$operationRun = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'backup_schedule.retry',
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $record->getKey(),
|
||||
'nonce' => $nonce,
|
||||
@ -734,7 +742,6 @@ public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
BackupScheduleOperationRunsRelationManager::class,
|
||||
BackupScheduleRunsRelationManager::class,
|
||||
];
|
||||
}
|
||||
|
||||
@ -904,6 +911,18 @@ protected static function policyTypeLabelMap(): array
|
||||
->all();
|
||||
}
|
||||
|
||||
protected static function scheduleStatusToOutcome(?string $status): ?string
|
||||
{
|
||||
return match (strtolower(trim((string) $status))) {
|
||||
'running' => OperationRunOutcome::Pending->value,
|
||||
'success' => OperationRunOutcome::Succeeded->value,
|
||||
'partial' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
'skipped' => OperationRunOutcome::Blocked->value,
|
||||
'failed', 'canceled' => OperationRunOutcome::Failed->value,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
protected static function dayOfWeekOptions(): array
|
||||
{
|
||||
return [
|
||||
|
||||
@ -1,107 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\BackupScheduleResource\RelationManagers;
|
||||
|
||||
use App\Filament\Resources\BackupSetResource;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use Filament\Actions;
|
||||
use Filament\Resources\RelationManagers\RelationManager;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class BackupScheduleRunsRelationManager extends RelationManager
|
||||
{
|
||||
protected static string $relationship = 'runs';
|
||||
|
||||
public function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->modifyQueryUsing(fn (Builder $query) => $query->where('tenant_id', Tenant::currentOrFail()->getKey())->with('backupSet'))
|
||||
->defaultSort('scheduled_for', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('scheduled_for')
|
||||
->label('Scheduled for')
|
||||
->dateTime(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BackupScheduleRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BackupScheduleRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BackupScheduleRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BackupScheduleRunStatus)),
|
||||
Tables\Columns\TextColumn::make('duration')
|
||||
->label('Duration')
|
||||
->getStateUsing(function (BackupScheduleRun $record): string {
|
||||
if (! $record->started_at || ! $record->finished_at) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
$seconds = max(0, $record->started_at->diffInSeconds($record->finished_at));
|
||||
|
||||
if ($seconds < 60) {
|
||||
return $seconds.'s';
|
||||
}
|
||||
|
||||
$minutes = intdiv($seconds, 60);
|
||||
$rem = $seconds % 60;
|
||||
|
||||
return sprintf('%dm %ds', $minutes, $rem);
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('counts')
|
||||
->label('Counts')
|
||||
->getStateUsing(function (BackupScheduleRun $record): string {
|
||||
$summary = is_array($record->summary) ? $record->summary : [];
|
||||
|
||||
$total = (int) ($summary['policies_total'] ?? 0);
|
||||
$backedUp = (int) ($summary['policies_backed_up'] ?? 0);
|
||||
$errors = (int) ($summary['errors_count'] ?? 0);
|
||||
|
||||
if ($total === 0 && $backedUp === 0 && $errors === 0) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
return sprintf('%d/%d (%d errors)', $backedUp, $total, $errors);
|
||||
}),
|
||||
Tables\Columns\TextColumn::make('error_code')
|
||||
->label('Error')
|
||||
->badge()
|
||||
->default('—'),
|
||||
Tables\Columns\TextColumn::make('error_message')
|
||||
->label('Message')
|
||||
->default('—')
|
||||
->limit(80)
|
||||
->wrap(),
|
||||
Tables\Columns\TextColumn::make('backup_set_id')
|
||||
->label('Backup set')
|
||||
->default('—')
|
||||
->url(function (BackupScheduleRun $record): ?string {
|
||||
if (! $record->backup_set_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return BackupSetResource::getUrl('view', ['record' => $record->backup_set_id], tenant: Tenant::current());
|
||||
})
|
||||
->openUrlInNewTab(true),
|
||||
])
|
||||
->filters([])
|
||||
->headerActions([])
|
||||
->actions([
|
||||
Actions\Action::make('view')
|
||||
->label('View')
|
||||
->icon('heroicon-o-eye')
|
||||
->modalHeading('View backup schedule run')
|
||||
->modalSubmitAction(false)
|
||||
->modalCancelActionLabel('Close')
|
||||
->modalContent(function (BackupScheduleRun $record): View {
|
||||
return view('filament.modals.backup-schedule-run-view', [
|
||||
'run' => $record,
|
||||
]);
|
||||
}),
|
||||
])
|
||||
->bulkActions([]);
|
||||
}
|
||||
}
|
||||
@ -3,7 +3,6 @@
|
||||
namespace App\Filament\Resources\EntraGroupResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
||||
use App\Jobs\EntraGroupSyncJob;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
@ -23,10 +22,10 @@ class ListEntraGroups extends ListRecords
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
Action::make('view_group_sync_runs')
|
||||
->label('Group Sync Runs')
|
||||
Action::make('view_operations')
|
||||
->label('Operations')
|
||||
->icon('heroicon-o-clock')
|
||||
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current()))
|
||||
->url(fn (): string => OperationRunLinks::index(Tenant::current()))
|
||||
->visible(fn (): bool => (bool) Tenant::current()),
|
||||
UiEnforcement::forAction(
|
||||
Action::make('sync_groups')
|
||||
@ -48,7 +47,7 @@ protected function getHeaderActions(): array
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'directory_groups.sync',
|
||||
type: 'entra_group_sync',
|
||||
identityInputs: ['selection_key' => $selectionKey],
|
||||
context: [
|
||||
'selection_key' => $selectionKey,
|
||||
|
||||
@ -1,168 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Resources\EntraGroupSyncRunResource\Pages;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
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 EntraGroupSyncRunResource extends Resource
|
||||
{
|
||||
protected static bool $isScopedToTenant = false;
|
||||
|
||||
protected static ?string $model = EntraGroupSyncRun::class;
|
||||
|
||||
protected static bool $shouldRegisterNavigation = false;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-user-group';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Directory';
|
||||
|
||||
protected static ?string $navigationLabel = 'Group Sync Runs';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Group sync runs list intentionally has no header actions; group sync is started from Directory group sync surfaces.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating group sync.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
|
||||
}
|
||||
|
||||
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 (EntraGroupSyncRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
|
||||
->badge()
|
||||
->color('primary'),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Sync Run')
|
||||
->schema([
|
||||
TextEntry::make('initiator.name')
|
||||
->label('Initiator')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)),
|
||||
TextEntry::make('selection_key')->label('Selection'),
|
||||
TextEntry::make('slot_key')->label('Slot')->placeholder('—')->copyable(),
|
||||
TextEntry::make('started_at')->dateTime(),
|
||||
TextEntry::make('finished_at')->dateTime(),
|
||||
TextEntry::make('pages_fetched')->label('Pages')->numeric(),
|
||||
TextEntry::make('items_observed_count')->label('Observed')->numeric(),
|
||||
TextEntry::make('items_upserted_count')->label('Upserted')->numeric(),
|
||||
TextEntry::make('error_count')->label('Errors')->numeric(),
|
||||
TextEntry::make('safety_stop_triggered')->label('Safety stop')->badge(),
|
||||
TextEntry::make('safety_stop_reason')->label('Stop reason')->placeholder('—'),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Error Summary')
|
||||
->schema([
|
||||
TextEntry::make('error_code')->placeholder('—'),
|
||||
TextEntry::make('error_category')->placeholder('—'),
|
||||
ViewEntry::make('error_summary')
|
||||
->label('Safe error summary')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (EntraGroupSyncRun $record) => $record->error_summary ? ['summary' => $record->error_summary] : [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columns(2)
|
||||
->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('initiator.name')
|
||||
->label('Initiator')
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::EntraGroupSyncRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::EntraGroupSyncRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::EntraGroupSyncRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::EntraGroupSyncRunStatus)),
|
||||
Tables\Columns\TextColumn::make('selection_key')
|
||||
->label('Selection')
|
||||
->limit(24)
|
||||
->copyable(),
|
||||
Tables\Columns\TextColumn::make('slot_key')
|
||||
->label('Slot')
|
||||
->placeholder('—')
|
||||
->limit(16)
|
||||
->copyable(),
|
||||
Tables\Columns\TextColumn::make('started_at')->since(),
|
||||
Tables\Columns\TextColumn::make('finished_at')->since(),
|
||||
Tables\Columns\TextColumn::make('pages_fetched')->label('Pages')->numeric(),
|
||||
Tables\Columns\TextColumn::make('items_observed_count')->label('Observed')->numeric(),
|
||||
Tables\Columns\TextColumn::make('items_upserted_count')->label('Upserted')->numeric(),
|
||||
Tables\Columns\TextColumn::make('error_count')->label('Errors')->numeric(),
|
||||
])
|
||||
->recordUrl(static fn (EntraGroupSyncRun $record): ?string => static::canView($record)
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
: null)
|
||||
->actions([])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
return parent::getEloquentQuery()
|
||||
->with('initiator')
|
||||
->latest('id');
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListEntraGroupSyncRuns::route('/'),
|
||||
'view' => Pages\ViewEntraGroupSyncRun::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,11 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListEntraGroupSyncRuns extends ListRecords
|
||||
{
|
||||
protected static string $resource = EntraGroupSyncRunResource::class;
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\EntraGroupSyncRunResource\Pages;
|
||||
|
||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewEntraGroupSyncRun extends ViewRecord
|
||||
{
|
||||
protected static string $resource = EntraGroupSyncRunResource::class;
|
||||
|
||||
public function mount(int|string $record): void
|
||||
{
|
||||
parent::mount($record);
|
||||
|
||||
$legacyRun = $this->getRecord();
|
||||
|
||||
if ($legacyRun instanceof EntraGroupSyncRun && is_numeric($legacyRun->operation_run_id)) {
|
||||
$this->redirect(OperationRunLinks::tenantlessView((int) $legacyRun->operation_run_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -117,16 +117,16 @@ public static function infolist(Schema $schema): Schema
|
||||
TextEntry::make('subject_display_name')->label('Subject')->placeholder('—'),
|
||||
TextEntry::make('subject_type')->label('Subject type'),
|
||||
TextEntry::make('subject_external_id')->label('External ID')->copyable(),
|
||||
TextEntry::make('baseline_run_id')
|
||||
TextEntry::make('baseline_operation_run_id')
|
||||
->label('Baseline run')
|
||||
->url(fn (Finding $record): ?string => $record->baseline_run_id
|
||||
? InventorySyncRunResource::getUrl('view', ['record' => $record->baseline_run_id], tenant: Tenant::current())
|
||||
->url(fn (Finding $record): ?string => $record->baseline_operation_run_id
|
||||
? route('admin.operations.view', ['run' => (int) $record->baseline_operation_run_id])
|
||||
: null)
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('current_run_id')
|
||||
TextEntry::make('current_operation_run_id')
|
||||
->label('Current run')
|
||||
->url(fn (Finding $record): ?string => $record->current_run_id
|
||||
? InventorySyncRunResource::getUrl('view', ['record' => $record->current_run_id], tenant: Tenant::current())
|
||||
->url(fn (Finding $record): ?string => $record->current_operation_run_id
|
||||
? route('admin.operations.view', ['run' => (int) $record->current_operation_run_id])
|
||||
: null)
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('acknowledged_at')->dateTime()->placeholder('—'),
|
||||
@ -297,22 +297,22 @@ public static function table(Table $table): Table
|
||||
Tables\Filters\Filter::make('run_ids')
|
||||
->label('Run IDs')
|
||||
->form([
|
||||
TextInput::make('baseline_run_id')
|
||||
TextInput::make('baseline_operation_run_id')
|
||||
->label('Baseline run id')
|
||||
->numeric(),
|
||||
TextInput::make('current_run_id')
|
||||
TextInput::make('current_operation_run_id')
|
||||
->label('Current run id')
|
||||
->numeric(),
|
||||
])
|
||||
->query(function (Builder $query, array $data): Builder {
|
||||
$baselineRunId = $data['baseline_run_id'] ?? null;
|
||||
$baselineRunId = $data['baseline_operation_run_id'] ?? null;
|
||||
if (is_numeric($baselineRunId)) {
|
||||
$query->where('baseline_run_id', (int) $baselineRunId);
|
||||
$query->where('baseline_operation_run_id', (int) $baselineRunId);
|
||||
}
|
||||
|
||||
$currentRunId = $data['current_run_id'] ?? null;
|
||||
$currentRunId = $data['current_operation_run_id'] ?? null;
|
||||
if (is_numeric($currentRunId)) {
|
||||
$query->where('current_run_id', (int) $currentRunId);
|
||||
$query->where('current_operation_run_id', (int) $currentRunId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
|
||||
@ -113,14 +113,14 @@ protected function buildAllMatchingQuery(): Builder
|
||||
}
|
||||
|
||||
$runIdsState = $this->getTableFilterState('run_ids') ?? [];
|
||||
$baselineRunId = Arr::get($runIdsState, 'baseline_run_id');
|
||||
$baselineRunId = Arr::get($runIdsState, 'baseline_operation_run_id');
|
||||
if (is_numeric($baselineRunId)) {
|
||||
$query->where('baseline_run_id', (int) $baselineRunId);
|
||||
$query->where('baseline_operation_run_id', (int) $baselineRunId);
|
||||
}
|
||||
|
||||
$currentRunId = Arr::get($runIdsState, 'current_run_id');
|
||||
$currentRunId = Arr::get($runIdsState, 'current_operation_run_id');
|
||||
if (is_numeric($currentRunId)) {
|
||||
$query->where('current_run_id', (int) $currentRunId);
|
||||
$query->where('current_operation_run_id', (int) $currentRunId);
|
||||
}
|
||||
|
||||
return $query;
|
||||
|
||||
@ -138,17 +138,6 @@ public static function infolist(Schema $schema): Schema
|
||||
return route('admin.operations.view', ['run' => (int) $record->last_seen_operation_run_id]);
|
||||
})
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('last_seen_run_id')
|
||||
->label('Last inventory sync (legacy)')
|
||||
->visible(fn (InventoryItem $record): bool => blank($record->last_seen_operation_run_id) && filled($record->last_seen_run_id))
|
||||
->url(function (InventoryItem $record): ?string {
|
||||
if (! $record->last_seen_run_id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return InventorySyncRunResource::getUrl('view', ['record' => $record->last_seen_run_id], tenant: Tenant::current());
|
||||
})
|
||||
->openUrlInNewTab(),
|
||||
TextEntry::make('support_restore')
|
||||
->label('Restore')
|
||||
->badge()
|
||||
@ -247,7 +236,7 @@ public static function table(Table $table): Table
|
||||
Tables\Columns\TextColumn::make('last_seen_at')
|
||||
->label('Last seen')
|
||||
->since(),
|
||||
Tables\Columns\TextColumn::make('lastSeenRun.status')
|
||||
Tables\Columns\TextColumn::make('lastSeenRun.outcome')
|
||||
->label('Run')
|
||||
->badge()
|
||||
->formatStateUsing(function (?string $state): string {
|
||||
@ -255,28 +244,28 @@ public static function table(Table $table): Table
|
||||
return '—';
|
||||
}
|
||||
|
||||
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->label;
|
||||
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->label;
|
||||
})
|
||||
->color(function (?string $state): string {
|
||||
if (! filled($state)) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->color;
|
||||
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->color;
|
||||
})
|
||||
->icon(function (?string $state): ?string {
|
||||
if (! filled($state)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state)->icon;
|
||||
return BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state)->icon;
|
||||
})
|
||||
->iconColor(function (?string $state): ?string {
|
||||
if (! filled($state)) {
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
$spec = BadgeRenderer::spec(BadgeDomain::InventorySyncRunStatus, $state);
|
||||
$spec = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $state);
|
||||
|
||||
return $spec->iconColor ?? $spec->color;
|
||||
}),
|
||||
|
||||
@ -153,12 +153,15 @@ protected function getHeaderActions(): array
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'inventory.sync',
|
||||
type: 'inventory_sync',
|
||||
identityInputs: [
|
||||
'selection_hash' => $computed['selection_hash'],
|
||||
],
|
||||
context: array_merge($computed['selection'], [
|
||||
'selection_hash' => $computed['selection_hash'],
|
||||
'target_scope' => [
|
||||
'entra_tenant_id' => $tenant->graphTenantId(),
|
||||
],
|
||||
]),
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
@ -1,230 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources;
|
||||
|
||||
use App\Filament\Clusters\Inventory\InventoryCluster;
|
||||
use App\Filament\Resources\InventorySyncRunResource\Pages;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\CapabilityResolver;
|
||||
use App\Support\Auth\Capabilities;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\Ui\ActionSurface\ActionSurfaceDeclaration;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceProfile;
|
||||
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
|
||||
use BackedEnum;
|
||||
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 Illuminate\Database\Eloquent\Model;
|
||||
use UnitEnum;
|
||||
|
||||
class InventorySyncRunResource extends Resource
|
||||
{
|
||||
protected static ?string $model = InventorySyncRun::class;
|
||||
|
||||
protected static ?string $tenantOwnershipRelationshipName = 'tenant';
|
||||
|
||||
protected static bool $shouldRegisterNavigation = true;
|
||||
|
||||
protected static ?string $cluster = InventoryCluster::class;
|
||||
|
||||
protected static ?int $navigationSort = 2;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = 'heroicon-o-clock';
|
||||
|
||||
protected static string|UnitEnum|null $navigationGroup = 'Inventory';
|
||||
|
||||
public static function actionSurfaceDeclaration(): ActionSurfaceDeclaration
|
||||
{
|
||||
return ActionSurfaceDeclaration::forResource(ActionSurfaceProfile::RunLog)
|
||||
->exempt(ActionSurfaceSlot::ListHeader, 'Inventory sync runs list intentionally has no header actions; sync is started from Inventory surfaces.')
|
||||
->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value)
|
||||
->exempt(ActionSurfaceSlot::ListBulkMoreGroup, 'Bulk actions are intentionally omitted for sync-run records.')
|
||||
->exempt(ActionSurfaceSlot::ListEmptyState, 'Empty-state CTA is intentionally omitted; sync runs appear after initiating inventory sync.')
|
||||
->exempt(ActionSurfaceSlot::DetailHeader, 'View page is informational and currently has no header actions.');
|
||||
}
|
||||
|
||||
public static function canViewAny(): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
return $resolver->can($user, $tenant, Capabilities::TENANT_VIEW);
|
||||
}
|
||||
|
||||
public static function canView(Model $record): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
$user = auth()->user();
|
||||
|
||||
if (! $tenant instanceof Tenant || ! $user instanceof User) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var CapabilityResolver $resolver */
|
||||
$resolver = app(CapabilityResolver::class);
|
||||
|
||||
if (! $resolver->can($user, $tenant, Capabilities::TENANT_VIEW)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($record instanceof InventorySyncRun) {
|
||||
return (int) $record->tenant_id === (int) $tenant->getKey();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function getNavigationLabel(): string
|
||||
{
|
||||
return 'Sync History';
|
||||
}
|
||||
|
||||
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 (InventorySyncRun $record): string => OperationRunLinks::index(Tenant::current() ?? $record->tenant))
|
||||
->badge()
|
||||
->color('primary'),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Sync Run')
|
||||
->schema([
|
||||
TextEntry::make('user.name')
|
||||
->label('Initiator')
|
||||
->placeholder('—'),
|
||||
TextEntry::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventorySyncRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::InventorySyncRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::InventorySyncRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventorySyncRunStatus)),
|
||||
TextEntry::make('selection_hash')->label('Selection hash')->copyable(),
|
||||
TextEntry::make('started_at')->dateTime(),
|
||||
TextEntry::make('finished_at')->dateTime(),
|
||||
TextEntry::make('items_observed_count')->label('Observed')->numeric(),
|
||||
TextEntry::make('items_upserted_count')->label('Upserted')->numeric(),
|
||||
TextEntry::make('errors_count')->label('Errors')->numeric(),
|
||||
TextEntry::make('had_errors')
|
||||
->label('Had errors')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::BooleanHasErrors))
|
||||
->color(BadgeRenderer::color(BadgeDomain::BooleanHasErrors))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::BooleanHasErrors))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::BooleanHasErrors)),
|
||||
])
|
||||
->columns(2)
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Selection Payload')
|
||||
->schema([
|
||||
ViewEntry::make('selection_payload')
|
||||
->label('')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (InventorySyncRun $record) => $record->selection_payload ?? [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
|
||||
Section::make('Error Summary')
|
||||
->schema([
|
||||
ViewEntry::make('error_codes')
|
||||
->label('Error codes')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (InventorySyncRun $record) => $record->error_codes ?? [])
|
||||
->columnSpanFull(),
|
||||
ViewEntry::make('error_context')
|
||||
->label('Safe error context')
|
||||
->view('filament.infolists.entries.snapshot-json')
|
||||
->state(fn (InventorySyncRun $record) => $record->error_context ?? [])
|
||||
->columnSpanFull(),
|
||||
])
|
||||
->columnSpanFull(),
|
||||
]);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->defaultSort('id', 'desc')
|
||||
->columns([
|
||||
Tables\Columns\TextColumn::make('user.name')
|
||||
->label('Initiator')
|
||||
->placeholder('—')
|
||||
->toggleable(),
|
||||
Tables\Columns\TextColumn::make('status')
|
||||
->badge()
|
||||
->formatStateUsing(BadgeRenderer::label(BadgeDomain::InventorySyncRunStatus))
|
||||
->color(BadgeRenderer::color(BadgeDomain::InventorySyncRunStatus))
|
||||
->icon(BadgeRenderer::icon(BadgeDomain::InventorySyncRunStatus))
|
||||
->iconColor(BadgeRenderer::iconColor(BadgeDomain::InventorySyncRunStatus)),
|
||||
Tables\Columns\TextColumn::make('selection_hash')
|
||||
->label('Selection')
|
||||
->copyable()
|
||||
->limit(12),
|
||||
Tables\Columns\TextColumn::make('started_at')->since(),
|
||||
Tables\Columns\TextColumn::make('finished_at')->since(),
|
||||
Tables\Columns\TextColumn::make('items_observed_count')
|
||||
->label('Observed')
|
||||
->numeric(),
|
||||
Tables\Columns\TextColumn::make('items_upserted_count')
|
||||
->label('Upserted')
|
||||
->numeric(),
|
||||
Tables\Columns\TextColumn::make('errors_count')
|
||||
->label('Errors')
|
||||
->numeric(),
|
||||
])
|
||||
->recordUrl(static fn (Model $record): ?string => static::canView($record)
|
||||
? static::getUrl('view', ['record' => $record])
|
||||
: null)
|
||||
->actions([])
|
||||
->bulkActions([]);
|
||||
}
|
||||
|
||||
public static function getEloquentQuery(): Builder
|
||||
{
|
||||
$tenantId = Tenant::current()?->getKey();
|
||||
|
||||
return parent::getEloquentQuery()
|
||||
->with('user')
|
||||
->when($tenantId, fn (Builder $query) => $query->where('tenant_id', $tenantId));
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => Pages\ListInventorySyncRuns::route('/'),
|
||||
'view' => Pages\ViewInventorySyncRun::route('/{record}'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
|
||||
|
||||
use App\Filament\Resources\InventorySyncRunResource;
|
||||
use App\Filament\Widgets\Inventory\InventoryKpiHeader;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListInventorySyncRuns extends ListRecords
|
||||
{
|
||||
protected static string $resource = InventorySyncRunResource::class;
|
||||
|
||||
protected function getHeaderWidgets(): array
|
||||
{
|
||||
return [
|
||||
InventoryKpiHeader::class,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\InventorySyncRunResource\Pages;
|
||||
|
||||
use App\Filament\Resources\InventorySyncRunResource;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Resources\Pages\ViewRecord;
|
||||
|
||||
class ViewInventorySyncRun extends ViewRecord
|
||||
{
|
||||
protected static string $resource = InventorySyncRunResource::class;
|
||||
|
||||
public function mount(int|string $record): void
|
||||
{
|
||||
parent::mount($record);
|
||||
|
||||
$legacyRun = $this->getRecord();
|
||||
|
||||
if ($legacyRun instanceof InventorySyncRun && is_numeric($legacyRun->operation_run_id)) {
|
||||
$this->redirect(OperationRunLinks::tenantlessView((int) $legacyRun->operation_run_id));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -370,7 +370,7 @@ public static function table(Table $table): Table
|
||||
$result = $gate->start(
|
||||
tenant: $tenant,
|
||||
connection: $record,
|
||||
operationType: 'inventory.sync',
|
||||
operationType: 'inventory_sync',
|
||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
||||
ProviderInventorySyncJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
|
||||
@ -423,7 +423,7 @@ protected function getHeaderActions(): array
|
||||
$result = $gate->start(
|
||||
tenant: $tenant,
|
||||
connection: $record,
|
||||
operationType: 'inventory.sync',
|
||||
operationType: 'inventory_sync',
|
||||
dispatcher: function (OperationRun $operationRun) use ($tenant, $initiator, $record): void {
|
||||
ProviderInventorySyncJob::dispatch(
|
||||
tenantId: (int) $tenant->getKey(),
|
||||
|
||||
@ -68,7 +68,7 @@ protected function getStats(): array
|
||||
|
||||
$inventoryActiveRuns = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->active()
|
||||
->count();
|
||||
|
||||
|
||||
@ -58,7 +58,7 @@ protected function getViewData(): array
|
||||
|
||||
$latestDriftSuccess = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'drift.generate')
|
||||
->where('type', 'drift_generate_findings')
|
||||
->where('status', 'completed')
|
||||
->where('outcome', 'succeeded')
|
||||
->whereNotNull('completed_at')
|
||||
@ -89,7 +89,7 @@ protected function getViewData(): array
|
||||
|
||||
$latestDriftFailure = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'drift.generate')
|
||||
->where('type', 'drift_generate_findings')
|
||||
->where('status', 'completed')
|
||||
->where('outcome', 'failed')
|
||||
->latest('id')
|
||||
|
||||
@ -4,15 +4,16 @@
|
||||
|
||||
namespace App\Filament\Widgets\Inventory;
|
||||
|
||||
use App\Filament\Resources\InventorySyncRunResource;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Inventory\CoverageCapabilitiesResolver;
|
||||
use App\Support\Badges\BadgeDomain;
|
||||
use App\Support\Badges\BadgeRenderer;
|
||||
use App\Support\Inventory\InventoryKpiBadges;
|
||||
use App\Support\Inventory\InventoryPolicyTypeMeta;
|
||||
use App\Support\Inventory\InventorySyncStatusBadge;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Filament\Facades\Filament;
|
||||
use Filament\Widgets\StatsOverviewWidget;
|
||||
use Filament\Widgets\StatsOverviewWidget\Stat;
|
||||
@ -80,8 +81,12 @@ protected function getStats(): array
|
||||
? (int) round(($restorableItems / $totalItems) * 100)
|
||||
: 0;
|
||||
|
||||
$lastRun = InventorySyncRun::query()
|
||||
$lastRun = OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'inventory_sync')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereNotNull('completed_at')
|
||||
->latest('completed_at')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
@ -91,21 +96,20 @@ protected function getStats(): array
|
||||
$lastInventorySyncStatusIcon = 'heroicon-m-clock';
|
||||
$lastInventorySyncViewUrl = null;
|
||||
|
||||
if ($lastRun instanceof InventorySyncRun) {
|
||||
$timestamp = $lastRun->finished_at ?? $lastRun->started_at;
|
||||
if ($lastRun instanceof OperationRun) {
|
||||
$timestamp = $lastRun->completed_at ?? $lastRun->started_at ?? $lastRun->created_at;
|
||||
|
||||
if ($timestamp) {
|
||||
$lastInventorySyncTimeLabel = $timestamp->diffForHumans(['short' => true]);
|
||||
}
|
||||
|
||||
$status = (string) ($lastRun->status ?? '');
|
||||
$outcome = (string) ($lastRun->outcome ?? OperationRunOutcome::Pending->value);
|
||||
$badge = BadgeRenderer::spec(BadgeDomain::OperationRunOutcome, $outcome);
|
||||
$lastInventorySyncStatusLabel = $badge->label;
|
||||
$lastInventorySyncStatusColor = $badge->color;
|
||||
$lastInventorySyncStatusIcon = (string) ($badge->icon ?? 'heroicon-m-clock');
|
||||
|
||||
$badge = InventorySyncStatusBadge::for($status);
|
||||
$lastInventorySyncStatusLabel = $badge['label'];
|
||||
$lastInventorySyncStatusColor = $badge['color'];
|
||||
$lastInventorySyncStatusIcon = $badge['icon'];
|
||||
|
||||
$lastInventorySyncViewUrl = InventorySyncRunResource::getUrl('view', ['record' => $lastRun], tenant: $tenant);
|
||||
$lastInventorySyncViewUrl = route('admin.operations.view', ['run' => (int) $lastRun->getKey()]);
|
||||
}
|
||||
|
||||
$badgeColor = $lastInventorySyncStatusColor;
|
||||
@ -135,7 +139,7 @@ protected function getStats(): array
|
||||
|
||||
$inventoryOps = (int) OperationRun::query()
|
||||
->where('tenant_id', $tenantId)
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->active()
|
||||
->count();
|
||||
|
||||
|
||||
@ -3,12 +3,15 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class ApplyBackupScheduleRetentionJob implements ShouldQueue
|
||||
{
|
||||
@ -26,6 +29,21 @@ public function handle(AuditLogger $auditLogger): void
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRun = OperationRun::query()->create([
|
||||
'workspace_id' => (int) $schedule->tenant->workspace_id,
|
||||
'tenant_id' => (int) $schedule->tenant_id,
|
||||
'user_id' => null,
|
||||
'initiator_name' => 'System',
|
||||
'type' => 'backup_schedule_retention',
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'run_identity_hash' => hash('sha256', (string) $schedule->tenant_id.':backup_schedule_retention:'.$schedule->id.':'.Str::uuid()->toString()),
|
||||
'context' => [
|
||||
'backup_schedule_id' => (int) $schedule->id,
|
||||
],
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$keepLast = (int) ($schedule->retention_keep_last ?? 30);
|
||||
|
||||
if ($keepLast < 1) {
|
||||
@ -33,55 +51,65 @@ public function handle(AuditLogger $auditLogger): void
|
||||
}
|
||||
|
||||
/** @var Collection<int, int> $keepBackupSetIds */
|
||||
$keepBackupSetIds = BackupScheduleRun::query()
|
||||
->where('backup_schedule_id', $schedule->id)
|
||||
->whereNotNull('backup_set_id')
|
||||
->orderByDesc('scheduled_for')
|
||||
$keepBackupSetIds = OperationRun::query()
|
||||
->where('tenant_id', (int) $schedule->tenant_id)
|
||||
->where('type', 'backup_schedule_run')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('context->backup_schedule_id', (int) $schedule->id)
|
||||
->whereNotNull('context->backup_set_id')
|
||||
->orderByDesc('completed_at')
|
||||
->orderByDesc('id')
|
||||
->limit($keepLast)
|
||||
->pluck('backup_set_id')
|
||||
->filter()
|
||||
->get()
|
||||
->map(fn (OperationRun $run): ?int => is_numeric(data_get($run->context, 'backup_set_id')) ? (int) data_get($run->context, 'backup_set_id') : null)
|
||||
->filter(fn (?int $id): bool => is_int($id) && $id > 0)
|
||||
->values();
|
||||
|
||||
/** @var Collection<int, int> $deleteBackupSetIds */
|
||||
$deleteBackupSetIds = BackupScheduleRun::query()
|
||||
->where('backup_schedule_id', $schedule->id)
|
||||
->whereNotNull('backup_set_id')
|
||||
->when($keepBackupSetIds->isNotEmpty(), fn ($query) => $query->whereNotIn('backup_set_id', $keepBackupSetIds->all()))
|
||||
->pluck('backup_set_id')
|
||||
->filter()
|
||||
/** @var Collection<int, int> $allBackupSetIds */
|
||||
$allBackupSetIds = OperationRun::query()
|
||||
->where('tenant_id', (int) $schedule->tenant_id)
|
||||
->where('type', 'backup_schedule_run')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->where('context->backup_schedule_id', (int) $schedule->id)
|
||||
->whereNotNull('context->backup_set_id')
|
||||
->get()
|
||||
->map(fn (OperationRun $run): ?int => is_numeric(data_get($run->context, 'backup_set_id')) ? (int) data_get($run->context, 'backup_set_id') : null)
|
||||
->filter(fn (?int $id): bool => is_int($id) && $id > 0)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($deleteBackupSetIds->isEmpty()) {
|
||||
$auditLogger->log(
|
||||
tenant: $schedule->tenant,
|
||||
action: 'backup_schedule.retention_applied',
|
||||
resourceType: 'backup_schedule',
|
||||
resourceId: (string) $schedule->id,
|
||||
status: 'success',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'keep_last' => $keepLast,
|
||||
'deleted_backup_sets' => 0,
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
/** @var Collection<int, int> $deleteBackupSetIds */
|
||||
$deleteBackupSetIds = $allBackupSetIds
|
||||
->reject(fn (int $backupSetId): bool => $keepBackupSetIds->contains($backupSetId))
|
||||
->values();
|
||||
|
||||
$deletedCount = 0;
|
||||
|
||||
BackupSet::query()
|
||||
->where('tenant_id', $schedule->tenant_id)
|
||||
->whereIn('id', $deleteBackupSetIds->all())
|
||||
->whereNull('deleted_at')
|
||||
->chunkById(200, function (Collection $sets) use (&$deletedCount): void {
|
||||
foreach ($sets as $set) {
|
||||
$set->delete();
|
||||
$deletedCount++;
|
||||
}
|
||||
});
|
||||
if ($deleteBackupSetIds->isNotEmpty()) {
|
||||
BackupSet::query()
|
||||
->where('tenant_id', $schedule->tenant_id)
|
||||
->whereIn('id', $deleteBackupSetIds->all())
|
||||
->whereNull('deleted_at')
|
||||
->chunkById(200, function (Collection $sets) use (&$deletedCount): void {
|
||||
foreach ($sets as $set) {
|
||||
$set->delete();
|
||||
$deletedCount++;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$operationRun->update([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => OperationRunOutcome::Succeeded->value,
|
||||
'summary_counts' => [
|
||||
'total' => (int) $deleteBackupSetIds->count(),
|
||||
'processed' => (int) $deleteBackupSetIds->count(),
|
||||
'succeeded' => $deletedCount,
|
||||
'failed' => max(0, (int) $deleteBackupSetIds->count() - $deletedCount),
|
||||
'updated' => $deletedCount,
|
||||
],
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $schedule->tenant,
|
||||
@ -93,6 +121,7 @@ public function handle(AuditLogger $auditLogger): void
|
||||
'metadata' => [
|
||||
'keep_last' => $keepLast,
|
||||
'deleted_backup_sets' => $deletedCount,
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
@ -3,13 +3,12 @@
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Directory\EntraGroupSyncService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\OperationRunService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -51,81 +50,40 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
|
||||
throw new RuntimeException('Tenant not found.');
|
||||
}
|
||||
|
||||
$legacyRun = $this->resolveLegacyRun($tenant);
|
||||
|
||||
if ($legacyRun instanceof EntraGroupSyncRun) {
|
||||
if ($legacyRun->status !== EntraGroupSyncRun::STATUS_PENDING) {
|
||||
return;
|
||||
}
|
||||
|
||||
$legacyRun->update([
|
||||
'status' => EntraGroupSyncRun::STATUS_RUNNING,
|
||||
'started_at' => CarbonImmutable::now('UTC'),
|
||||
]);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'directory_groups.sync.started',
|
||||
context: [
|
||||
'selection_key' => $legacyRun->selection_key,
|
||||
'run_id' => $legacyRun->getKey(),
|
||||
'slot_key' => $legacyRun->slot_key,
|
||||
],
|
||||
actorId: $legacyRun->initiator_user_id,
|
||||
status: 'success',
|
||||
resourceType: 'entra_group_sync_run',
|
||||
resourceId: (string) $legacyRun->getKey(),
|
||||
);
|
||||
} else {
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'directory_groups.sync.started',
|
||||
context: [
|
||||
'selection_key' => $this->selectionKey,
|
||||
'slot_key' => $this->slotKey,
|
||||
],
|
||||
actorId: $this->operationRun->user_id,
|
||||
status: 'success',
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $this->operationRun->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
$result = $syncService->sync($tenant, $this->selectionKey);
|
||||
|
||||
|
||||
$terminalStatus = EntraGroupSyncRun::STATUS_SUCCEEDED;
|
||||
|
||||
if ($result['error_code'] !== null) {
|
||||
$terminalStatus = EntraGroupSyncRun::STATUS_FAILED;
|
||||
} elseif ($result['safety_stop_triggered'] === true) {
|
||||
$terminalStatus = EntraGroupSyncRun::STATUS_PARTIAL;
|
||||
}
|
||||
|
||||
if ($legacyRun instanceof EntraGroupSyncRun) {
|
||||
$legacyRun->update([
|
||||
'status' => $terminalStatus,
|
||||
'pages_fetched' => $result['pages_fetched'],
|
||||
'items_observed_count' => $result['items_observed_count'],
|
||||
'items_upserted_count' => $result['items_upserted_count'],
|
||||
'error_count' => $result['error_count'],
|
||||
'safety_stop_triggered' => $result['safety_stop_triggered'],
|
||||
'safety_stop_reason' => $result['safety_stop_reason'],
|
||||
'error_code' => $result['error_code'],
|
||||
'error_category' => $result['error_category'],
|
||||
'error_summary' => $result['error_summary'],
|
||||
'finished_at' => CarbonImmutable::now('UTC'),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var OperationRunService $opService */
|
||||
$opService = app(OperationRunService::class);
|
||||
|
||||
if ($this->operationRun->status === 'queued') {
|
||||
$opService->updateRun($this->operationRun, 'running');
|
||||
}
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'directory_groups.sync.started',
|
||||
context: [
|
||||
'selection_key' => $this->selectionKey,
|
||||
'slot_key' => $this->slotKey,
|
||||
],
|
||||
actorId: $this->operationRun->user_id,
|
||||
status: 'success',
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $this->operationRun->getKey(),
|
||||
);
|
||||
|
||||
$result = $syncService->sync($tenant, $this->selectionKey);
|
||||
|
||||
$terminalStatus = 'succeeded';
|
||||
|
||||
if ($result['error_code'] !== null) {
|
||||
$terminalStatus = 'failed';
|
||||
} elseif ($result['safety_stop_triggered'] === true) {
|
||||
$terminalStatus = 'partial';
|
||||
}
|
||||
|
||||
$opOutcome = match ($terminalStatus) {
|
||||
EntraGroupSyncRun::STATUS_SUCCEEDED => 'succeeded',
|
||||
EntraGroupSyncRun::STATUS_PARTIAL => 'partially_succeeded',
|
||||
EntraGroupSyncRun::STATUS_FAILED => 'failed',
|
||||
default => 'failed',
|
||||
'succeeded' => OperationRunOutcome::Succeeded->value,
|
||||
'partial' => OperationRunOutcome::PartiallySucceeded->value,
|
||||
default => OperationRunOutcome::Failed->value,
|
||||
};
|
||||
|
||||
$failures = [];
|
||||
@ -141,48 +99,19 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
|
||||
'completed',
|
||||
$opOutcome,
|
||||
[
|
||||
// NOTE: summary_counts are normalized to a fixed whitelist for Ops UX.
|
||||
// Keep keys aligned with App\Support\OpsUx\OperationSummaryKeys.
|
||||
'total' => $result['items_observed_count'],
|
||||
'processed' => $result['items_observed_count'],
|
||||
'updated' => $result['items_upserted_count'],
|
||||
'failed' => $result['error_count'],
|
||||
'total' => (int) $result['items_observed_count'],
|
||||
'processed' => (int) $result['items_observed_count'],
|
||||
'updated' => (int) $result['items_upserted_count'],
|
||||
'failed' => (int) $result['error_count'],
|
||||
],
|
||||
$failures,
|
||||
);
|
||||
|
||||
if ($legacyRun instanceof EntraGroupSyncRun) {
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED
|
||||
? 'directory_groups.sync.succeeded'
|
||||
: ($terminalStatus === EntraGroupSyncRun::STATUS_PARTIAL
|
||||
? 'directory_groups.sync.partial'
|
||||
: 'directory_groups.sync.failed'),
|
||||
context: [
|
||||
'selection_key' => $legacyRun->selection_key,
|
||||
'run_id' => $legacyRun->getKey(),
|
||||
'slot_key' => $legacyRun->slot_key,
|
||||
'pages_fetched' => $legacyRun->pages_fetched,
|
||||
'items_observed_count' => $legacyRun->items_observed_count,
|
||||
'items_upserted_count' => $legacyRun->items_upserted_count,
|
||||
'error_code' => $legacyRun->error_code,
|
||||
'error_category' => $legacyRun->error_category,
|
||||
],
|
||||
actorId: $legacyRun->initiator_user_id,
|
||||
status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success',
|
||||
resourceType: 'entra_group_sync_run',
|
||||
resourceId: (string) $legacyRun->getKey(),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: $terminalStatus === EntraGroupSyncRun::STATUS_SUCCEEDED
|
||||
action: $terminalStatus === 'succeeded'
|
||||
? 'directory_groups.sync.succeeded'
|
||||
: ($terminalStatus === EntraGroupSyncRun::STATUS_PARTIAL
|
||||
: ($terminalStatus === 'partial'
|
||||
? 'directory_groups.sync.partial'
|
||||
: 'directory_groups.sync.failed'),
|
||||
context: [
|
||||
@ -195,41 +124,9 @@ public function handle(EntraGroupSyncService $syncService, AuditLogger $auditLog
|
||||
'error_category' => $result['error_category'],
|
||||
],
|
||||
actorId: $this->operationRun->user_id,
|
||||
status: $terminalStatus === EntraGroupSyncRun::STATUS_FAILED ? 'failed' : 'success',
|
||||
status: $terminalStatus === 'failed' ? 'failed' : 'success',
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $this->operationRun->getKey(),
|
||||
);
|
||||
}
|
||||
|
||||
private function resolveLegacyRun(Tenant $tenant): ?EntraGroupSyncRun
|
||||
{
|
||||
if ($this->runId !== null) {
|
||||
$run = EntraGroupSyncRun::query()
|
||||
->whereKey($this->runId)
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->first();
|
||||
|
||||
if ($run instanceof EntraGroupSyncRun) {
|
||||
return $run;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($this->slotKey !== null) {
|
||||
$run = EntraGroupSyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('selection_key', $this->selectionKey)
|
||||
->where('slot_key', $this->slotKey)
|
||||
->first();
|
||||
|
||||
if ($run instanceof EntraGroupSyncRun) {
|
||||
return $run;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Services\Drift\DriftFindingGenerator;
|
||||
@ -45,8 +44,8 @@ public function handle(
|
||||
): void {
|
||||
Log::info('GenerateDriftFindingsJob: started', [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'baseline_run_id' => $this->baselineRunId,
|
||||
'current_run_id' => $this->currentRunId,
|
||||
'baseline_operation_run_id' => $this->baselineRunId,
|
||||
'current_operation_run_id' => $this->currentRunId,
|
||||
'scope_key' => $this->scopeKey,
|
||||
]);
|
||||
|
||||
@ -78,13 +77,21 @@ public function handle(
|
||||
throw new RuntimeException('Tenant not found.');
|
||||
}
|
||||
|
||||
$baseline = InventorySyncRun::query()->find($this->baselineRunId);
|
||||
if (! $baseline instanceof InventorySyncRun) {
|
||||
$baseline = OperationRun::query()
|
||||
->whereKey($this->baselineRunId)
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory_sync')
|
||||
->first();
|
||||
if (! $baseline instanceof OperationRun) {
|
||||
throw new RuntimeException('Baseline run not found.');
|
||||
}
|
||||
|
||||
$current = InventorySyncRun::query()->find($this->currentRunId);
|
||||
if (! $current instanceof InventorySyncRun) {
|
||||
$current = OperationRun::query()
|
||||
->whereKey($this->currentRunId)
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory_sync')
|
||||
->first();
|
||||
if (! $current instanceof OperationRun) {
|
||||
throw new RuntimeException('Current run not found.');
|
||||
}
|
||||
|
||||
@ -104,8 +111,8 @@ public function handle(
|
||||
|
||||
Log::info('GenerateDriftFindingsJob: completed', [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'baseline_run_id' => $this->baselineRunId,
|
||||
'current_run_id' => $this->currentRunId,
|
||||
'baseline_operation_run_id' => $this->baselineRunId,
|
||||
'current_operation_run_id' => $this->currentRunId,
|
||||
'scope_key' => $this->scopeKey,
|
||||
'created_findings_count' => $created,
|
||||
]);
|
||||
@ -120,8 +127,8 @@ public function handle(
|
||||
} catch (Throwable $e) {
|
||||
Log::error('GenerateDriftFindingsJob: failed', [
|
||||
'tenant_id' => $this->tenantId,
|
||||
'baseline_run_id' => $this->baselineRunId,
|
||||
'current_run_id' => $this->currentRunId,
|
||||
'baseline_operation_run_id' => $this->baselineRunId,
|
||||
'current_operation_run_id' => $this->currentRunId,
|
||||
'scope_key' => $this->scopeKey,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
@ -132,7 +139,7 @@ public function handle(
|
||||
]);
|
||||
|
||||
$runs->appendFailures($this->operationRun, [[
|
||||
'code' => 'drift.generate.failed',
|
||||
'code' => 'drift_generate_findings.failed',
|
||||
'message' => $e->getMessage(),
|
||||
]]);
|
||||
|
||||
|
||||
@ -4,9 +4,9 @@
|
||||
|
||||
use App\Jobs\Middleware\TrackOperationRun;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\BackupScheduling\RunErrorMapper;
|
||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||
@ -15,6 +15,7 @@
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunLinks;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Notifications\Notification;
|
||||
@ -23,15 +24,23 @@
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class RunBackupScheduleJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
private const string STATUS_RUNNING = 'running';
|
||||
|
||||
private const string STATUS_SUCCESS = 'success';
|
||||
|
||||
private const string STATUS_PARTIAL = 'partial';
|
||||
|
||||
private const string STATUS_FAILED = 'failed';
|
||||
|
||||
private const string STATUS_SKIPPED = 'skipped';
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public ?OperationRun $operationRun = null;
|
||||
@ -63,317 +72,19 @@ public function handle(
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->backupScheduleId !== null) {
|
||||
$this->handleFromScheduleId(
|
||||
backupScheduleId: $this->backupScheduleId,
|
||||
policySyncService: $policySyncService,
|
||||
backupService: $backupService,
|
||||
policyTypeResolver: $policyTypeResolver,
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
auditLogger: $auditLogger,
|
||||
errorMapper: $errorMapper,
|
||||
$backupScheduleId = $this->resolveBackupScheduleId();
|
||||
|
||||
if ($backupScheduleId <= 0) {
|
||||
$this->markOperationRunFailed(
|
||||
run: $this->operationRun,
|
||||
summaryCounts: [],
|
||||
reasonCode: 'schedule_not_provided',
|
||||
reason: 'No backup schedule was provided for this run.',
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$run = BackupScheduleRun::query()
|
||||
->with(['schedule', 'tenant', 'user'])
|
||||
->find($this->backupScheduleRunId);
|
||||
|
||||
if (! $run) {
|
||||
if ($this->operationRun) {
|
||||
$this->markOperationRunFailed(
|
||||
run: $this->operationRun,
|
||||
summaryCounts: [],
|
||||
reasonCode: 'run_not_found',
|
||||
reason: 'Backup schedule run not found.',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$tenant = $run->tenant;
|
||||
|
||||
if ($this->operationRun) {
|
||||
$this->operationRun->update([
|
||||
'context' => array_merge($this->operationRun->context ?? [], [
|
||||
'backup_schedule_id' => (int) $run->backup_schedule_id,
|
||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||
]),
|
||||
]);
|
||||
|
||||
/** @var OperationRunService $operationRunService */
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
|
||||
if ($this->operationRun->status === 'queued') {
|
||||
$operationRunService->updateRun($this->operationRun, 'running');
|
||||
}
|
||||
}
|
||||
|
||||
$schedule = $run->schedule;
|
||||
|
||||
if (! $schedule instanceof BackupSchedule) {
|
||||
$run->update([
|
||||
'status' => BackupScheduleRun::STATUS_FAILED,
|
||||
'error_code' => RunErrorMapper::ERROR_UNKNOWN,
|
||||
'error_message' => 'Schedule not found.',
|
||||
'finished_at' => CarbonImmutable::now('UTC'),
|
||||
]);
|
||||
|
||||
if ($this->operationRun) {
|
||||
$this->markOperationRunFailed(
|
||||
run: $this->operationRun,
|
||||
summaryCounts: [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'failed' => 1,
|
||||
],
|
||||
reasonCode: 'schedule_not_found',
|
||||
reason: 'Schedule not found.',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $tenant) {
|
||||
$run->update([
|
||||
'status' => BackupScheduleRun::STATUS_FAILED,
|
||||
'error_code' => RunErrorMapper::ERROR_UNKNOWN,
|
||||
'error_message' => 'Tenant not found.',
|
||||
'finished_at' => CarbonImmutable::now('UTC'),
|
||||
]);
|
||||
|
||||
if ($this->operationRun) {
|
||||
$this->markOperationRunFailed(
|
||||
run: $this->operationRun,
|
||||
summaryCounts: [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'failed' => 1,
|
||||
],
|
||||
reasonCode: 'tenant_not_found',
|
||||
reason: 'Tenant not found.',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$lock = Cache::lock("backup_schedule:{$schedule->id}", 900);
|
||||
|
||||
if (! $lock->get()) {
|
||||
$this->finishRun(
|
||||
run: $run,
|
||||
schedule: $schedule,
|
||||
status: BackupScheduleRun::STATUS_SKIPPED,
|
||||
errorCode: 'CONCURRENT_RUN',
|
||||
errorMessage: 'Another run is already in progress for this schedule.',
|
||||
summary: ['reason' => 'concurrent_run'],
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
);
|
||||
|
||||
$this->syncOperationRunFromRun(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
run: $run->refresh(),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$nowUtc = CarbonImmutable::now('UTC');
|
||||
|
||||
$run->forceFill([
|
||||
'started_at' => $run->started_at ?? $nowUtc,
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
])->save();
|
||||
|
||||
$this->notifyRunStarted($run, $schedule);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_started',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'scheduled_for' => $run->scheduled_for?->toDateTimeString(),
|
||||
],
|
||||
],
|
||||
resourceType: 'backup_schedule_run',
|
||||
resourceId: (string) $run->id,
|
||||
status: 'success'
|
||||
);
|
||||
|
||||
$runtime = $policyTypeResolver->resolveRuntime((array) ($schedule->policy_types ?? []));
|
||||
$validTypes = $runtime['valid'];
|
||||
$unknownTypes = $runtime['unknown'];
|
||||
|
||||
if (empty($validTypes)) {
|
||||
$this->finishRun(
|
||||
run: $run,
|
||||
schedule: $schedule,
|
||||
status: BackupScheduleRun::STATUS_SKIPPED,
|
||||
errorCode: 'UNKNOWN_POLICY_TYPE',
|
||||
errorMessage: 'All configured policy types are unknown.',
|
||||
summary: [
|
||||
'unknown_policy_types' => $unknownTypes,
|
||||
],
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
);
|
||||
|
||||
$this->syncOperationRunFromRun(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
run: $run->refresh(),
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$supported = array_values(array_filter(
|
||||
config('tenantpilot.supported_policy_types', []),
|
||||
fn (array $typeConfig): bool => in_array($typeConfig['type'] ?? null, $validTypes, true),
|
||||
));
|
||||
|
||||
$syncReport = $policySyncService->syncPoliciesWithReport($tenant, $supported);
|
||||
|
||||
$policyIds = $syncReport['synced'] ?? [];
|
||||
$syncFailures = $syncReport['failures'] ?? [];
|
||||
|
||||
$backupSet = $backupService->createBackupSet(
|
||||
tenant: $tenant,
|
||||
policyIds: $policyIds,
|
||||
actorEmail: null,
|
||||
actorName: null,
|
||||
name: 'Scheduled backup: '.$schedule->name,
|
||||
includeAssignments: false,
|
||||
includeScopeTags: false,
|
||||
includeFoundations: (bool) ($schedule->include_foundations ?? false),
|
||||
);
|
||||
|
||||
$status = match ($backupSet->status) {
|
||||
'completed' => BackupScheduleRun::STATUS_SUCCESS,
|
||||
'partial' => BackupScheduleRun::STATUS_PARTIAL,
|
||||
'failed' => BackupScheduleRun::STATUS_FAILED,
|
||||
default => BackupScheduleRun::STATUS_SUCCESS,
|
||||
};
|
||||
|
||||
$errorCode = null;
|
||||
$errorMessage = null;
|
||||
|
||||
$summary = [
|
||||
'policies_total' => count($policyIds),
|
||||
'policies_backed_up' => (int) ($backupSet->item_count ?? 0),
|
||||
'sync_failures' => $syncFailures,
|
||||
];
|
||||
|
||||
if (! empty($unknownTypes)) {
|
||||
$status = BackupScheduleRun::STATUS_PARTIAL;
|
||||
$errorCode = 'UNKNOWN_POLICY_TYPE';
|
||||
$errorMessage = 'Some configured policy types are unknown and were skipped.';
|
||||
$summary['unknown_policy_types'] = $unknownTypes;
|
||||
}
|
||||
|
||||
$this->finishRun(
|
||||
run: $run,
|
||||
schedule: $schedule,
|
||||
status: $status,
|
||||
errorCode: $errorCode,
|
||||
errorMessage: $errorMessage,
|
||||
summary: $summary,
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
backupSetId: (string) $backupSet->id,
|
||||
);
|
||||
|
||||
$this->syncOperationRunFromRun(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
run: $run->refresh(),
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_finished',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'status' => $status,
|
||||
'error_code' => $errorCode,
|
||||
],
|
||||
],
|
||||
resourceType: 'backup_schedule_run',
|
||||
resourceId: (string) $run->id,
|
||||
status: in_array($status, [BackupScheduleRun::STATUS_SUCCESS], true) ? 'success' : 'partial'
|
||||
);
|
||||
} catch (\Throwable $throwable) {
|
||||
$attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1;
|
||||
$mapped = $errorMapper->map($throwable, $attempt, $this->tries);
|
||||
|
||||
if ($mapped['shouldRetry']) {
|
||||
if ($this->operationRun) {
|
||||
/** @var OperationRunService $operationRunService */
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
$operationRunService->updateRun($this->operationRun, 'running', 'pending');
|
||||
}
|
||||
|
||||
$this->release($mapped['delay']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->finishRun(
|
||||
run: $run,
|
||||
schedule: $schedule,
|
||||
status: BackupScheduleRun::STATUS_FAILED,
|
||||
errorCode: $mapped['error_code'],
|
||||
errorMessage: $mapped['error_message'],
|
||||
summary: [
|
||||
'exception' => get_class($throwable),
|
||||
'attempt' => $attempt,
|
||||
],
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
);
|
||||
|
||||
$this->syncOperationRunFromRun(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
run: $run->refresh(),
|
||||
);
|
||||
|
||||
$auditLogger->log(
|
||||
tenant: $tenant,
|
||||
action: 'backup_schedule.run_failed',
|
||||
context: [
|
||||
'metadata' => [
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'backup_schedule_run_id' => $run->id,
|
||||
'error_code' => $mapped['error_code'],
|
||||
],
|
||||
],
|
||||
resourceType: 'backup_schedule_run',
|
||||
resourceId: (string) $run->id,
|
||||
status: 'failed'
|
||||
);
|
||||
} finally {
|
||||
optional($lock)->release();
|
||||
}
|
||||
}
|
||||
|
||||
private function handleFromScheduleId(
|
||||
int $backupScheduleId,
|
||||
PolicySyncService $policySyncService,
|
||||
BackupService $backupService,
|
||||
PolicyTypeResolver $policyTypeResolver,
|
||||
ScheduleTimeService $scheduleTimeService,
|
||||
AuditLogger $auditLogger,
|
||||
RunErrorMapper $errorMapper,
|
||||
): void {
|
||||
$schedule = BackupSchedule::query()
|
||||
->with('tenant')
|
||||
->find($backupScheduleId);
|
||||
@ -402,15 +113,15 @@ private function handleFromScheduleId(
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var OperationRunService $operationRunService */
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
|
||||
$this->operationRun->update([
|
||||
'context' => array_merge($this->operationRun->context ?? [], [
|
||||
'backup_schedule_id' => (int) $schedule->getKey(),
|
||||
]),
|
||||
]);
|
||||
|
||||
/** @var OperationRunService $operationRunService */
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
|
||||
if ($this->operationRun->status === 'queued') {
|
||||
$operationRunService->updateRun($this->operationRun, 'running');
|
||||
}
|
||||
@ -422,7 +133,7 @@ private function handleFromScheduleId(
|
||||
|
||||
$this->finishSchedule(
|
||||
schedule: $schedule,
|
||||
status: BackupScheduleRun::STATUS_SKIPPED,
|
||||
status: self::STATUS_SKIPPED,
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
nowUtc: $nowUtc,
|
||||
);
|
||||
@ -430,11 +141,12 @@ private function handleFromScheduleId(
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: 'completed',
|
||||
outcome: 'failed',
|
||||
outcome: OperationRunOutcome::Blocked->value,
|
||||
summaryCounts: [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'failed' => 1,
|
||||
'failed' => 0,
|
||||
'skipped' => 1,
|
||||
],
|
||||
failures: [
|
||||
[
|
||||
@ -447,7 +159,7 @@ private function handleFromScheduleId(
|
||||
$this->notifyScheduleRunFinished(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
status: BackupScheduleRun::STATUS_SKIPPED,
|
||||
status: self::STATUS_SKIPPED,
|
||||
errorMessage: 'Another run is already in progress for this schedule.',
|
||||
);
|
||||
|
||||
@ -495,7 +207,7 @@ private function handleFromScheduleId(
|
||||
if (empty($validTypes)) {
|
||||
$this->finishSchedule(
|
||||
schedule: $schedule,
|
||||
status: BackupScheduleRun::STATUS_SKIPPED,
|
||||
status: self::STATUS_SKIPPED,
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
nowUtc: $nowUtc,
|
||||
);
|
||||
@ -503,11 +215,12 @@ private function handleFromScheduleId(
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: 'completed',
|
||||
outcome: 'failed',
|
||||
outcome: OperationRunOutcome::Blocked->value,
|
||||
summaryCounts: [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'failed' => 1,
|
||||
'failed' => 0,
|
||||
'skipped' => 1,
|
||||
],
|
||||
failures: [
|
||||
[
|
||||
@ -520,7 +233,7 @@ private function handleFromScheduleId(
|
||||
$this->notifyScheduleRunFinished(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
status: BackupScheduleRun::STATUS_SKIPPED,
|
||||
status: self::STATUS_SKIPPED,
|
||||
errorMessage: 'All configured policy types are unknown.',
|
||||
);
|
||||
|
||||
@ -549,17 +262,17 @@ private function handleFromScheduleId(
|
||||
);
|
||||
|
||||
$status = match ($backupSet->status) {
|
||||
'completed' => BackupScheduleRun::STATUS_SUCCESS,
|
||||
'partial' => BackupScheduleRun::STATUS_PARTIAL,
|
||||
'failed' => BackupScheduleRun::STATUS_FAILED,
|
||||
default => BackupScheduleRun::STATUS_SUCCESS,
|
||||
'completed' => self::STATUS_SUCCESS,
|
||||
'partial' => self::STATUS_PARTIAL,
|
||||
'failed' => self::STATUS_FAILED,
|
||||
default => self::STATUS_SUCCESS,
|
||||
};
|
||||
|
||||
$errorCode = null;
|
||||
$errorMessage = null;
|
||||
|
||||
if (! empty($unknownTypes)) {
|
||||
$status = BackupScheduleRun::STATUS_PARTIAL;
|
||||
$status = self::STATUS_PARTIAL;
|
||||
$errorCode = 'UNKNOWN_POLICY_TYPE';
|
||||
$errorMessage = 'Some configured policy types are unknown and were skipped.';
|
||||
}
|
||||
@ -626,9 +339,10 @@ private function handleFromScheduleId(
|
||||
]);
|
||||
|
||||
$outcome = match ($status) {
|
||||
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
|
||||
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
|
||||
default => 'failed',
|
||||
self::STATUS_SUCCESS => OperationRunOutcome::Succeeded->value,
|
||||
self::STATUS_PARTIAL => OperationRunOutcome::PartiallySucceeded->value,
|
||||
self::STATUS_SKIPPED => OperationRunOutcome::Blocked->value,
|
||||
default => OperationRunOutcome::Failed->value,
|
||||
};
|
||||
|
||||
$operationRunService->updateRun(
|
||||
@ -653,8 +367,8 @@ private function handleFromScheduleId(
|
||||
errorMessage: $errorMessage,
|
||||
);
|
||||
|
||||
if (in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) {
|
||||
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
|
||||
if (in_array($status, [self::STATUS_SUCCESS, self::STATUS_PARTIAL], true)) {
|
||||
Bus::dispatch(new ApplyBackupScheduleRetentionJob((int) $schedule->getKey()));
|
||||
}
|
||||
|
||||
$auditLogger->log(
|
||||
@ -670,14 +384,14 @@ private function handleFromScheduleId(
|
||||
],
|
||||
resourceType: 'operation_run',
|
||||
resourceId: (string) $this->operationRun->getKey(),
|
||||
status: in_array($status, [BackupScheduleRun::STATUS_SUCCESS], true) ? 'success' : 'partial'
|
||||
status: in_array($status, [self::STATUS_SUCCESS], true) ? 'success' : 'partial'
|
||||
);
|
||||
} catch (\Throwable $throwable) {
|
||||
$attempt = (int) method_exists($this, 'attempts') ? $this->attempts() : 1;
|
||||
$mapped = $errorMapper->map($throwable, $attempt, $this->tries);
|
||||
|
||||
if ($mapped['shouldRetry']) {
|
||||
$operationRunService->updateRun($this->operationRun, 'running', 'pending');
|
||||
$operationRunService->updateRun($this->operationRun, 'running', OperationRunOutcome::Pending->value);
|
||||
|
||||
$this->release($mapped['delay']);
|
||||
|
||||
@ -688,7 +402,7 @@ private function handleFromScheduleId(
|
||||
|
||||
$this->finishSchedule(
|
||||
schedule: $schedule,
|
||||
status: BackupScheduleRun::STATUS_FAILED,
|
||||
status: self::STATUS_FAILED,
|
||||
scheduleTimeService: $scheduleTimeService,
|
||||
nowUtc: $nowUtc,
|
||||
);
|
||||
@ -696,7 +410,7 @@ private function handleFromScheduleId(
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: 'completed',
|
||||
outcome: 'failed',
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
@ -713,7 +427,7 @@ private function handleFromScheduleId(
|
||||
$this->notifyScheduleRunFinished(
|
||||
tenant: $tenant,
|
||||
schedule: $schedule,
|
||||
status: BackupScheduleRun::STATUS_FAILED,
|
||||
status: self::STATUS_FAILED,
|
||||
errorMessage: (string) $mapped['error_message'],
|
||||
);
|
||||
|
||||
@ -736,6 +450,17 @@ private function handleFromScheduleId(
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveBackupScheduleId(): int
|
||||
{
|
||||
if ($this->backupScheduleId !== null && $this->backupScheduleId > 0) {
|
||||
return $this->backupScheduleId;
|
||||
}
|
||||
|
||||
$contextScheduleId = data_get($this->operationRun?->context, 'backup_schedule_id');
|
||||
|
||||
return is_numeric($contextScheduleId) ? (int) $contextScheduleId : 0;
|
||||
}
|
||||
|
||||
private function notifyScheduleRunStarted(Tenant $tenant, BackupSchedule $schedule): void
|
||||
{
|
||||
$userId = $this->operationRun?->user_id;
|
||||
@ -744,9 +469,9 @@ private function notifyScheduleRunStarted(Tenant $tenant, BackupSchedule $schedu
|
||||
return;
|
||||
}
|
||||
|
||||
$user = \App\Models\User::query()->find($userId);
|
||||
$user = User::query()->find($userId);
|
||||
|
||||
if (! $user) {
|
||||
if (! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -774,16 +499,16 @@ private function notifyScheduleRunFinished(
|
||||
return;
|
||||
}
|
||||
|
||||
$user = \App\Models\User::query()->find($userId);
|
||||
$user = User::query()->find($userId);
|
||||
|
||||
if (! $user) {
|
||||
if (! $user instanceof User) {
|
||||
return;
|
||||
}
|
||||
|
||||
$title = match ($status) {
|
||||
BackupScheduleRun::STATUS_SUCCESS => 'Backup completed',
|
||||
BackupScheduleRun::STATUS_PARTIAL => 'Backup completed (partial)',
|
||||
BackupScheduleRun::STATUS_SKIPPED => 'Backup skipped',
|
||||
self::STATUS_SUCCESS => 'Backup completed',
|
||||
self::STATUS_PARTIAL => 'Backup completed (partial)',
|
||||
self::STATUS_SKIPPED => 'Backup skipped',
|
||||
default => 'Backup failed',
|
||||
};
|
||||
|
||||
@ -796,8 +521,8 @@ private function notifyScheduleRunFinished(
|
||||
}
|
||||
|
||||
match ($status) {
|
||||
BackupScheduleRun::STATUS_SUCCESS => $notification->success(),
|
||||
BackupScheduleRun::STATUS_PARTIAL, BackupScheduleRun::STATUS_SKIPPED => $notification->warning(),
|
||||
self::STATUS_SUCCESS => $notification->success(),
|
||||
self::STATUS_PARTIAL, self::STATUS_SKIPPED => $notification->warning(),
|
||||
default => $notification->danger(),
|
||||
};
|
||||
|
||||
@ -823,163 +548,6 @@ private function finishSchedule(
|
||||
])->saveQuietly();
|
||||
}
|
||||
|
||||
private function notifyRunStarted(BackupScheduleRun $run, BackupSchedule $schedule): void
|
||||
{
|
||||
$user = $run->user;
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$notification = Notification::make()
|
||||
->title('Backup started')
|
||||
->body(sprintf('Schedule "%s" has started.', $schedule->name))
|
||||
->info()
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $run->tenant) : OperationRunLinks::index($run->tenant)),
|
||||
]);
|
||||
|
||||
$notification->sendToDatabase($user);
|
||||
}
|
||||
|
||||
private function notifyRunFinished(BackupScheduleRun $run, BackupSchedule $schedule): void
|
||||
{
|
||||
$user = $run->user;
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$title = match ($run->status) {
|
||||
BackupScheduleRun::STATUS_SUCCESS => 'Backup completed',
|
||||
BackupScheduleRun::STATUS_PARTIAL => 'Backup completed (partial)',
|
||||
BackupScheduleRun::STATUS_SKIPPED => 'Backup skipped',
|
||||
default => 'Backup failed',
|
||||
};
|
||||
|
||||
$notification = Notification::make()
|
||||
->title($title)
|
||||
->body(sprintf('Schedule "%s" finished with status: %s.', $schedule->name, $run->status));
|
||||
|
||||
if (filled($run->error_message)) {
|
||||
$notification->body($notification->getBody()."\n".$run->error_message);
|
||||
}
|
||||
|
||||
match ($run->status) {
|
||||
BackupScheduleRun::STATUS_SUCCESS => $notification->success(),
|
||||
BackupScheduleRun::STATUS_PARTIAL, BackupScheduleRun::STATUS_SKIPPED => $notification->warning(),
|
||||
default => $notification->danger(),
|
||||
};
|
||||
|
||||
$notification
|
||||
->actions([
|
||||
Action::make('view_run')
|
||||
->label('View run')
|
||||
->url($this->operationRun ? OperationRunLinks::view($this->operationRun, $run->tenant) : OperationRunLinks::index($run->tenant)),
|
||||
])
|
||||
->sendToDatabase($user);
|
||||
}
|
||||
|
||||
private function syncOperationRunFromRun(
|
||||
Tenant $tenant,
|
||||
BackupSchedule $schedule,
|
||||
BackupScheduleRun $run,
|
||||
): void {
|
||||
if (! $this->operationRun) {
|
||||
return;
|
||||
}
|
||||
|
||||
$outcome = match ($run->status) {
|
||||
BackupScheduleRun::STATUS_SUCCESS => 'succeeded',
|
||||
BackupScheduleRun::STATUS_PARTIAL => 'partially_succeeded',
|
||||
// Note: 'cancelled' is a reserved OperationRun outcome token.
|
||||
// We treat schedule SKIPPED/CANCELED as terminal failures with a failure entry.
|
||||
BackupScheduleRun::STATUS_SKIPPED,
|
||||
BackupScheduleRun::STATUS_CANCELED => 'failed',
|
||||
default => 'failed',
|
||||
};
|
||||
|
||||
$summary = is_array($run->summary) ? $run->summary : [];
|
||||
$syncFailures = $summary['sync_failures'] ?? [];
|
||||
|
||||
$policiesTotal = (int) ($summary['policies_total'] ?? 0);
|
||||
$policiesBackedUp = (int) ($summary['policies_backed_up'] ?? 0);
|
||||
$syncFailureCount = is_array($syncFailures) ? count($syncFailures) : 0;
|
||||
|
||||
$failedCount = max(0, $policiesTotal - $policiesBackedUp);
|
||||
|
||||
$summaryCounts = [
|
||||
'total' => $policiesTotal,
|
||||
'processed' => $policiesTotal,
|
||||
'succeeded' => $policiesBackedUp,
|
||||
'failed' => $failedCount,
|
||||
'skipped' => 0,
|
||||
'created' => filled($run->backup_set_id) ? 1 : 0,
|
||||
'updated' => $policiesBackedUp,
|
||||
'items' => $policiesTotal,
|
||||
];
|
||||
|
||||
$failures = [];
|
||||
|
||||
if (filled($run->error_message) || filled($run->error_code)) {
|
||||
$failures[] = [
|
||||
'code' => strtolower((string) ($run->error_code ?: 'backup_schedule_error')),
|
||||
'message' => (string) ($run->error_message ?: 'Backup schedule run failed.'),
|
||||
];
|
||||
}
|
||||
|
||||
if (is_array($syncFailures)) {
|
||||
foreach ($syncFailures as $failure) {
|
||||
if (! is_array($failure)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$policyType = (string) ($failure['policy_type'] ?? 'unknown');
|
||||
$status = is_numeric($failure['status'] ?? null) ? (int) $failure['status'] : null;
|
||||
$errors = $failure['errors'] ?? null;
|
||||
|
||||
$firstErrorMessage = null;
|
||||
if (is_array($errors) && isset($errors[0]) && is_array($errors[0])) {
|
||||
$firstErrorMessage = $errors[0]['message'] ?? null;
|
||||
}
|
||||
|
||||
$message = $status !== null
|
||||
? "{$policyType}: Graph returned {$status}"
|
||||
: "{$policyType}: Graph request failed";
|
||||
|
||||
if (is_string($firstErrorMessage) && $firstErrorMessage !== '') {
|
||||
$message .= ' - '.trim($firstErrorMessage);
|
||||
}
|
||||
|
||||
$failures[] = [
|
||||
'code' => $status !== null ? 'graph_http_'.(string) $status : 'graph_error',
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
/** @var OperationRunService $operationRunService */
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
|
||||
$this->operationRun->update([
|
||||
'context' => array_merge($this->operationRun->context ?? [], [
|
||||
'backup_schedule_id' => (int) $schedule->getKey(),
|
||||
'backup_schedule_run_id' => (int) $run->getKey(),
|
||||
'backup_set_id' => $run->backup_set_id ? (int) $run->backup_set_id : null,
|
||||
]),
|
||||
]);
|
||||
|
||||
$operationRunService->updateRun(
|
||||
$this->operationRun,
|
||||
status: 'completed',
|
||||
outcome: $outcome,
|
||||
summaryCounts: $summaryCounts,
|
||||
failures: $failures,
|
||||
);
|
||||
}
|
||||
|
||||
private function markOperationRunFailed(
|
||||
OperationRun $run,
|
||||
array $summaryCounts,
|
||||
@ -992,7 +560,7 @@ private function markOperationRunFailed(
|
||||
$operationRunService->updateRun(
|
||||
$run,
|
||||
status: 'completed',
|
||||
outcome: 'failed',
|
||||
outcome: OperationRunOutcome::Failed->value,
|
||||
summaryCounts: $summaryCounts,
|
||||
failures: [
|
||||
[
|
||||
@ -1002,38 +570,4 @@ private function markOperationRunFailed(
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
private function finishRun(
|
||||
BackupScheduleRun $run,
|
||||
BackupSchedule $schedule,
|
||||
string $status,
|
||||
?string $errorCode,
|
||||
?string $errorMessage,
|
||||
array $summary,
|
||||
ScheduleTimeService $scheduleTimeService,
|
||||
?string $backupSetId = null,
|
||||
): void {
|
||||
$nowUtc = CarbonImmutable::now('UTC');
|
||||
|
||||
$run->forceFill([
|
||||
'status' => $status,
|
||||
'error_code' => $errorCode,
|
||||
'error_message' => $errorMessage,
|
||||
'summary' => Arr::wrap($summary),
|
||||
'finished_at' => $nowUtc,
|
||||
'backup_set_id' => $backupSetId,
|
||||
])->save();
|
||||
|
||||
$schedule->forceFill([
|
||||
'last_run_at' => $nowUtc,
|
||||
'last_run_status' => $status,
|
||||
'next_run_at' => $scheduleTimeService->nextRunFor($schedule, $nowUtc),
|
||||
])->saveQuietly();
|
||||
|
||||
$this->notifyRunFinished($run, $schedule);
|
||||
|
||||
if ($backupSetId && in_array($status, [BackupScheduleRun::STATUS_SUCCESS, BackupScheduleRun::STATUS_PARTIAL], true)) {
|
||||
Bus::dispatch(new ApplyBackupScheduleRetentionJob($schedule->id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@
|
||||
use App\Services\OperationRunService;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
use App\Support\Providers\ProviderReasonCodes;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
@ -79,7 +80,6 @@ public function handle(InventorySyncService $inventorySyncService, AuditLogger $
|
||||
// However, InventorySyncService execution logic might be complex with partial failures.
|
||||
// We might want to explicitly update the OperationRun if partial failures occur.
|
||||
|
||||
|
||||
$result = $inventorySyncService->executeSelection(
|
||||
$this->operationRun,
|
||||
$tenant,
|
||||
@ -97,10 +97,49 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
|
||||
},
|
||||
);
|
||||
|
||||
$updatedContext = is_array($this->operationRun->context) ? $this->operationRun->context : [];
|
||||
$updatedContext['result'] = [
|
||||
'had_errors' => (bool) ($result['had_errors'] ?? true),
|
||||
'error_codes' => is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : [],
|
||||
'error_context' => is_array($result['error_context'] ?? null) ? $result['error_context'] : null,
|
||||
];
|
||||
|
||||
$this->operationRun->update([
|
||||
'context' => $updatedContext,
|
||||
]);
|
||||
|
||||
$this->operationRun->refresh();
|
||||
|
||||
$status = (string) ($result['status'] ?? 'failed');
|
||||
$errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : [];
|
||||
$reason = (string) ($errorCodes[0] ?? $status);
|
||||
|
||||
$errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : [];
|
||||
$sanitizedErrorMessage = is_string($errorContext['message'] ?? null) ? (string) $errorContext['message'] : null;
|
||||
|
||||
$reasonCode = null;
|
||||
$sanitizedMessageWithoutReasonCode = null;
|
||||
|
||||
if (is_string($sanitizedErrorMessage)) {
|
||||
$sanitizedMessageWithoutReasonCode = preg_replace('/^\[[^\]]+\]\s*/', '', $sanitizedErrorMessage);
|
||||
$sanitizedMessageWithoutReasonCode = is_string($sanitizedMessageWithoutReasonCode)
|
||||
? trim($sanitizedMessageWithoutReasonCode)
|
||||
: null;
|
||||
|
||||
if (preg_match('/^\[(?<code>[^\]]+)\]/', $sanitizedErrorMessage, $m)) {
|
||||
$candidate = (string) ($m['code'] ?? '');
|
||||
if ($candidate !== '' && ProviderReasonCodes::isKnown($candidate)) {
|
||||
$reasonCode = $candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($reason === 'unexpected_exception' && is_string($sanitizedErrorMessage) && $sanitizedErrorMessage !== '') {
|
||||
$reason = is_string($sanitizedMessageWithoutReasonCode) && $sanitizedMessageWithoutReasonCode !== ''
|
||||
? $sanitizedMessageWithoutReasonCode
|
||||
: $sanitizedErrorMessage;
|
||||
}
|
||||
|
||||
$itemsObserved = (int) ($result['items_observed_count'] ?? 0);
|
||||
$itemsUpserted = (int) ($result['items_upserted_count'] ?? 0);
|
||||
$errorsCount = (int) ($result['errors_count'] ?? 0);
|
||||
@ -229,7 +268,7 @@ function (string $policyType, bool $success, ?string $errorCode) use (&$processe
|
||||
'failed' => max($failedCount, count($missingPolicyTypes)),
|
||||
],
|
||||
failures: [
|
||||
['code' => 'inventory.failed', 'message' => $reason],
|
||||
['code' => 'inventory.failed', 'reason_code' => $reasonCode, 'message' => $reason],
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@ -3,9 +3,9 @@
|
||||
namespace App\Livewire;
|
||||
|
||||
use App\Filament\Resources\EntraGroupResource;
|
||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
use Filament\Actions\Action;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
@ -151,9 +151,9 @@ public function table(Table $table): Table
|
||||
->icon('heroicon-o-user-group')
|
||||
->url(fn (): string => EntraGroupResource::getUrl('index', tenant: Tenant::current())),
|
||||
Action::make('open_sync_runs')
|
||||
->label('Group Sync Runs')
|
||||
->label('Operations')
|
||||
->icon('heroicon-o-clock')
|
||||
->url(fn (): string => EntraGroupSyncRunResource::getUrl('index', tenant: Tenant::current())),
|
||||
->url(fn (): string => OperationRunLinks::index(Tenant::current())),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@ -27,18 +27,13 @@ public function tenant(): BelongsTo
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function runs(): HasMany
|
||||
{
|
||||
return $this->hasMany(BackupScheduleRun::class);
|
||||
}
|
||||
|
||||
public function operationRuns(): HasMany
|
||||
{
|
||||
return $this->hasMany(OperationRun::class, 'tenant_id', 'tenant_id')
|
||||
->whereIn('type', [
|
||||
'backup_schedule.run_now',
|
||||
'backup_schedule.retry',
|
||||
'backup_schedule.scheduled',
|
||||
'backup_schedule_run',
|
||||
'backup_schedule_retention',
|
||||
'backup_schedule_purge',
|
||||
])
|
||||
->where('context->backup_schedule_id', (int) $this->getKey());
|
||||
}
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class BackupScheduleRun extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_RUNNING = 'running';
|
||||
|
||||
public const STATUS_SUCCESS = 'success';
|
||||
|
||||
public const STATUS_PARTIAL = 'partial';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const STATUS_CANCELED = 'canceled';
|
||||
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'scheduled_for' => 'datetime',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
'summary' => 'array',
|
||||
];
|
||||
|
||||
public function schedule(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BackupSchedule::class, 'backup_schedule_id');
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function backupSet(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(BackupSet::class);
|
||||
}
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class EntraGroupSyncRun extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_RUNNING = 'running';
|
||||
|
||||
public const STATUS_SUCCEEDED = 'succeeded';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const STATUS_PARTIAL = 'partial';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'safety_stop_triggered' => 'boolean',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function initiator(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'initiator_user_id');
|
||||
}
|
||||
}
|
||||
@ -37,12 +37,12 @@ public function tenant(): BelongsTo
|
||||
|
||||
public function baselineRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventorySyncRun::class, 'baseline_run_id');
|
||||
return $this->belongsTo(OperationRun::class, 'baseline_operation_run_id');
|
||||
}
|
||||
|
||||
public function currentRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventorySyncRun::class, 'current_run_id');
|
||||
return $this->belongsTo(OperationRun::class, 'current_operation_run_id');
|
||||
}
|
||||
|
||||
public function acknowledgedByUser(): BelongsTo
|
||||
|
||||
@ -25,7 +25,7 @@ public function tenant(): BelongsTo
|
||||
|
||||
public function lastSeenRun(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(InventorySyncRun::class, 'last_seen_run_id');
|
||||
return $this->belongsTo(OperationRun::class, 'last_seen_operation_run_id');
|
||||
}
|
||||
|
||||
public function lastSeenOperationRun(): BelongsTo
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class InventorySyncRun extends Model
|
||||
{
|
||||
/** @use HasFactory<\Database\Factories\InventorySyncRunFactory> */
|
||||
use HasFactory;
|
||||
|
||||
public const STATUS_RUNNING = 'running';
|
||||
|
||||
public const STATUS_SUCCESS = 'success';
|
||||
|
||||
public const STATUS_PARTIAL = 'partial';
|
||||
|
||||
public const STATUS_FAILED = 'failed';
|
||||
|
||||
public const STATUS_SKIPPED = 'skipped';
|
||||
|
||||
protected $guarded = [];
|
||||
|
||||
protected $casts = [
|
||||
'selection_payload' => 'array',
|
||||
'had_errors' => 'boolean',
|
||||
'error_codes' => 'array',
|
||||
'error_context' => 'array',
|
||||
'started_at' => 'datetime',
|
||||
'finished_at' => 'datetime',
|
||||
];
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function scopeCompleted(Builder $query): Builder
|
||||
{
|
||||
return $query
|
||||
->whereIn('status', [
|
||||
self::STATUS_SUCCESS,
|
||||
self::STATUS_PARTIAL,
|
||||
self::STATUS_FAILED,
|
||||
self::STATUS_SKIPPED,
|
||||
])
|
||||
->whereNotNull('finished_at');
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Arr;
|
||||
|
||||
class OperationRun extends Model
|
||||
{
|
||||
@ -65,4 +66,65 @@ public function scopeActive(Builder $query): Builder
|
||||
{
|
||||
return $query->whereIn('status', ['queued', 'running']);
|
||||
}
|
||||
|
||||
public function getSelectionHashAttribute(): ?string
|
||||
{
|
||||
$context = is_array($this->context) ? $this->context : [];
|
||||
|
||||
return isset($context['selection_hash']) && is_string($context['selection_hash'])
|
||||
? $context['selection_hash']
|
||||
: null;
|
||||
}
|
||||
|
||||
public function setSelectionHashAttribute(?string $value): void
|
||||
{
|
||||
$context = is_array($this->context) ? $this->context : [];
|
||||
$context['selection_hash'] = $value;
|
||||
|
||||
$this->context = $context;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getSelectionPayloadAttribute(): array
|
||||
{
|
||||
$context = is_array($this->context) ? $this->context : [];
|
||||
|
||||
return Arr::only($context, [
|
||||
'policy_types',
|
||||
'categories',
|
||||
'include_foundations',
|
||||
'include_dependencies',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $value
|
||||
*/
|
||||
public function setSelectionPayloadAttribute(?array $value): void
|
||||
{
|
||||
$context = is_array($this->context) ? $this->context : [];
|
||||
|
||||
if (is_array($value)) {
|
||||
$context = array_merge($context, Arr::only($value, [
|
||||
'policy_types',
|
||||
'categories',
|
||||
'include_foundations',
|
||||
'include_dependencies',
|
||||
]));
|
||||
}
|
||||
|
||||
$this->context = $context;
|
||||
}
|
||||
|
||||
public function getFinishedAtAttribute(): mixed
|
||||
{
|
||||
return $this->completed_at;
|
||||
}
|
||||
|
||||
public function setFinishedAtAttribute(mixed $value): void
|
||||
{
|
||||
$this->completed_at = $value;
|
||||
}
|
||||
}
|
||||
|
||||
@ -240,11 +240,6 @@ public function backupSchedules(): HasMany
|
||||
return $this->hasMany(BackupSchedule::class);
|
||||
}
|
||||
|
||||
public function backupScheduleRuns(): HasMany
|
||||
{
|
||||
return $this->hasMany(BackupScheduleRun::class);
|
||||
}
|
||||
|
||||
public function policyVersions(): HasMany
|
||||
{
|
||||
return $this->hasMany(PolicyVersion::class);
|
||||
@ -260,11 +255,6 @@ public function entraGroups(): HasMany
|
||||
return $this->hasMany(EntraGroup::class);
|
||||
}
|
||||
|
||||
public function entraGroupSyncRuns(): HasMany
|
||||
{
|
||||
return $this->hasMany(EntraGroupSyncRun::class);
|
||||
}
|
||||
|
||||
public function auditLogs(): HasMany
|
||||
{
|
||||
return $this->hasMany(AuditLog::class);
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Notifications;
|
||||
|
||||
use App\Filament\Resources\EntraGroupSyncRunResource;
|
||||
use App\Filament\Resources\RestoreRunResource;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunLinks;
|
||||
@ -61,16 +60,13 @@ public function toDatabase(object $notifiable): array
|
||||
|
||||
$actions = [];
|
||||
|
||||
if (in_array($runType, ['bulk_operation', 'restore', 'directory_groups'], true) && $tenantId > 0 && $runId > 0) {
|
||||
if ($tenantId > 0 && $runId > 0) {
|
||||
$tenant = Tenant::query()->find($tenantId);
|
||||
|
||||
if ($tenant) {
|
||||
$url = match ($runType) {
|
||||
'bulk_operation' => OperationRunLinks::view($runId, $tenant),
|
||||
'restore' => RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant),
|
||||
'directory_groups' => OperationRunLinks::view($runId, $tenant),
|
||||
default => null,
|
||||
};
|
||||
$url = $runType === 'restore'
|
||||
? RestoreRunResource::getUrl('view', ['record' => $runId], tenant: $tenant)
|
||||
: OperationRunLinks::view($runId, $tenant);
|
||||
|
||||
if (! $url) {
|
||||
return [
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\HandlesAuthorization;
|
||||
|
||||
class EntraGroupSyncRunPolicy
|
||||
{
|
||||
use HandlesAuthorization;
|
||||
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->canAccessTenant($tenant);
|
||||
}
|
||||
|
||||
public function view(User $user, EntraGroupSyncRun $run): bool
|
||||
{
|
||||
$tenant = Tenant::current();
|
||||
|
||||
if (! $tenant) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $user->canAccessTenant($tenant)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (int) $run->tenant_id === (int) $tenant->getKey();
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,6 @@
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\Finding;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderCredential;
|
||||
@ -16,7 +15,6 @@
|
||||
use App\Observers\RestoreRunObserver;
|
||||
use App\Policies\BackupSchedulePolicy;
|
||||
use App\Policies\EntraGroupPolicy;
|
||||
use App\Policies\EntraGroupSyncRunPolicy;
|
||||
use App\Policies\FindingPolicy;
|
||||
use App\Policies\OperationRunPolicy;
|
||||
use App\Services\Graph\GraphClientInterface;
|
||||
@ -136,7 +134,6 @@ public function boot(): void
|
||||
|
||||
Gate::policy(BackupSchedule::class, BackupSchedulePolicy::class);
|
||||
Gate::policy(Finding::class, FindingPolicy::class);
|
||||
Gate::policy(EntraGroupSyncRun::class, EntraGroupSyncRunPolicy::class);
|
||||
Gate::policy(EntraGroup::class, EntraGroupPolicy::class);
|
||||
Gate::policy(OperationRun::class, OperationRunPolicy::class);
|
||||
}
|
||||
|
||||
@ -67,7 +67,7 @@ public function dispatchDue(?array $tenantIdentifiers = null): array
|
||||
|
||||
$operationRun = $this->operationRunService->ensureRunWithIdentityStrict(
|
||||
tenant: $schedule->tenant,
|
||||
type: OperationRunType::BackupScheduleScheduled->value,
|
||||
type: OperationRunType::BackupScheduleExecute->value,
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $schedule->id,
|
||||
'scheduled_for' => $scheduledFor->toDateTimeString(),
|
||||
|
||||
@ -30,7 +30,7 @@ public function startManualSync(Tenant $tenant, User $user): OperationRun
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'directory_groups.sync',
|
||||
type: 'entra_group_sync',
|
||||
identityInputs: ['selection_key' => $selectionKey],
|
||||
context: [
|
||||
'selection_key' => $selectionKey,
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
namespace App\Services\Drift;
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Models\Tenant;
|
||||
@ -21,14 +21,14 @@ public function __construct(
|
||||
private readonly ScopeTagsNormalizer $scopeTagsNormalizer,
|
||||
) {}
|
||||
|
||||
public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySyncRun $current, string $scopeKey): int
|
||||
public function generate(Tenant $tenant, OperationRun $baseline, OperationRun $current, string $scopeKey): int
|
||||
{
|
||||
if (! $baseline->finished_at || ! $current->finished_at) {
|
||||
if (! $baseline->completed_at || ! $current->completed_at) {
|
||||
throw new RuntimeException('Baseline/current run must be finished.');
|
||||
}
|
||||
|
||||
/** @var array<string, mixed> $selection */
|
||||
$selection = is_array($current->selection_payload) ? $current->selection_payload : [];
|
||||
$selection = is_array($current->context) ? $current->context : [];
|
||||
|
||||
$policyTypes = Arr::get($selection, 'policy_types');
|
||||
if (! is_array($policyTypes)) {
|
||||
@ -114,8 +114,8 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
|
||||
$finding->forceFill([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => $baseline->getKey(),
|
||||
'current_run_id' => $current->getKey(),
|
||||
'baseline_operation_run_id' => $baseline->getKey(),
|
||||
'current_operation_run_id' => $current->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => (string) $policy->external_id,
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
@ -187,8 +187,8 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
|
||||
$finding->forceFill([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => $baseline->getKey(),
|
||||
'current_run_id' => $current->getKey(),
|
||||
'baseline_operation_run_id' => $baseline->getKey(),
|
||||
'current_operation_run_id' => $current->getKey(),
|
||||
'subject_type' => 'assignment',
|
||||
'subject_external_id' => (string) $policy->external_id,
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
@ -262,8 +262,8 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
|
||||
$finding->forceFill([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => $baseline->getKey(),
|
||||
'current_run_id' => $current->getKey(),
|
||||
'baseline_operation_run_id' => $baseline->getKey(),
|
||||
'current_operation_run_id' => $current->getKey(),
|
||||
'subject_type' => 'scope_tag',
|
||||
'subject_external_id' => (string) $policy->external_id,
|
||||
'severity' => Finding::SEVERITY_MEDIUM,
|
||||
@ -289,16 +289,16 @@ public function generate(Tenant $tenant, InventorySyncRun $baseline, InventorySy
|
||||
return $created;
|
||||
}
|
||||
|
||||
private function versionForRun(Policy $policy, InventorySyncRun $run): ?PolicyVersion
|
||||
private function versionForRun(Policy $policy, OperationRun $run): ?PolicyVersion
|
||||
{
|
||||
if (! $run->finished_at) {
|
||||
if (! $run->completed_at) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return PolicyVersion::query()
|
||||
->where('tenant_id', $policy->tenant_id)
|
||||
->where('policy_id', $policy->getKey())
|
||||
->where('captured_at', '<=', $run->finished_at)
|
||||
->where('captured_at', '<=', $run->completed_at)
|
||||
->latest('captured_at')
|
||||
->first();
|
||||
}
|
||||
|
||||
@ -2,22 +2,29 @@
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Support\OperationRunOutcome;
|
||||
use App\Support\OperationRunStatus;
|
||||
|
||||
class DriftRunSelector
|
||||
{
|
||||
/**
|
||||
* @return array{baseline:InventorySyncRun,current:InventorySyncRun}|null
|
||||
* @return array{baseline:OperationRun,current:OperationRun}|null
|
||||
*/
|
||||
public function selectBaselineAndCurrent(Tenant $tenant, string $scopeKey): ?array
|
||||
{
|
||||
$runs = InventorySyncRun::query()
|
||||
$runs = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('selection_hash', $scopeKey)
|
||||
->where('status', InventorySyncRun::STATUS_SUCCESS)
|
||||
->whereNotNull('finished_at')
|
||||
->orderByDesc('finished_at')
|
||||
->where('type', 'inventory_sync')
|
||||
->where('status', OperationRunStatus::Completed->value)
|
||||
->whereIn('outcome', [
|
||||
OperationRunOutcome::Succeeded->value,
|
||||
OperationRunOutcome::PartiallySucceeded->value,
|
||||
])
|
||||
->where('context->selection_hash', $scopeKey)
|
||||
->whereNotNull('completed_at')
|
||||
->orderByDesc('completed_at')
|
||||
->limit(2)
|
||||
->get();
|
||||
|
||||
@ -28,7 +35,7 @@ public function selectBaselineAndCurrent(Tenant $tenant, string $scopeKey): ?arr
|
||||
$current = $runs->first();
|
||||
$baseline = $runs->last();
|
||||
|
||||
if (! $baseline instanceof InventorySyncRun || ! $current instanceof InventorySyncRun) {
|
||||
if (! $baseline instanceof OperationRun || ! $current instanceof OperationRun) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -2,12 +2,14 @@
|
||||
|
||||
namespace App\Services\Drift;
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\OperationRun;
|
||||
|
||||
class DriftScopeKey
|
||||
{
|
||||
public function fromRun(InventorySyncRun $run): string
|
||||
public function fromRun(OperationRun $run): string
|
||||
{
|
||||
return (string) $run->selection_hash;
|
||||
$context = is_array($run->context) ? $run->context : [];
|
||||
|
||||
return (string) ($context['selection_hash'] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ public function missingForSelection(Tenant $tenant, array $selectionPayload): ar
|
||||
|
||||
$latestRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'inventory.sync')
|
||||
->where('type', 'inventory_sync')
|
||||
->where('status', 'completed')
|
||||
->where('context->selection_hash', $selectionHash)
|
||||
->orderByDesc('completed_at')
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
namespace App\Services\Inventory;
|
||||
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\ProviderConnection;
|
||||
use App\Models\Tenant;
|
||||
@ -34,13 +33,13 @@ public function __construct(
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Runs an inventory sync immediately and persists a corresponding InventorySyncRun.
|
||||
* Runs an inventory sync immediately and persists a canonical OperationRun.
|
||||
*
|
||||
* This is primarily used in tests and for synchronous workflows.
|
||||
*
|
||||
* @param array<string, mixed> $selectionPayload
|
||||
*/
|
||||
public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncRun
|
||||
public function syncNow(Tenant $tenant, array $selectionPayload): OperationRun
|
||||
{
|
||||
$computed = $this->normalizeAndHashSelection($selectionPayload);
|
||||
$normalizedSelection = $computed['selection'];
|
||||
@ -54,40 +53,20 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
|
||||
'type' => OperationRunType::InventorySync->value,
|
||||
'status' => OperationRunStatus::Running->value,
|
||||
'outcome' => OperationRunOutcome::Pending->value,
|
||||
'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory.sync:'.$selectionHash.':'.Str::uuid()->toString()),
|
||||
'context' => $normalizedSelection,
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$run = InventorySyncRun::query()->create([
|
||||
'tenant_id' => (int) $tenant->getKey(),
|
||||
'user_id' => null,
|
||||
'operation_run_id' => (int) $operationRun->getKey(),
|
||||
'selection_hash' => $selectionHash,
|
||||
'selection_payload' => $normalizedSelection,
|
||||
'status' => InventorySyncRun::STATUS_RUNNING,
|
||||
'had_errors' => false,
|
||||
'run_identity_hash' => hash('sha256', (string) $tenant->getKey().':inventory_sync:'.$selectionHash.':'.Str::uuid()->toString()),
|
||||
'context' => array_merge($normalizedSelection, [
|
||||
'selection_hash' => $selectionHash,
|
||||
]),
|
||||
'started_at' => now(),
|
||||
]);
|
||||
|
||||
$result = $this->executeSelection($operationRun, $tenant, $normalizedSelection);
|
||||
|
||||
$status = (string) ($result['status'] ?? InventorySyncRun::STATUS_FAILED);
|
||||
$status = (string) ($result['status'] ?? 'failed');
|
||||
$hadErrors = (bool) ($result['had_errors'] ?? true);
|
||||
$errorCodes = is_array($result['error_codes'] ?? null) ? $result['error_codes'] : null;
|
||||
$errorCodes = is_array($result['error_codes'] ?? null) ? array_values($result['error_codes']) : [];
|
||||
$errorContext = is_array($result['error_context'] ?? null) ? $result['error_context'] : null;
|
||||
|
||||
$run->update([
|
||||
'status' => $status,
|
||||
'had_errors' => $hadErrors,
|
||||
'error_codes' => $errorCodes,
|
||||
'error_context' => $errorContext,
|
||||
'items_observed_count' => (int) ($result['items_observed_count'] ?? 0),
|
||||
'items_upserted_count' => (int) ($result['items_upserted_count'] ?? 0),
|
||||
'errors_count' => (int) ($result['errors_count'] ?? 0),
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
$policyTypes = $normalizedSelection['policy_types'] ?? [];
|
||||
$policyTypes = is_array($policyTypes) ? $policyTypes : [];
|
||||
|
||||
@ -98,6 +77,28 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
|
||||
default => OperationRunOutcome::Failed->value,
|
||||
};
|
||||
|
||||
$failureSummary = [];
|
||||
|
||||
if ($hadErrors && $errorCodes !== []) {
|
||||
foreach (array_values(array_unique($errorCodes)) as $errorCode) {
|
||||
if (! is_string($errorCode) || $errorCode === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$failureSummary[] = [
|
||||
'code' => $errorCode,
|
||||
'message' => sprintf('Inventory sync reported %s.', str_replace('_', ' ', $errorCode)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$updatedContext = is_array($operationRun->context) ? $operationRun->context : [];
|
||||
$updatedContext['result'] = [
|
||||
'had_errors' => $hadErrors,
|
||||
'error_codes' => $errorCodes,
|
||||
'error_context' => $errorContext,
|
||||
];
|
||||
|
||||
$operationRun->update([
|
||||
'status' => OperationRunStatus::Completed->value,
|
||||
'outcome' => $operationOutcome,
|
||||
@ -109,16 +110,18 @@ public function syncNow(Tenant $tenant, array $selectionPayload): InventorySyncR
|
||||
'items' => (int) ($result['items_observed_count'] ?? 0),
|
||||
'updated' => (int) ($result['items_upserted_count'] ?? 0),
|
||||
],
|
||||
'failure_summary' => $failureSummary,
|
||||
'context' => $updatedContext,
|
||||
'completed_at' => now(),
|
||||
]);
|
||||
|
||||
return $run->refresh();
|
||||
return $operationRun->refresh();
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs an inventory sync (inline), enforcing locks/concurrency.
|
||||
*
|
||||
* This method MUST NOT create or update InventorySyncRun rows; OperationRun is canonical.
|
||||
* This method MUST NOT create or update legacy sync-run rows; OperationRun is canonical.
|
||||
*
|
||||
* @param array<string, mixed> $selectionPayload
|
||||
* @param null|callable(string $policyType, bool $success, ?string $errorCode): void $onPolicyTypeProcessed
|
||||
|
||||
@ -17,7 +17,7 @@ public function all(): array
|
||||
'module' => 'health_check',
|
||||
'label' => 'Provider connection check',
|
||||
],
|
||||
'inventory.sync' => [
|
||||
'inventory_sync' => [
|
||||
'provider' => 'microsoft',
|
||||
'module' => 'inventory',
|
||||
'label' => 'Inventory sync',
|
||||
|
||||
@ -14,10 +14,7 @@ final class BadgeCatalog
|
||||
private const DOMAIN_MAPPERS = [
|
||||
BadgeDomain::OperationRunStatus->value => Domains\OperationRunStatusBadge::class,
|
||||
BadgeDomain::OperationRunOutcome->value => Domains\OperationRunOutcomeBadge::class,
|
||||
BadgeDomain::InventorySyncRunStatus->value => Domains\InventorySyncRunStatusBadge::class,
|
||||
BadgeDomain::BackupScheduleRunStatus->value => Domains\BackupScheduleRunStatusBadge::class,
|
||||
BadgeDomain::BackupSetStatus->value => Domains\BackupSetStatusBadge::class,
|
||||
BadgeDomain::EntraGroupSyncRunStatus->value => Domains\EntraGroupSyncRunStatusBadge::class,
|
||||
BadgeDomain::RestoreRunStatus->value => Domains\RestoreRunStatusBadge::class,
|
||||
BadgeDomain::RestoreCheckSeverity->value => Domains\RestoreCheckSeverityBadge::class,
|
||||
BadgeDomain::FindingStatus->value => Domains\FindingStatusBadge::class,
|
||||
|
||||
@ -6,10 +6,7 @@ enum BadgeDomain: string
|
||||
{
|
||||
case OperationRunStatus = 'operation_run_status';
|
||||
case OperationRunOutcome = 'operation_run_outcome';
|
||||
case InventorySyncRunStatus = 'inventory_sync_run_status';
|
||||
case BackupScheduleRunStatus = 'backup_schedule_run_status';
|
||||
case BackupSetStatus = 'backup_set_status';
|
||||
case EntraGroupSyncRunStatus = 'entra_group_sync_run_status';
|
||||
case RestoreRunStatus = 'restore_run_status';
|
||||
case RestoreCheckSeverity = 'restore_check_severity';
|
||||
case FindingStatus = 'finding_status';
|
||||
|
||||
@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class BackupScheduleRunStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
BackupScheduleRun::STATUS_RUNNING => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
||||
BackupScheduleRun::STATUS_SUCCESS => new BadgeSpec('Success', 'success', 'heroicon-m-check-circle'),
|
||||
BackupScheduleRun::STATUS_PARTIAL => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
BackupScheduleRun::STATUS_FAILED => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
BackupScheduleRun::STATUS_CANCELED => new BadgeSpec('Canceled', 'gray', 'heroicon-m-minus-circle'),
|
||||
BackupScheduleRun::STATUS_SKIPPED => new BadgeSpec('Skipped', 'gray', 'heroicon-m-minus-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class EntraGroupSyncRunStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
EntraGroupSyncRun::STATUS_PENDING => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
|
||||
EntraGroupSyncRun::STATUS_RUNNING => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
||||
EntraGroupSyncRun::STATUS_SUCCEEDED => new BadgeSpec('Succeeded', 'success', 'heroicon-m-check-circle'),
|
||||
EntraGroupSyncRun::STATUS_PARTIAL => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
EntraGroupSyncRun::STATUS_FAILED => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Badges\Domains;
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Support\Badges\BadgeCatalog;
|
||||
use App\Support\Badges\BadgeMapper;
|
||||
use App\Support\Badges\BadgeSpec;
|
||||
|
||||
final class InventorySyncRunStatusBadge implements BadgeMapper
|
||||
{
|
||||
public function spec(mixed $value): BadgeSpec
|
||||
{
|
||||
$state = BadgeCatalog::normalizeState($value);
|
||||
|
||||
return match ($state) {
|
||||
InventorySyncRun::STATUS_PENDING, 'queued' => new BadgeSpec('Pending', 'gray', 'heroicon-m-clock'),
|
||||
InventorySyncRun::STATUS_RUNNING => new BadgeSpec('Running', 'info', 'heroicon-m-arrow-path'),
|
||||
InventorySyncRun::STATUS_SUCCESS => new BadgeSpec('Success', 'success', 'heroicon-m-check-circle'),
|
||||
InventorySyncRun::STATUS_PARTIAL => new BadgeSpec('Partial', 'warning', 'heroicon-m-exclamation-triangle'),
|
||||
InventorySyncRun::STATUS_FAILED => new BadgeSpec('Failed', 'danger', 'heroicon-m-x-circle'),
|
||||
InventorySyncRun::STATUS_SKIPPED => new BadgeSpec('Skipped', 'gray', 'heroicon-m-minus-circle'),
|
||||
default => BadgeSpec::unknown(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Support\Inventory;
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
|
||||
class InventorySyncStatusBadge
|
||||
{
|
||||
/**
|
||||
* @return array{label: string, color: string, icon: string}
|
||||
*/
|
||||
public static function for(?string $status): array
|
||||
{
|
||||
$status = (string) ($status ?? '');
|
||||
|
||||
$label = match ($status) {
|
||||
InventorySyncRun::STATUS_SUCCESS => 'Success',
|
||||
InventorySyncRun::STATUS_PARTIAL => 'Partial',
|
||||
InventorySyncRun::STATUS_FAILED => 'Failed',
|
||||
InventorySyncRun::STATUS_RUNNING => 'Running',
|
||||
InventorySyncRun::STATUS_PENDING => 'Pending',
|
||||
InventorySyncRun::STATUS_SKIPPED => 'Skipped',
|
||||
'queued' => 'Queued',
|
||||
default => '—',
|
||||
};
|
||||
|
||||
$color = match ($status) {
|
||||
InventorySyncRun::STATUS_SUCCESS => 'success',
|
||||
InventorySyncRun::STATUS_PARTIAL => 'warning',
|
||||
InventorySyncRun::STATUS_FAILED => 'danger',
|
||||
InventorySyncRun::STATUS_RUNNING => 'info',
|
||||
InventorySyncRun::STATUS_PENDING, 'queued' => 'gray',
|
||||
InventorySyncRun::STATUS_SKIPPED => 'gray',
|
||||
default => 'gray',
|
||||
};
|
||||
|
||||
$icon = match ($status) {
|
||||
InventorySyncRun::STATUS_SUCCESS => 'heroicon-m-check-circle',
|
||||
InventorySyncRun::STATUS_PARTIAL => 'heroicon-m-exclamation-triangle',
|
||||
InventorySyncRun::STATUS_FAILED => 'heroicon-m-x-circle',
|
||||
InventorySyncRun::STATUS_RUNNING => 'heroicon-m-arrow-path',
|
||||
InventorySyncRun::STATUS_PENDING, 'queued' => 'heroicon-m-clock',
|
||||
InventorySyncRun::STATUS_SKIPPED => 'heroicon-m-minus-circle',
|
||||
default => 'heroicon-m-clock',
|
||||
};
|
||||
|
||||
return [
|
||||
'label' => $label,
|
||||
'color' => $color,
|
||||
'icon' => $icon,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -19,18 +19,18 @@ public static function labels(): array
|
||||
'policy.unignore' => 'Restore policies',
|
||||
'policy.export' => 'Export policies to backup',
|
||||
'provider.connection.check' => 'Provider connection check',
|
||||
'inventory.sync' => 'Inventory sync',
|
||||
'inventory_sync' => 'Inventory sync',
|
||||
'compliance.snapshot' => 'Compliance snapshot',
|
||||
'directory_groups.sync' => 'Directory groups sync',
|
||||
'drift.generate' => 'Drift generation',
|
||||
'entra_group_sync' => 'Directory groups sync',
|
||||
'drift_generate_findings' => 'Drift generation',
|
||||
'backup_set.add_policies' => 'Backup set update',
|
||||
'backup_set.remove_policies' => 'Backup set update',
|
||||
'backup_set.delete' => 'Archive backup sets',
|
||||
'backup_set.restore' => 'Restore backup sets',
|
||||
'backup_set.force_delete' => 'Delete backup sets',
|
||||
'backup_schedule.run_now' => 'Backup schedule run',
|
||||
'backup_schedule.retry' => 'Backup schedule retry',
|
||||
'backup_schedule.scheduled' => 'Backup schedule run',
|
||||
'backup_schedule_run' => 'Backup schedule run',
|
||||
'backup_schedule_retention' => 'Backup schedule retention',
|
||||
'backup_schedule_purge' => 'Backup schedule purge',
|
||||
'restore.execute' => 'Restore execution',
|
||||
'directory_role_definitions.sync' => 'Role definitions sync',
|
||||
'restore_run.delete' => 'Delete restore runs',
|
||||
@ -60,10 +60,10 @@ public static function expectedDurationSeconds(string $operationType): ?int
|
||||
'policy.sync', 'policy.sync_one' => 90,
|
||||
'provider.connection.check' => 30,
|
||||
'policy.export' => 120,
|
||||
'inventory.sync' => 180,
|
||||
'inventory_sync' => 180,
|
||||
'compliance.snapshot' => 180,
|
||||
'directory_groups.sync' => 120,
|
||||
'drift.generate' => 240,
|
||||
'entra_group_sync' => 120,
|
||||
'drift_generate_findings' => 240,
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
@ -54,7 +54,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
$links['Provider Connection'] = ProviderConnectionResource::getUrl('edit', ['tenant' => $tenant, 'record' => (int) $providerConnectionId], panel: 'admin');
|
||||
}
|
||||
|
||||
if ($run->type === 'inventory.sync') {
|
||||
if ($run->type === 'inventory_sync') {
|
||||
$links['Inventory'] = InventoryLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
|
||||
@ -67,11 +67,11 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
}
|
||||
}
|
||||
|
||||
if ($run->type === 'directory_groups.sync') {
|
||||
if ($run->type === 'entra_group_sync') {
|
||||
$links['Directory Groups'] = EntraGroupResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
|
||||
if ($run->type === 'drift.generate') {
|
||||
if ($run->type === 'drift_generate_findings') {
|
||||
$links['Drift'] = DriftLanding::getUrl(panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ public static function related(OperationRun $run, ?Tenant $tenant): array
|
||||
}
|
||||
}
|
||||
|
||||
if (in_array($run->type, ['backup_schedule.run_now', 'backup_schedule.retry'], true)) {
|
||||
if (in_array($run->type, ['backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge'], true)) {
|
||||
$links['Backup Schedules'] = BackupScheduleResource::getUrl('index', panel: 'tenant', tenant: $tenant);
|
||||
}
|
||||
|
||||
|
||||
@ -4,16 +4,16 @@
|
||||
|
||||
enum OperationRunType: string
|
||||
{
|
||||
case InventorySync = 'inventory.sync';
|
||||
case InventorySync = 'inventory_sync';
|
||||
case PolicySync = 'policy.sync';
|
||||
case PolicySyncOne = 'policy.sync_one';
|
||||
case DirectoryGroupsSync = 'directory_groups.sync';
|
||||
case DriftGenerate = 'drift.generate';
|
||||
case DirectoryGroupsSync = 'entra_group_sync';
|
||||
case DriftGenerate = 'drift_generate_findings';
|
||||
case BackupSetAddPolicies = 'backup_set.add_policies';
|
||||
case BackupSetRemovePolicies = 'backup_set.remove_policies';
|
||||
case BackupScheduleRunNow = 'backup_schedule.run_now';
|
||||
case BackupScheduleRetry = 'backup_schedule.retry';
|
||||
case BackupScheduleScheduled = 'backup_schedule.scheduled';
|
||||
case BackupScheduleExecute = 'backup_schedule_run';
|
||||
case BackupScheduleRetention = 'backup_schedule_retention';
|
||||
case BackupSchedulePurge = 'backup_schedule_purge';
|
||||
case DirectoryRoleDefinitionsSync = 'directory_role_definitions.sync';
|
||||
case RestoreExecute = 'restore.execute';
|
||||
|
||||
|
||||
@ -15,9 +15,9 @@ public function requiredCapabilityForType(string $operationType): ?string
|
||||
}
|
||||
|
||||
return match ($operationType) {
|
||||
'inventory.sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
'directory_groups.sync' => Capabilities::TENANT_SYNC,
|
||||
'backup_schedule.run_now', 'backup_schedule.retry', 'backup_schedule.scheduled' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
||||
'inventory_sync' => Capabilities::TENANT_INVENTORY_SYNC_RUN,
|
||||
'entra_group_sync' => Capabilities::TENANT_SYNC,
|
||||
'backup_schedule_run', 'backup_schedule_retention', 'backup_schedule_purge' => Capabilities::TENANT_BACKUP_SCHEDULES_RUN,
|
||||
'restore.execute' => Capabilities::TENANT_MANAGE,
|
||||
'directory_role_definitions.sync' => Capabilities::TENANT_MANAGE,
|
||||
|
||||
|
||||
@ -36,7 +36,6 @@ public static function baseline(): self
|
||||
'App\\Filament\\Pages\\Workspaces\\ManagedTenantOnboardingWizard' => 'Onboarding wizard has dedicated conformance tests and remains exempt in spec 082.',
|
||||
'App\\Filament\\Pages\\Workspaces\\ManagedTenantsLanding' => 'Managed-tenant landing retrofit deferred to workspace feature track.',
|
||||
'App\\Filament\\Resources\\BackupScheduleResource' => 'Backup schedule resource retrofit deferred to backup scheduling track.',
|
||||
'App\\Filament\\Resources\\BackupScheduleResource\\RelationManagers\\BackupScheduleRunsRelationManager' => 'Backup schedule runs relation manager retrofit deferred to backup scheduling track.',
|
||||
'App\\Filament\\Resources\\BackupSetResource' => 'Backup set resource retrofit deferred to backup set track.',
|
||||
'App\\Filament\\Resources\\BackupSetResource\\RelationManagers\\BackupItemsRelationManager' => 'Backup items relation manager retrofit deferred to backup set track.',
|
||||
'App\\Filament\\Resources\\FindingResource' => 'Finding resource retrofit deferred to drift track.',
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\EntraGroupSyncRun;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\EntraGroupSyncRun>
|
||||
*/
|
||||
class EntraGroupSyncRunFactory extends Factory
|
||||
{
|
||||
protected $model = EntraGroupSyncRun::class;
|
||||
|
||||
public function definition(): array
|
||||
{
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'selection_key' => 'groups-v1:all',
|
||||
'slot_key' => null,
|
||||
'status' => EntraGroupSyncRun::STATUS_PENDING,
|
||||
'initiator_user_id' => User::factory(),
|
||||
'pages_fetched' => 0,
|
||||
'items_observed_count' => 0,
|
||||
'items_upserted_count' => 0,
|
||||
'error_count' => 0,
|
||||
'safety_stop_triggered' => false,
|
||||
'safety_stop_reason' => null,
|
||||
'error_code' => null,
|
||||
'error_category' => null,
|
||||
'error_summary' => null,
|
||||
'started_at' => null,
|
||||
'finished_at' => null,
|
||||
];
|
||||
}
|
||||
|
||||
public function scheduled(): static
|
||||
{
|
||||
return $this->state(fn (): array => [
|
||||
'initiator_user_id' => null,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -22,8 +22,8 @@ public function definition(): array
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => hash('sha256', fake()->uuid()),
|
||||
'baseline_run_id' => null,
|
||||
'current_run_id' => null,
|
||||
'baseline_operation_run_id' => null,
|
||||
'current_operation_run_id' => null,
|
||||
'fingerprint' => hash('sha256', fake()->uuid()),
|
||||
'subject_type' => 'assignment',
|
||||
'subject_external_id' => fake()->uuid(),
|
||||
|
||||
@ -32,7 +32,7 @@ public function definition(): array
|
||||
'warnings' => [],
|
||||
],
|
||||
'last_seen_at' => now(),
|
||||
'last_seen_run_id' => null,
|
||||
'last_seen_operation_run_id' => null,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,43 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
|
||||
/**
|
||||
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\InventorySyncRun>
|
||||
*/
|
||||
class InventorySyncRunFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* Define the model's default state.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$selectionPayload = [
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'categories' => ['Configuration'],
|
||||
'include_foundations' => false,
|
||||
'include_dependencies' => false,
|
||||
];
|
||||
|
||||
return [
|
||||
'tenant_id' => Tenant::factory(),
|
||||
'selection_hash' => hash('sha256', (string) json_encode($selectionPayload)),
|
||||
'selection_payload' => $selectionPayload,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'had_errors' => false,
|
||||
'error_codes' => [],
|
||||
'error_context' => null,
|
||||
'started_at' => now()->subMinute(),
|
||||
'finished_at' => now(),
|
||||
'items_observed_count' => 0,
|
||||
'items_upserted_count' => 0,
|
||||
'errors_count' => 0,
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -24,7 +24,7 @@ public function up(): void
|
||||
DB::statement(<<<'SQL'
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS operation_runs_backup_schedule_scheduled_unique
|
||||
ON operation_runs (tenant_id, run_identity_hash)
|
||||
WHERE type = 'backup_schedule.scheduled'
|
||||
WHERE type = 'backup_schedule_run'
|
||||
SQL);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $upMap = [
|
||||
'inventory.sync' => 'inventory_sync',
|
||||
'directory_groups.sync' => 'entra_group_sync',
|
||||
'drift.generate' => 'drift_generate_findings',
|
||||
'backup_schedule.run_now' => 'backup_schedule_run',
|
||||
'backup_schedule.retry' => 'backup_schedule_run',
|
||||
'backup_schedule.scheduled' => 'backup_schedule_run',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private array $downMap = [
|
||||
'inventory_sync' => 'inventory.sync',
|
||||
'entra_group_sync' => 'directory_groups.sync',
|
||||
'drift_generate_findings' => 'drift.generate',
|
||||
// Multiple legacy values are normalized into one canonical value.
|
||||
'backup_schedule_run' => 'backup_schedule.run_now',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('operation_runs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->upMap as $from => $to) {
|
||||
DB::table('operation_runs')
|
||||
->where('type', $from)
|
||||
->update(['type' => $to]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('operation_runs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($this->downMap as $from => $to) {
|
||||
DB::table('operation_runs')
|
||||
->where('type', $from)
|
||||
->update(['type' => $to]);
|
||||
}
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('findings') || ! Schema::hasTable('operation_runs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('findings', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('findings', 'baseline_operation_run_id')) {
|
||||
$table->foreignId('baseline_operation_run_id')
|
||||
->nullable()
|
||||
->after('baseline_run_id')
|
||||
->constrained('operation_runs')
|
||||
->nullOnDelete();
|
||||
$table->index(['tenant_id', 'baseline_operation_run_id'], 'findings_tenant_baseline_operation_run_idx');
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('findings', 'current_operation_run_id')) {
|
||||
$table->foreignId('current_operation_run_id')
|
||||
->nullable()
|
||||
->after('current_run_id')
|
||||
->constrained('operation_runs')
|
||||
->nullOnDelete();
|
||||
$table->index(['tenant_id', 'current_operation_run_id'], 'findings_tenant_current_operation_run_idx');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('findings')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('findings', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('findings', 'baseline_operation_run_id')) {
|
||||
$table->dropIndex('findings_tenant_baseline_operation_run_idx');
|
||||
$table->dropConstrainedForeignId('baseline_operation_run_id');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('findings', 'current_operation_run_id')) {
|
||||
$table->dropIndex('findings_tenant_current_operation_run_idx');
|
||||
$table->dropConstrainedForeignId('current_operation_run_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('findings') || ! Schema::hasTable('inventory_sync_runs') || ! Schema::hasTable('operation_runs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('findings', 'baseline_operation_run_id') || ! Schema::hasColumn('findings', 'current_operation_run_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('findings')
|
||||
->select(['id', 'baseline_run_id', 'current_run_id', 'baseline_operation_run_id', 'current_operation_run_id'])
|
||||
->orderBy('id')
|
||||
->chunkById(500, function (Collection $rows): void {
|
||||
$legacyRunIds = $rows
|
||||
->flatMap(function (object $row): array {
|
||||
return [
|
||||
$row->baseline_run_id,
|
||||
$row->current_run_id,
|
||||
];
|
||||
})
|
||||
->filter(fn (mixed $id): bool => is_numeric($id))
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($legacyRunIds->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRunIdByLegacyRunId = DB::table('inventory_sync_runs')
|
||||
->whereIn('id', $legacyRunIds->all())
|
||||
->whereNotNull('operation_run_id')
|
||||
->pluck('operation_run_id', 'id')
|
||||
->map(fn (mixed $id): int => (int) $id);
|
||||
|
||||
if ($operationRunIdByLegacyRunId->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existingOperationRunIds = DB::table('operation_runs')
|
||||
->whereIn('id', $operationRunIdByLegacyRunId->values()->unique()->all())
|
||||
->pluck('id')
|
||||
->mapWithKeys(fn (mixed $id): array => [(int) $id => true]);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
$updates = [];
|
||||
|
||||
if ($row->baseline_operation_run_id === null && is_numeric($row->baseline_run_id)) {
|
||||
$candidate = $operationRunIdByLegacyRunId->get((int) $row->baseline_run_id);
|
||||
|
||||
if (is_numeric($candidate) && $existingOperationRunIds->has((int) $candidate)) {
|
||||
$updates['baseline_operation_run_id'] = (int) $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if ($row->current_operation_run_id === null && is_numeric($row->current_run_id)) {
|
||||
$candidate = $operationRunIdByLegacyRunId->get((int) $row->current_run_id);
|
||||
|
||||
if (is_numeric($candidate) && $existingOperationRunIds->has((int) $candidate)) {
|
||||
$updates['current_operation_run_id'] = (int) $candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if ($updates === []) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('findings')
|
||||
->where('id', (int) $row->id)
|
||||
->update($updates);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('findings')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$updates = [];
|
||||
|
||||
if (Schema::hasColumn('findings', 'baseline_operation_run_id')) {
|
||||
$updates['baseline_operation_run_id'] = null;
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('findings', 'current_operation_run_id')) {
|
||||
$updates['current_operation_run_id'] = null;
|
||||
}
|
||||
|
||||
if ($updates === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('findings')->update($updates);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasTable('inventory_items') || ! Schema::hasTable('inventory_sync_runs') || ! Schema::hasTable('operation_runs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('inventory_items', 'last_seen_run_id') || ! Schema::hasColumn('inventory_items', 'last_seen_operation_run_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('inventory_items')
|
||||
->select(['id', 'last_seen_run_id', 'last_seen_operation_run_id'])
|
||||
->orderBy('id')
|
||||
->chunkById(500, function (Collection $rows): void {
|
||||
$legacyRunIds = $rows
|
||||
->pluck('last_seen_run_id')
|
||||
->filter(fn (mixed $id): bool => is_numeric($id))
|
||||
->map(fn (mixed $id): int => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($legacyRunIds->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operationRunIdByLegacyRunId = DB::table('inventory_sync_runs')
|
||||
->whereIn('id', $legacyRunIds->all())
|
||||
->whereNotNull('operation_run_id')
|
||||
->pluck('operation_run_id', 'id')
|
||||
->map(fn (mixed $id): int => (int) $id);
|
||||
|
||||
if ($operationRunIdByLegacyRunId->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$existingOperationRunIds = DB::table('operation_runs')
|
||||
->whereIn('id', $operationRunIdByLegacyRunId->values()->unique()->all())
|
||||
->pluck('id')
|
||||
->mapWithKeys(fn (mixed $id): array => [(int) $id => true]);
|
||||
|
||||
foreach ($rows as $row) {
|
||||
if ($row->last_seen_operation_run_id !== null || ! is_numeric($row->last_seen_run_id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$candidate = $operationRunIdByLegacyRunId->get((int) $row->last_seen_run_id);
|
||||
|
||||
if (! is_numeric($candidate) || ! $existingOperationRunIds->has((int) $candidate)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('inventory_items')
|
||||
->where('id', (int) $row->id)
|
||||
->update(['last_seen_operation_run_id' => (int) $candidate]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('inventory_items') || ! Schema::hasColumn('inventory_items', 'last_seen_operation_run_id')) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::table('inventory_items')->update(['last_seen_operation_run_id' => null]);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,74 @@
|
||||
<?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
|
||||
{
|
||||
if (Schema::hasTable('findings')) {
|
||||
DB::statement('DROP INDEX IF EXISTS findings_tenant_id_baseline_run_id_index');
|
||||
DB::statement('DROP INDEX IF EXISTS findings_tenant_id_current_run_id_index');
|
||||
|
||||
Schema::table('findings', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('findings', 'baseline_run_id')) {
|
||||
$table->dropConstrainedForeignId('baseline_run_id');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('findings', 'current_run_id')) {
|
||||
$table->dropConstrainedForeignId('current_run_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasTable('inventory_items')) {
|
||||
Schema::table('inventory_items', function (Blueprint $table) {
|
||||
if (Schema::hasColumn('inventory_items', 'last_seen_run_id')) {
|
||||
$table->dropConstrainedForeignId('last_seen_run_id');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('inventory_sync_runs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Schema::hasTable('findings')) {
|
||||
Schema::table('findings', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('findings', 'baseline_run_id')) {
|
||||
$table->foreignId('baseline_run_id')
|
||||
->nullable()
|
||||
->after('scope_key')
|
||||
->constrained('inventory_sync_runs');
|
||||
$table->index(['tenant_id', 'baseline_run_id']);
|
||||
}
|
||||
|
||||
if (! Schema::hasColumn('findings', 'current_run_id')) {
|
||||
$table->foreignId('current_run_id')
|
||||
->nullable()
|
||||
->after('baseline_run_id')
|
||||
->constrained('inventory_sync_runs');
|
||||
$table->index(['tenant_id', 'current_run_id']);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (Schema::hasTable('inventory_items')) {
|
||||
Schema::table('inventory_items', function (Blueprint $table) {
|
||||
if (! Schema::hasColumn('inventory_items', 'last_seen_run_id')) {
|
||||
$table->foreignId('last_seen_run_id')
|
||||
->nullable()
|
||||
->after('last_seen_at')
|
||||
->constrained('inventory_sync_runs')
|
||||
->nullOnDelete();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
100
database/migrations/2026_02_12_000006_drop_legacy_run_tables.php
Normal file
100
database/migrations/2026_02_12_000006_drop_legacy_run_tables.php
Normal file
@ -0,0 +1,100 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::dropIfExists('backup_schedule_runs');
|
||||
Schema::dropIfExists('entra_group_sync_runs');
|
||||
Schema::dropIfExists('inventory_sync_runs');
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasTable('inventory_sync_runs')) {
|
||||
Schema::create('inventory_sync_runs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('selection_hash', 64);
|
||||
$table->jsonb('selection_payload')->nullable();
|
||||
$table->string('status');
|
||||
$table->boolean('had_errors')->default(false);
|
||||
$table->jsonb('error_codes')->nullable();
|
||||
$table->jsonb('error_context')->nullable();
|
||||
$table->timestampTz('started_at')->nullable();
|
||||
$table->timestampTz('finished_at')->nullable();
|
||||
$table->unsignedInteger('items_observed_count')->default(0);
|
||||
$table->unsignedInteger('items_upserted_count')->default(0);
|
||||
$table->unsignedInteger('errors_count')->default(0);
|
||||
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['tenant_id', 'selection_hash']);
|
||||
$table->index(['tenant_id', 'status']);
|
||||
$table->index(['tenant_id', 'finished_at']);
|
||||
$table->index(['tenant_id', 'user_id']);
|
||||
$table->index('operation_run_id');
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('entra_group_sync_runs')) {
|
||||
Schema::create('entra_group_sync_runs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('tenant_id')->constrained()->cascadeOnDelete();
|
||||
$table->string('selection_key');
|
||||
$table->string('slot_key')->nullable();
|
||||
$table->string('status');
|
||||
$table->string('error_code')->nullable();
|
||||
$table->string('error_category')->nullable();
|
||||
$table->text('error_summary')->nullable();
|
||||
$table->boolean('safety_stop_triggered')->default(false);
|
||||
$table->string('safety_stop_reason')->nullable();
|
||||
$table->unsignedInteger('pages_fetched')->default(0);
|
||||
$table->unsignedInteger('items_observed_count')->default(0);
|
||||
$table->unsignedInteger('items_upserted_count')->default(0);
|
||||
$table->unsignedInteger('error_count')->default(0);
|
||||
$table->foreignId('initiator_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->timestampTz('started_at')->nullable();
|
||||
$table->timestampTz('finished_at')->nullable();
|
||||
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['tenant_id', 'selection_key']);
|
||||
$table->index(['tenant_id', 'status']);
|
||||
$table->index(['tenant_id', 'finished_at']);
|
||||
$table->index('operation_run_id');
|
||||
$table->unique(['tenant_id', 'selection_key', 'slot_key']);
|
||||
});
|
||||
}
|
||||
|
||||
if (! Schema::hasTable('backup_schedule_runs')) {
|
||||
Schema::create('backup_schedule_runs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('backup_schedule_id')->constrained('backup_schedules')->cascadeOnDelete();
|
||||
$table->foreignId('tenant_id')->constrained('tenants')->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->dateTime('scheduled_for');
|
||||
$table->dateTime('started_at')->nullable();
|
||||
$table->dateTime('finished_at')->nullable();
|
||||
$table->enum('status', ['running', 'success', 'partial', 'failed', 'canceled', 'skipped']);
|
||||
$table->json('summary')->nullable();
|
||||
$table->string('error_code')->nullable();
|
||||
$table->text('error_message')->nullable();
|
||||
$table->foreignId('backup_set_id')->nullable()->constrained('backup_sets')->nullOnDelete();
|
||||
$table->foreignId('operation_run_id')->nullable()->constrained('operation_runs')->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['backup_schedule_id', 'scheduled_for']);
|
||||
$table->index(['backup_schedule_id', 'scheduled_for']);
|
||||
$table->index(['tenant_id', 'created_at']);
|
||||
$table->index(['user_id', 'created_at'], 'backup_schedule_runs_user_created');
|
||||
$table->index('operation_run_id');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
34
specs/087-legacy-runs-removal/checklists/requirements.md
Normal file
34
specs/087-legacy-runs-removal/checklists/requirements.md
Normal file
@ -0,0 +1,34 @@
|
||||
# Specification Quality Checklist: Legacy Runs Removal
|
||||
|
||||
**Purpose**: Validate specification completeness and quality before proceeding to planning
|
||||
**Created**: 2026-02-12
|
||||
**Feature**: [specs/087-legacy-runs-removal/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 on 2026-02-12. Ready for `/speckit.plan`.
|
||||
@ -0,0 +1,59 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: TenantPilot Operations Runs (UI endpoints)
|
||||
version: "1.0"
|
||||
description: |
|
||||
Minimal contract describing the canonical Operations run list and detail endpoints.
|
||||
|
||||
Note: These are Filament (server-rendered / Livewire) endpoints, not a public JSON API.
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
/admin/monitoring/operations:
|
||||
get:
|
||||
summary: Operations run list (canonical)
|
||||
description: Canonical list of operation runs scoped by workspace entitlement.
|
||||
responses:
|
||||
"200":
|
||||
description: HTML page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
"302":
|
||||
description: Redirect to login
|
||||
/admin/operations/{runId}:
|
||||
get:
|
||||
summary: Operations run detail (canonical)
|
||||
description: Canonical tenantless run viewer.
|
||||
parameters:
|
||||
- name: runId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: HTML page
|
||||
content:
|
||||
text/html:
|
||||
schema:
|
||||
type: string
|
||||
"403":
|
||||
description: Workspace member but missing capability
|
||||
"404":
|
||||
description: Not entitled to workspace scope (deny-as-not-found)
|
||||
"302":
|
||||
description: Redirect to login
|
||||
components:
|
||||
schemas:
|
||||
OperationRunType:
|
||||
type: string
|
||||
description: Canonical run types created by this feature.
|
||||
enum:
|
||||
- inventory_sync
|
||||
- drift_generate_findings
|
||||
- entra_group_sync
|
||||
- backup_schedule_run
|
||||
- backup_schedule_retention
|
||||
- backup_schedule_purge
|
||||
109
specs/087-legacy-runs-removal/data-model.md
Normal file
109
specs/087-legacy-runs-removal/data-model.md
Normal file
@ -0,0 +1,109 @@
|
||||
# Phase 1 — Data Model: Legacy Runs Removal (Spec 087)
|
||||
|
||||
**Branch**: `087-legacy-runs-removal`
|
||||
**Date**: 2026-02-12
|
||||
|
||||
## Canonical Entity: OperationRun
|
||||
|
||||
**Table**: `operation_runs`
|
||||
**Model**: `App\\Models\\OperationRun`
|
||||
|
||||
Key fields (existing):
|
||||
- `id`
|
||||
- `workspace_id` (required)
|
||||
- `tenant_id` (nullable; tenant-scoped runs use this)
|
||||
- `user_id` (nullable)
|
||||
- `initiator_name`
|
||||
- `type` (canonical run_type)
|
||||
- `status` / `outcome`
|
||||
- `run_identity_hash` (idempotency)
|
||||
- `summary_counts` (JSON)
|
||||
- `failure_summary` (JSON)
|
||||
- `context` (JSON)
|
||||
- `started_at` / `completed_at`
|
||||
- `created_at` / `updated_at`
|
||||
|
||||
Spec impact:
|
||||
- `type` must be standardized to the underscore identifiers listed in FR-012.
|
||||
|
||||
## Legacy Entities (To Remove)
|
||||
|
||||
### InventorySyncRun
|
||||
|
||||
**Table**: `inventory_sync_runs`
|
||||
**Model**: `App\\Models\\InventorySyncRun`
|
||||
|
||||
Notes:
|
||||
- Acts as a legacy duplicate run store.
|
||||
- Has `operation_run_id` bridge column (added later).
|
||||
|
||||
**Plan**: remove model + table, and rely on `operation_runs`.
|
||||
|
||||
### EntraGroupSyncRun
|
||||
|
||||
**Table**: `entra_group_sync_runs`
|
||||
**Model**: `App\\Models\\EntraGroupSyncRun`
|
||||
|
||||
Notes:
|
||||
- Legacy duplicate run store.
|
||||
- Has `operation_run_id` bridge column.
|
||||
|
||||
**Plan**: remove model + table, and rely on `operation_runs`.
|
||||
|
||||
### BackupScheduleRun
|
||||
|
||||
**Table**: `backup_schedule_runs`
|
||||
**Model**: `App\\Models\\BackupScheduleRun`
|
||||
|
||||
Notes:
|
||||
- Currently used for schedule run history + retention/purge selection.
|
||||
- Has `operation_run_id` bridge column.
|
||||
|
||||
**Plan**: remove model + table and replace schedule “run history” with querying `operation_runs` by type + context.
|
||||
|
||||
## Drift Findings: References Must Become Canonical
|
||||
|
||||
**Table**: `findings`
|
||||
**Model**: `App\\Models\\Finding`
|
||||
|
||||
Current fields (relevant):
|
||||
- `baseline_run_id` → FK to `inventory_sync_runs` (nullable)
|
||||
- `current_run_id` → FK to `inventory_sync_runs` (nullable)
|
||||
|
||||
**Planned fields**:
|
||||
- `baseline_operation_run_id` → FK to `operation_runs` (nullable)
|
||||
- `current_operation_run_id` → FK to `operation_runs` (nullable)
|
||||
|
||||
**Backfill rule**:
|
||||
- If `baseline_run_id` points to an `inventory_sync_runs` row with a non-null `operation_run_id`, and that `operation_runs` row exists, copy it.
|
||||
- Same for `current_run_id`.
|
||||
- Otherwise leave null (matches spec edge-case expectations).
|
||||
|
||||
## Inventory Items: Last Seen Run Reference Must Become Canonical
|
||||
|
||||
**Table**: `inventory_items`
|
||||
**Model**: `App\\Models\\InventoryItem`
|
||||
|
||||
Current fields (relevant):
|
||||
- `last_seen_run_id` → FK to `inventory_sync_runs` (nullable)
|
||||
- `last_seen_operation_run_id` → FK to `operation_runs` (nullable; already exists)
|
||||
|
||||
**Plan**:
|
||||
- Backfill `last_seen_operation_run_id` via `inventory_sync_runs.operation_run_id` where possible.
|
||||
- Drop `last_seen_run_id` after code is migrated.
|
||||
|
||||
## Backup Schedules: Run History
|
||||
|
||||
**Table**: `backup_schedules`
|
||||
**Model**: `App\\Models\\BackupSchedule`
|
||||
|
||||
Planned behavior:
|
||||
- Remove `runs()` relationship that points to `backup_schedule_runs`.
|
||||
- For UI/history, query `operation_runs` using:
|
||||
- `type = backup_schedule_run|backup_schedule_retention|backup_schedule_purge`
|
||||
- `context->backup_schedule_id = {id}` (and optionally scheduled time metadata)
|
||||
|
||||
## Validation Rules (from spec)
|
||||
|
||||
- All new run records created by this feature must have `type` in the FR-012 allow-list.
|
||||
- Run visibility is workspace-scoped; non-members must be deny-as-not-found (404).
|
||||
144
specs/087-legacy-runs-removal/plan.md
Normal file
144
specs/087-legacy-runs-removal/plan.md
Normal file
@ -0,0 +1,144 @@
|
||||
# Implementation Plan: Legacy Runs Removal (Spec 087)
|
||||
|
||||
**Branch**: `087-legacy-runs-removal` | **Date**: 2026-02-12 | **Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/spec.md`
|
||||
**Input**: Feature specification from `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Remove the “legacy run” worlds for inventory sync, Entra group sync, and backup schedules so that `operation_runs` is the only run tracking source. Migrate drift + inventory references away from `inventory_sync_runs`, remove legacy Filament run UI surfaces (no redirects), drop legacy run tables, and add an architecture guard test (`NoLegacyRuns`) to prevent regressions.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PHP 8.4.15
|
||||
**Primary Dependencies**: Laravel 12, Filament v5, Livewire v4
|
||||
**Storage**: PostgreSQL (Sail), SQLite in tests
|
||||
**Testing**: Pest v4 (PHPUnit 12)
|
||||
**Target Platform**: Containerized web app (Sail locally, Dokploy deploy)
|
||||
**Project Type**: Web application (Laravel + Filament)
|
||||
**Performance Goals**: Monitoring/Operations pages render DB-only; avoid N+1 when listing runs
|
||||
**Constraints**: Legacy run URLs must be not found (no redirects). This does not apply to the existing tenant-scoped operations index convenience redirect (`/admin/t/{tenant}/operations` → `/admin/operations`). Non-member workspace access is 404; member missing capability is 403
|
||||
**Scale/Scope**: TenantPilot admin app; operations visibility + drift references are core operational surfaces
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
- Workspace isolation / RBAC-UX (404 vs 403): PASS (required by spec FR-008)
|
||||
- Run observability: PASS (this feature eliminates duplicate run stores)
|
||||
- Monitoring pages DB-only: PASS (no new render-time Graph calls)
|
||||
- Badge semantics (BADGE-001): PASS (legacy run badge mappings are removed, not expanded)
|
||||
- Filament action surface contract: PASS (feature mostly removes surfaces; modified surfaces must preserve inspection affordance + RBAC)
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/
|
||||
├── spec.md
|
||||
├── plan.md
|
||||
├── research.md
|
||||
├── data-model.md
|
||||
├── quickstart.md
|
||||
├── contracts/
|
||||
│ └── operations-runs.openapi.yaml
|
||||
└── checklists/
|
||||
└── requirements.md
|
||||
|
||||
### Source Code (repository root)
|
||||
```text
|
||||
/Users/ahmeddarrazi/Documents/projects/TenantAtlas/
|
||||
├── app/
|
||||
│ ├── Filament/
|
||||
│ ├── Jobs/
|
||||
│ ├── Models/
|
||||
│ ├── Services/
|
||||
│ └── Support/
|
||||
├── database/
|
||||
│ ├── migrations/
|
||||
│ ├── factories/
|
||||
│ └── seeders/
|
||||
├── resources/
|
||||
├── routes/
|
||||
└── tests/
|
||||
└── Feature/
|
||||
└── Guards/
|
||||
```
|
||||
|
||||
**Structure Decision**: Laravel monolith with Filament resources/pages; this feature touches `app/`, `database/migrations/`, `resources/`, `routes/`, and `tests/Feature/`.
|
||||
|
||||
## Phase 0 — Outline & Research (COMPLETE)
|
||||
|
||||
Output: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/research.md`
|
||||
|
||||
Research resolves remaining planning unknowns:
|
||||
- Identifies legacy run tables and UI surfaces to remove.
|
||||
- Resolves the `run_type` contract mismatch (spec underscore values vs current dotted enum values).
|
||||
- Defines an FK cutover plan for drift + inventory references so legacy tables can be dropped safely.
|
||||
|
||||
## Phase 1 — Design & Contracts (COMPLETE)
|
||||
|
||||
Outputs:
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/data-model.md`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/contracts/operations-runs.openapi.yaml`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/quickstart.md`
|
||||
|
||||
Post-design constitution re-check: PASS (no new Graph/render concerns; run observability strengthened).
|
||||
|
||||
## Phase 2 — Implementation Plan
|
||||
|
||||
### Ordering constraints
|
||||
|
||||
- Drift + inventory FKs currently point at legacy run tables; migrate those references before dropping tables.
|
||||
- Legacy run pages must be removed without redirects; verify with 404 tests.
|
||||
|
||||
### Step A — Canonical run types (FR-012)
|
||||
|
||||
- Align stored `operation_runs.type` values with the underscore allow-list for the affected workflows.
|
||||
- Provide a migration that rewrites existing dotted values used today into the canonical underscore forms where safe.
|
||||
|
||||
### Step B — Stop legacy writes + remove legacy read dependencies
|
||||
|
||||
- Inventory sync: eliminate writes to `inventory_sync_runs` and use `operation_runs` only.
|
||||
- Entra group sync: eliminate writes to `entra_group_sync_runs` and use `operation_runs` only.
|
||||
- Backup schedule:
|
||||
- Eliminate writes to `backup_schedule_runs`.
|
||||
- Persist schedule metadata in `operation_runs.context` (e.g., `backup_schedule_id`, `scheduled_for`, `reason`).
|
||||
- Ensure retention and purge each create their own canonical runs (FR-011).
|
||||
|
||||
### Step C — Drift + inventory reference cutover
|
||||
|
||||
- Add `findings.baseline_operation_run_id` / `findings.current_operation_run_id` and backfill via `inventory_sync_runs.operation_run_id`.
|
||||
- Backfill `inventory_items.last_seen_operation_run_id` where only `last_seen_run_id` exists.
|
||||
- Update app code to use the canonical columns and tolerate null mappings.
|
||||
- Drop legacy run ID columns after cutover.
|
||||
|
||||
### Step D — Remove legacy UI surfaces (FR-003/FR-004)
|
||||
|
||||
- Remove Filament resources/pages:
|
||||
- `InventorySyncRunResource`
|
||||
- `EntraGroupSyncRunResource`
|
||||
- Remove backup schedule legacy run history relation manager + modal.
|
||||
- Update links in drift/finding/inventory surfaces to use the canonical operations viewer.
|
||||
- Keep (or explicitly remove) the tenant-scoped operations index convenience redirect separately; it is not a legacy *run* page.
|
||||
|
||||
### Step E — Drop legacy tables
|
||||
|
||||
- Drop `inventory_sync_runs`, `entra_group_sync_runs`, and `backup_schedule_runs` after FK cutover.
|
||||
|
||||
### Step F — Architecture guard (FR-009)
|
||||
|
||||
- Add a guard test under `tests/Feature/Guards/` scanning `app/`, `database/`, `resources/`, and `routes/` for legacy run tokens.
|
||||
|
||||
### Test plan (minimum)
|
||||
|
||||
- New: `NoLegacyRunsTest` (architecture guard)
|
||||
- Legacy routes: assert old run URLs return 404 (no redirects)
|
||||
- Drift + inventory:
|
||||
- new records store canonical run references
|
||||
- historical records without safe mapping render with null references
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
No constitution violations are required for this plan.
|
||||
|
||||
36
specs/087-legacy-runs-removal/quickstart.md
Normal file
36
specs/087-legacy-runs-removal/quickstart.md
Normal file
@ -0,0 +1,36 @@
|
||||
# Quickstart: Legacy Runs Removal (Spec 087)
|
||||
|
||||
**Branch**: `087-legacy-runs-removal`
|
||||
|
||||
## Local Dev (Sail)
|
||||
|
||||
1) Start containers:
|
||||
|
||||
- `vendor/bin/sail up -d`
|
||||
|
||||
2) Run migrations:
|
||||
|
||||
- `vendor/bin/sail artisan migrate`
|
||||
|
||||
3) Run targeted tests (once implementation exists):
|
||||
|
||||
- Minimum required pack:
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/Guards/NoLegacyRunsTest.php tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php tests/Feature/Monitoring/MonitoringOperationsTest.php tests/Feature/Drift`
|
||||
- Backup + directory regressions:
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/BackupScheduling tests/Feature/DirectoryGroups`
|
||||
- Canonical run flow checks:
|
||||
- `vendor/bin/sail artisan test --compact tests/Feature/Inventory/InventorySyncServiceTest.php tests/Feature/Console/PurgeNonPersistentDataCommandTest.php tests/Feature/Console/ReconcileBackupScheduleOperationRunsCommandTest.php`
|
||||
|
||||
## Manual Verification Checklist (once implementation exists)
|
||||
|
||||
- Inventory sync run appears in Monitoring → Operations and opens via the canonical viewer.
|
||||
- Entra group sync run appears in Monitoring → Operations and opens via the canonical viewer.
|
||||
- Backup schedule run/retention/purge each appear in Monitoring → Operations.
|
||||
- Legacy run URLs return 404 (no redirects):
|
||||
- Inventory sync runs resource route
|
||||
- Entra group sync runs resource route
|
||||
- Backup schedule run history relation manager is removed from schedule detail
|
||||
|
||||
## Notes
|
||||
|
||||
- Per spec clarifications, no backfill of legacy run history is performed. Only reference migration from legacy IDs to existing `operation_runs` IDs is expected.
|
||||
120
specs/087-legacy-runs-removal/research.md
Normal file
120
specs/087-legacy-runs-removal/research.md
Normal file
@ -0,0 +1,120 @@
|
||||
# Phase 0 — Research: Legacy Runs Removal (Spec 087)
|
||||
|
||||
**Branch**: `087-legacy-runs-removal`
|
||||
**Date**: 2026-02-12
|
||||
**Input Spec**: `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/specs/087-legacy-runs-removal/spec.md`
|
||||
|
||||
## What “Legacy Runs” Means In This Repo
|
||||
|
||||
**Decision**: “Legacy runs” for this spec are the tenant-scoped run tables + UI surfaces that duplicate `operation_runs` for:
|
||||
- Inventory sync
|
||||
- Entra group sync
|
||||
- Backup schedule execution
|
||||
|
||||
**Evidence (current repo state)**:
|
||||
- Legacy tables exist:
|
||||
- `inventory_sync_runs` ([database/migrations/2026_01_07_142719_create_inventory_sync_runs_table.php](../../database/migrations/2026_01_07_142719_create_inventory_sync_runs_table.php))
|
||||
- `entra_group_sync_runs` ([database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php](../../database/migrations/2026_01_11_120004_create_entra_group_sync_runs_table.php))
|
||||
- `backup_schedule_runs` ([database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php](../../database/migrations/2026_01_05_011034_create_backup_schedule_runs_table.php))
|
||||
- These legacy tables already have bridging columns to canonical runs (`operation_run_id` was added later), indicating an ongoing migration to `operation_runs`:
|
||||
- [database/migrations/2026_02_10_090213_add_operation_run_id_to_inventory_sync_runs_table.php](../../database/migrations/2026_02_10_090213_add_operation_run_id_to_inventory_sync_runs_table.php)
|
||||
- [database/migrations/2026_02_10_090214_add_operation_run_id_to_entra_group_sync_runs_table.php](../../database/migrations/2026_02_10_090214_add_operation_run_id_to_entra_group_sync_runs_table.php)
|
||||
- [database/migrations/2026_02_10_090215_add_operation_run_id_to_backup_schedule_runs_table.php](../../database/migrations/2026_02_10_090215_add_operation_run_id_to_backup_schedule_runs_table.php)
|
||||
|
||||
## Canonical Run Types: Spec vs Current Code
|
||||
|
||||
### Problem
|
||||
The spec mandates canonical `run_type` identifiers:
|
||||
- `inventory_sync`
|
||||
- `drift_generate_findings`
|
||||
- `entra_group_sync`
|
||||
- `backup_schedule_run`
|
||||
- `backup_schedule_retention`
|
||||
- `backup_schedule_purge`
|
||||
|
||||
But the current code uses dotted `OperationRunType` values such as:
|
||||
- `inventory.sync`
|
||||
- `directory_groups.sync`
|
||||
- `drift.generate`
|
||||
- `backup_schedule.run_now` / `backup_schedule.retry` / `backup_schedule.scheduled`
|
||||
|
||||
([app/Support/OperationRunType.php](../../app/Support/OperationRunType.php))
|
||||
|
||||
### Decision
|
||||
Adopt the spec’s underscore identifiers as the canonical stored values going forward.
|
||||
|
||||
### Rationale
|
||||
- The spec explicitly requires a standardized and enforced contract.
|
||||
- Stored `type` values are used across UI and notifications; a single canonical scheme reduces “type sprawl.”
|
||||
|
||||
### Backwards Compatibility Plan (for existing rows)
|
||||
- Provide a one-time data migration that rewrites existing `operation_runs.type` values from the old dotted values to the new underscore values *for the affected categories only*.
|
||||
- Keep a compatibility mapping in code (temporary) only if needed to avoid breaking filters/UI during rollout.
|
||||
|
||||
### Alternatives Considered
|
||||
- Keep dotted values and treat the spec’s list as “display labels.” Rejected because it violates FR-012’s explicit identifiers.
|
||||
- Introduce a parallel “canonical_type” column. Rejected because it increases complexity and duplication.
|
||||
|
||||
## Drift + Inventory References (FK Cutover)
|
||||
|
||||
### Problem
|
||||
Drift and inventory currently reference legacy run IDs:
|
||||
- Findings: `baseline_run_id` / `current_run_id` are FKs to `inventory_sync_runs` ([database/migrations/2026_01_13_223311_create_findings_table.php](../../database/migrations/2026_01_13_223311_create_findings_table.php))
|
||||
- Inventory items: `last_seen_run_id` references `inventory_sync_runs` ([database/migrations/2026_01_07_142720_create_inventory_items_table.php](../../database/migrations/2026_01_07_142720_create_inventory_items_table.php))
|
||||
|
||||
But inventory items already have the new canonical field:
|
||||
- `last_seen_operation_run_id` ([database/migrations/2026_02_10_091433_add_last_seen_operation_run_id_to_inventory_items_table.php](../../database/migrations/2026_02_10_091433_add_last_seen_operation_run_id_to_inventory_items_table.php))
|
||||
|
||||
### Decision
|
||||
Migrate drift + inventory references to `operation_runs` and drop the legacy FK columns.
|
||||
|
||||
### Migration Strategy
|
||||
- Add new nullable columns:
|
||||
- `findings.baseline_operation_run_id`
|
||||
- `findings.current_operation_run_id`
|
||||
- Backfill by joining through `inventory_sync_runs.operation_run_id` where available.
|
||||
- Update app code to read/write the new fields.
|
||||
- Drop old `*_run_id` FK columns.
|
||||
- Only after that, drop the legacy tables.
|
||||
|
||||
### Rationale
|
||||
- Required to drop legacy tables without breaking referential integrity.
|
||||
- Aligns with spec acceptance scenario: “historical drift findings where no safe mapping exists” remain functional (references can be null).
|
||||
|
||||
## Legacy UI Surfaces Found (To Remove)
|
||||
|
||||
**Inventory sync runs**:
|
||||
- [app/Filament/Resources/InventorySyncRunResource.php](../../app/Filament/Resources/InventorySyncRunResource.php)
|
||||
|
||||
**Entra group sync runs**:
|
||||
- [app/Filament/Resources/EntraGroupSyncRunResource.php](../../app/Filament/Resources/EntraGroupSyncRunResource.php)
|
||||
|
||||
**Backup schedule run history** (RelationManager + modal):
|
||||
- [app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php](../../app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php)
|
||||
|
||||
## Architecture Guard (NoLegacyRuns)
|
||||
|
||||
### Decision
|
||||
Add a CI/architecture Pest test similar to existing “NoLegacy…” guards:
|
||||
- [tests/Feature/Guards/NoLegacyBulkOperationsTest.php](../../tests/Feature/Guards/NoLegacyBulkOperationsTest.php)
|
||||
|
||||
### Guard Scope (per spec clarification)
|
||||
Scan for forbidden tokens under:
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/app/`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/database/`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/resources/`
|
||||
- `/Users/ahmeddarrazi/Documents/projects/TenantAtlas/routes/`
|
||||
|
||||
Exclude:
|
||||
- `vendor/`, `storage/`, `specs/`, `spechistory/`, `references/`, `bootstrap/cache/`
|
||||
|
||||
### Initial Forbidden Tokens (starting list)
|
||||
- `InventorySyncRun`, `inventory_sync_runs`, `InventorySyncRunResource`
|
||||
- `EntraGroupSyncRun`, `entra_group_sync_runs`, `EntraGroupSyncRunResource`
|
||||
- `BackupScheduleRun`, `backup_schedule_runs`, `BackupScheduleRunsRelationManager`
|
||||
|
||||
## Open Questions
|
||||
None remaining for planning.
|
||||
|
||||
- “No backfill” is already clarified in the spec; the only data movement here is migrating *references* to existing `operation_runs`.
|
||||
- Authorization semantics for canonical operations pages are clarified (404 vs 403) and must be preserved.
|
||||
146
specs/087-legacy-runs-removal/spec.md
Normal file
146
specs/087-legacy-runs-removal/spec.md
Normal file
@ -0,0 +1,146 @@
|
||||
# Feature Specification: Legacy Runs Removal
|
||||
|
||||
**Feature Branch**: `087-legacy-runs-removal`
|
||||
**Created**: 2026-02-12
|
||||
**Status**: Draft
|
||||
**Input**: User description: "Spec 087 — Legacy Runs Removal (RIGOROS)"
|
||||
|
||||
## Clarifications
|
||||
|
||||
### Session 2026-02-12
|
||||
|
||||
- Q: Should we backfill legacy run history into the canonical run system before dropping legacy tables? → A: No backfill (accept losing pre-existing legacy run history)
|
||||
- Q: Should backup schedule retention/purge produce canonical run records? → A: Yes — retention and purge each produce their own canonical runs
|
||||
- Q: Which canonical run_type contract should be enforced? → A: Use the explicit list: inventory_sync, drift_generate_findings, entra_group_sync, backup_schedule_run, backup_schedule_retention, backup_schedule_purge
|
||||
- Q: For canonical run URLs, should access be 404 for non-members and 403 for members missing capability? → A: Yes — non-member/not entitled → 404; member missing capability → 403
|
||||
- Q: How broad should the “NoLegacyRuns” architecture guard scan be? → A: Scan app/, database/, resources/, and routes/ (exclude specs/ and references/)
|
||||
|
||||
## Redirect Scope
|
||||
|
||||
This spec’s “no redirects” requirement applies to legacy *run* pages and legacy run UI surfaces.
|
||||
|
||||
The existing convenience route that redirects the tenant-scoped operations index to the canonical operations index (for example: `/admin/t/{tenant}/operations` → `/admin/operations`) is not considered a legacy run page.
|
||||
|
||||
## User Scenarios & Testing *(mandatory)*
|
||||
|
||||
### User Story 1 - Single canonical run history (Priority: P1)
|
||||
|
||||
As a workspace member, I can see and open run history for inventory sync, Entra group sync, and backup schedules in a single, consistent place, without having to navigate multiple legacy run screens.
|
||||
|
||||
**Why this priority**: Removes duplicated tracking and inconsistent UX, and reduces confusion about which “run” is authoritative.
|
||||
|
||||
**Independent Test**: Trigger each run type once and verify it appears in the canonical Operations run list and can be opened via the canonical run detail view.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a workspace member with permission to view operations, **When** a new inventory sync run starts and finishes, **Then** a corresponding canonical run exists and is viewable via the canonical Operations UI.
|
||||
2. **Given** a workspace member with permission to view operations, **When** a new Entra group sync run starts and finishes, **Then** a corresponding canonical run exists and is viewable via the canonical Operations UI.
|
||||
3. **Given** a workspace member with permission to view operations, **When** a backup schedule run starts and finishes, **Then** a corresponding canonical run exists and is viewable via the canonical Operations UI.
|
||||
|
||||
---
|
||||
|
||||
### User Story 2 - Legacy run UI is intentionally gone (Priority: P1)
|
||||
|
||||
As a workspace member, I cannot access legacy run pages anymore. Old links intentionally fail, so there is no ambiguity about where runs are viewed.
|
||||
|
||||
**Why this priority**: Prevents “run sprawl” and ensures there is exactly one canonical run UI.
|
||||
|
||||
**Independent Test**: Attempt to access any known legacy run route and verify it is not available.
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** a user (any role), **When** they attempt to access a legacy run URL, **Then** the application responds as not found.
|
||||
2. **Given** the application navigation, **When** a user browses the admin UI, **Then** no legacy run resources appear in navigation.
|
||||
|
||||
---
|
||||
|
||||
### User Story 3 - Drift references canonical runs (Priority: P1)
|
||||
|
||||
As a workspace member using drift-related features, I see baseline/current references tied to canonical runs, and drift logic does not depend on a legacy run store.
|
||||
|
||||
**Why this priority**: Drift currently has structural coupling to a legacy run concept; removing it reduces risk and complexity.
|
||||
|
||||
**Independent Test**: Create drift findings that reference baseline/current runs and confirm the references use canonical runs (or are empty if historical data cannot be mapped).
|
||||
|
||||
**Acceptance Scenarios**:
|
||||
|
||||
1. **Given** drift findings created after this feature ships, **When** a user views baseline/current references, **Then** the references point to canonical runs.
|
||||
2. **Given** historical drift findings where no safe mapping exists, **When** a user views baseline/current references, **Then** the UI remains functional and references are empty rather than erroring.
|
||||
|
||||
---
|
||||
|
||||
### Edge Cases
|
||||
|
||||
- Historical runs exist with details that were only stored in legacy run records, and this history will not be backfilled into canonical runs.
|
||||
- A user is a non-member of the workspace and attempts to view a run.
|
||||
- A user is a workspace member but lacks the capability to view operations.
|
||||
- A scheduled backup run is skipped/cancelled and still needs a canonical run record with a clear final outcome.
|
||||
- Retention/purge runs remove old data; the user expects runs to remain auditable and consistent.
|
||||
|
||||
## Requirements *(mandatory)*
|
||||
|
||||
**Constitution alignment (required):** This feature changes long-running work and run tracking. It MUST ensure run observability via the canonical run system, consistent identity, and workspace-scoped visibility.
|
||||
|
||||
**Constitution alignment (RBAC-UX):** This feature changes run visibility and removes legacy pages. It MUST explicitly define not-found vs forbidden behavior and enforce authorization server-side.
|
||||
|
||||
**Constitution alignment (BADGE-001):** Any run status-like badges MUST be derived from canonical run status/outcome to keep semantics centralized.
|
||||
|
||||
**Constitution alignment (Filament Action Surfaces):** This feature removes legacy run resources and updates run links to point to the canonical operations views.
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- **FR-001**: The system MUST treat the canonical run system as the single source of truth for all run tracking and display.
|
||||
- **FR-002**: The system MUST NOT create or update legacy run records for inventory sync, Entra group sync, or backup schedule runs.
|
||||
- **FR-003**: The system MUST remove all legacy run UI surfaces (resources/pages/relation managers/badges) so that runs are only accessible from the canonical Operations UI.
|
||||
- **FR-004**: Requests to legacy run pages MUST behave as not found (no redirects).
|
||||
- **FR-005**: Drift-related entities MUST reference canonical runs for baseline/current/last-seen run linkage.
|
||||
- **FR-006**: Backup schedule retention/purge workflows MUST operate without relying on legacy run stores.
|
||||
- **FR-007**: Entra group sync run history and links MUST reference canonical runs only.
|
||||
- **FR-008**: The system MUST enforce workspace-first access rules for canonical run list and detail views:
|
||||
- non-member or not entitled to the workspace scope → not found (404)
|
||||
- member but missing capability → forbidden (403)
|
||||
- **FR-009**: Automated architecture checks MUST prevent reintroduction of legacy run concepts by failing CI if legacy tokens are found anywhere in: `app/`, `database/`, `resources/`, `routes/`.
|
||||
- **FR-010**: The system MUST NOT backfill historical legacy run records into the canonical run system; only new runs are guaranteed to be represented as canonical runs.
|
||||
- **FR-011**: Backup schedule retention and purge executions MUST each create their own canonical run records, observable in the canonical Operations UI.
|
||||
- **FR-012**: The system MUST standardize and enforce the canonical `run_type` identifiers for runs created by this feature:
|
||||
- `inventory_sync`
|
||||
- `drift_generate_findings`
|
||||
- `entra_group_sync`
|
||||
- `backup_schedule_run`
|
||||
- `backup_schedule_retention`
|
||||
- `backup_schedule_purge`
|
||||
|
||||
## UI Action Matrix *(mandatory when Filament is changed)*
|
||||
|
||||
| Surface | Location | Header Actions | Inspect Affordance (List/Table) | Row Actions (max 2 visible) | Bulk Actions (grouped) | Empty-State CTA(s) | View Header Actions | Create/Edit Save+Cancel | Audit log? | Notes / Exemptions |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| Operations run list | Admin UI | None | Filtered list + open run | View | None | None | N/A | N/A | Yes (via canonical runs) | All run types converge here |
|
||||
| Operations run detail | Admin UI | None | N/A | N/A | None | None | None | N/A | Yes (via canonical runs) | Single canonical view for run details |
|
||||
| Backup schedule detail | Admin UI | None | Links to canonical runs | View | None | None | N/A | N/A | Yes | Legacy run tab removed |
|
||||
|
||||
### Key Entities *(include if feature involves data)*
|
||||
|
||||
- **Operation Run**: A canonical record representing a long-running operation, including type, status, timestamps, context, and summary.
|
||||
- **Drift Finding**: A drift detection result that may reference baseline and current runs.
|
||||
- **Inventory Item**: An inventory record that may reference the last run in which it was observed.
|
||||
- **Backup Schedule**: A recurring configuration that triggers backup operations and may trigger retention/purge operations.
|
||||
|
||||
## Success Criteria *(mandatory)*
|
||||
|
||||
### Measurable Outcomes
|
||||
|
||||
- **SC-001**: 100% of new inventory sync, Entra group sync, and backup schedule executions produce exactly one canonical run record that is visible in the canonical Operations run list.
|
||||
- **SC-002**: Legacy run pages are not accessible (attempts to access legacy run URLs result in not found).
|
||||
- **SC-003**: Drift creation and drift UI flows do not depend on legacy run stores; baseline/current references resolve to canonical runs when mappings exist.
|
||||
- **SC-004**: The automated architecture guard blocks reintroduction of legacy run concepts and fails the build when legacy tokens reappear.
|
||||
- **SC-005**: Backup schedule retention/purge workflows complete successfully without legacy run stores and are observable through canonical runs.
|
||||
- **SC-006**: Each retention and each purge execution produces a canonical run record (independent from the associated backup schedule run).
|
||||
|
||||
## Assumptions
|
||||
|
||||
- Historical legacy-only run details do not need to be preserved; pre-existing legacy run history may be lost after legacy tables are removed.
|
||||
- The canonical Operations UI already exists and is the single supported run viewer.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Workspace-scoped run access rules remain enforced consistently across list and detail views.
|
||||
179
specs/087-legacy-runs-removal/tasks.md
Normal file
179
specs/087-legacy-runs-removal/tasks.md
Normal file
@ -0,0 +1,179 @@
|
||||
---
|
||||
description: "Task list for Spec 087 implementation"
|
||||
---
|
||||
|
||||
# Tasks: Legacy Runs Removal (Spec 087)
|
||||
|
||||
**Input**: Design documents from `/specs/087-legacy-runs-removal/`
|
||||
|
||||
**Tests**: REQUIRED (Pest) because this feature changes runtime behavior.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Setup (Docs + Local Validation)
|
||||
|
||||
- [X] T001 Validate and (if needed) update specs/087-legacy-runs-removal/quickstart.md with the exact focused test commands for this spec
|
||||
- [X] T002 Capture the final run_type allow-list in specs/087-legacy-runs-removal/spec.md (FR-012) and ensure tasks below only create those values
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Foundational (Blocking Prerequisites)
|
||||
|
||||
**⚠️ CRITICAL**: Complete this phase before starting any user story work.
|
||||
|
||||
- [X] T003 Update canonical run type identifiers to underscore values for this feature only:
|
||||
- Update app/Support/OperationRunType.php (enum App\\Support\\OperationRunType) so new runs store the underscore identifiers from FR-012
|
||||
- Change ONLY the affected enum case values (leave unrelated run types unchanged):
|
||||
- InventorySync
|
||||
- DirectoryGroupsSync
|
||||
- DriftGenerate
|
||||
- BackupScheduleRunNow
|
||||
- BackupScheduleRetry
|
||||
- BackupScheduleScheduled
|
||||
- Scope note: OperationRun creation accepts a raw string type; enforcement is via standardized usage + tests in this spec (no global renames)
|
||||
- [X] T004 Create migration database/migrations/2026_02_12_000001_canonicalize_operation_run_types.php to rewrite existing operation_runs.type values (dotted → underscore) for affected workflows (FR-012). Acceptance criteria: perform these rewrites (and only these):
|
||||
- inventory.sync → inventory_sync
|
||||
- directory_groups.sync → entra_group_sync
|
||||
- drift.generate → drift_generate_findings
|
||||
- backup_schedule.run_now → backup_schedule_run
|
||||
- backup_schedule.retry → backup_schedule_run
|
||||
- backup_schedule.scheduled → backup_schedule_run
|
||||
- [X] T005 [P] Add/adjust OperationRun RBAC semantics tests (404 vs 403) in tests/Feature/Monitoring/MonitoringOperationsTest.php and/or tests/Feature/Operations/ (FR-008)
|
||||
- [X] T006 [P] Add architecture guard test tests/Feature/Guards/NoLegacyRunsTest.php scanning app/, database/, resources/, routes/ for legacy run tokens (FR-009). Use this initial forbidden token list:
|
||||
- InventorySyncRun, inventory_sync_runs, InventorySyncRunResource
|
||||
- EntraGroupSyncRun, entra_group_sync_runs, EntraGroupSyncRunResource
|
||||
- BackupScheduleRun, backup_schedule_runs, BackupScheduleRunsRelationManager
|
||||
Exclusions (minimum): vendor/, storage/, specs/, spechistory/, references/, bootstrap/cache/
|
||||
- [X] T007 [P] Remove legacy run badge domains/mappers and rely on OperationRun status/outcome badge domains by updating app/Support/Badges/BadgeDomain.php, app/Support/Badges/BadgeCatalog.php, and tests/Unit/Badges/RunStatusBadgesTest.php (BADGE-001)
|
||||
- [X] T043 [P] Add explicit “no backfill of legacy run history” regression coverage:
|
||||
- Confirm no migration/job creates new operation_runs by reading legacy run tables (reference/FK backfills are allowed)
|
||||
- Add a focused test under tests/Feature/Guards/ or tests/Feature/Database/ to enforce this (FR-010)
|
||||
|
||||
NOTE: routes/web.php currently has a legacy convenience redirect (/admin/t/{tenant}/operations → /admin/operations). It is not a legacy *run page*; keep or change it explicitly as part of US2 decisions.
|
||||
|
||||
**Checkpoint**: Canonical run types are stable, guard exists, and operations RBAC semantics are enforced by tests.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: User Story 1 — Single canonical run history (Priority: P1) 🎯 MVP
|
||||
|
||||
**Goal**: New inventory sync, Entra group sync, and backup schedule executions produce exactly one canonical OperationRun each, visible in Operations UI.
|
||||
|
||||
**Independent Test**: Trigger each run type once and verify it appears in the Operations run list and can be opened in the canonical run viewer.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T008 [P] [US1] Update inventory sync run creation expectations in tests/Feature/Inventory/InventorySyncServiceTest.php to assert an operation_runs row exists with type inventory_sync
|
||||
- [X] T009 [P] [US1] Update Entra group sync expectations in tests/Feature/DirectoryGroups/StartSyncTest.php and tests/Feature/DirectoryGroups/ScheduledSyncDispatchTest.php to assert an operation_runs row exists with type entra_group_sync
|
||||
- [X] T010 [P] [US1] Update backup schedule run expectations in tests/Feature/BackupScheduling/RunBackupScheduleJobTest.php to assert an operation_runs row exists with type backup_schedule_run
|
||||
- [X] T011 [P] [US1] Add tests for retention + purge creating independent canonical runs in tests/Feature/BackupScheduling/ApplyRetentionJobTest.php and tests/Feature/Console/PurgeNonPersistentDataCommandTest.php (backup_schedule_retention / backup_schedule_purge)
|
||||
- [X] T044 [P] [US1] Add/adjust backup schedule tests to cover skipped/canceled schedule runs still producing a canonical operation_run with a clear terminal outcome (spec Edge Case)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T012 [US1] Refactor app/Services/Inventory/InventorySyncService.php to stop creating InventorySyncRun rows and instead create/update a canonical OperationRun (FR-001/FR-002)
|
||||
- [X] T013 [US1] Refactor app/Jobs/EntraGroupSyncJob.php to stop updating EntraGroupSyncRun and instead create/update a canonical OperationRun (FR-001/FR-002)
|
||||
- [X] T014 [US1] Refactor app/Jobs/RunBackupScheduleJob.php to stop reading/updating BackupScheduleRun status as the canonical record and instead use a canonical OperationRun with schedule metadata stored in operation_runs.context (FR-001/FR-002)
|
||||
- [X] T015 [US1] Refactor app/Jobs/ApplyBackupScheduleRetentionJob.php to remove BackupScheduleRun dependency and track retention via a canonical OperationRun (FR-006/FR-011)
|
||||
- [X] T016 [US1] Refactor app/Console/Commands/TenantpilotPurgeNonPersistentData.php to track purge via a canonical OperationRun and remove BackupScheduleRun queries (FR-006/FR-011)
|
||||
- [X] T017 [US1] Refactor app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php to reconcile based on operation_runs only (remove BackupScheduleRun reads) (FR-001/FR-006)
|
||||
- [X] T018 [US1] Update run status notifications/links to point to canonical run viewer by editing app/Notifications/RunStatusChangedNotification.php and app/Notifications/OperationRunQueued.php (FR-001)
|
||||
- [X] T019 [US1] Update inventory run “last run” widget links to canonical runs by editing app/Filament/Widgets/Inventory/InventoryKpiHeader.php (FR-001)
|
||||
|
||||
**Checkpoint**: US1 tests pass and every new execution yields a canonical OperationRun of the correct underscore run_type.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: User Story 2 — Legacy run UI is intentionally gone (Priority: P1)
|
||||
|
||||
**Goal**: Legacy run pages/resources are removed; old links return 404; no legacy run resources appear in navigation.
|
||||
|
||||
**Independent Test**: Request known legacy run URLs and verify 404; verify navigation has no legacy run resources.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T020 [P] [US2] Replace redirect-based legacy tests with 404 assertions in tests/Feature/Operations/LegacyRunRedirectTest.php (FR-004)
|
||||
- [X] T021 [P] [US2] Add explicit “legacy run routes not found” coverage in tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php for inventory/entra/backup schedule legacy run URLs (FR-004)
|
||||
|
||||
### Implementation
|
||||
|
||||
- [X] T022 [US2] Remove legacy Inventory Sync run Filament resource by deleting app/Filament/Resources/InventorySyncRunResource.php and app/Filament/Resources/InventorySyncRunResource/Pages/ (FR-003)
|
||||
- [X] T023 [US2] Remove legacy Entra Group Sync run Filament resource by deleting app/Filament/Resources/EntraGroupSyncRunResource.php and app/Filament/Resources/EntraGroupSyncRunResource/Pages/ (FR-003)
|
||||
- [X] T024 [US2] Remove legacy backup schedule run history relation manager by deleting app/Filament/Resources/BackupScheduleResource/RelationManagers/BackupScheduleRunsRelationManager.php (FR-003)
|
||||
- [X] T025 [US2] Remove navigation links to legacy run resources by updating app/Filament/Resources/EntraGroupResource/Pages/ListEntraGroups.php and any other references found under app/Filament/ (FR-003)
|
||||
- [X] T026 [US2] Remove legacy run UI tests and replace with canonical Operations coverage by updating/deleting tests/Feature/Filament/InventorySyncRunResourceTest.php, tests/Feature/Filament/EntraGroupSyncRunResourceTest.php, and tests/Feature/BackupScheduling/BackupScheduleRunViewModalTest.php (FR-003)
|
||||
- [X] T027 [US2] Remove legacy run badge mappers now unused by UI by deleting app/Support/Inventory/InventorySyncStatusBadge.php and app/Support/Badges/Domains/EntraGroupSyncRunStatusBadge.php (FR-003/BADGE-001)
|
||||
|
||||
NOTE: Badge cleanup is handled by T007. If any legacy badge mapper is still referenced, remove it as part of T007 and treat this note as satisfied.
|
||||
|
||||
**Checkpoint**: Legacy run URLs are 404 (no redirects) and legacy run resources no longer exist.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: User Story 3 — Drift references canonical runs (Priority: P1)
|
||||
|
||||
**Goal**: Drift baseline/current/last-seen references use canonical OperationRuns; historical records without a safe mapping render without errors.
|
||||
|
||||
**Independent Test**: Generate drift findings and confirm baseline/current reference canonical run IDs; verify null-safe behavior when mapping is absent.
|
||||
|
||||
### Tests (write first)
|
||||
|
||||
- [X] T028 [P] [US3] Update drift baseline/current selection tests under tests/Feature/Drift/ (directory) to create OperationRun records instead of InventorySyncRun
|
||||
- [X] T029 [P] [US3] Update RBAC drift landing UI enforcement in tests/Feature/Rbac/DriftLandingUiEnforcementTest.php to avoid InventorySyncRunResource and assert canonical run links
|
||||
|
||||
### Data model + migrations
|
||||
|
||||
- [X] T030 [US3] Create migration database/migrations/2026_02_12_000002_add_operation_run_ids_to_findings_table.php adding findings.baseline_operation_run_id and findings.current_operation_run_id (nullable FKs to operation_runs)
|
||||
- [X] T031 [US3] Create migration database/migrations/2026_02_12_000003_backfill_findings_operation_run_ids.php that backfills the new findings columns by joining through inventory_sync_runs.operation_run_id (null-safe)
|
||||
- [X] T032 [US3] Create migration database/migrations/2026_02_12_000004_backfill_inventory_items_last_seen_operation_run_id.php backfilling inventory_items.last_seen_operation_run_id from inventory_items.last_seen_run_id via inventory_sync_runs.operation_run_id
|
||||
|
||||
### Application changes
|
||||
|
||||
- [X] T033 [US3] Refactor drift run selection to use OperationRuns by updating app/Services/Drift/DriftRunSelector.php and app/Services/Drift/DriftScopeKey.php (FR-005)
|
||||
- [X] T034 [US3] Refactor drift finding generation to accept/use OperationRuns by updating app/Services/Drift/DriftFindingGenerator.php (FR-005)
|
||||
- [X] T035 [US3] Update drift landing UI to link to canonical OperationRuns (not legacy resources) by updating app/Filament/Pages/DriftLanding.php (FR-003/FR-005)
|
||||
|
||||
### Cutover + cleanup
|
||||
|
||||
- [X] T036 [US3] Create migration database/migrations/2026_02_12_000005_drop_legacy_run_id_columns_from_findings_and_inventory_items.php dropping findings.baseline_run_id/current_run_id and inventory_items.last_seen_run_id after code cutover (FR-005)
|
||||
|
||||
**Checkpoint**: Drift reads/writes canonical operation run references and remains functional with null mappings.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6: Drop legacy tables (Data + Code)
|
||||
|
||||
**Purpose**: Remove legacy run storage permanently after all cutovers.
|
||||
|
||||
- [X] T037 Create migration database/migrations/2026_02_12_000006_drop_legacy_run_tables.php dropping inventory_sync_runs, entra_group_sync_runs, and backup_schedule_runs (FR-001)
|
||||
- [X] T038 [P] Delete legacy run Eloquent models app/Models/InventorySyncRun.php, app/Models/EntraGroupSyncRun.php, and app/Models/BackupScheduleRun.php (FR-001)
|
||||
- [X] T039 [P] Delete legacy factories database/factories/InventorySyncRunFactory.php and database/factories/EntraGroupSyncRunFactory.php and update any references in tests/Feature/ (FR-001)
|
||||
- [X] T040 Update any remaining references to legacy run tables (tokens caught by the guard) under app/, database/, resources/, routes/ until tests/Feature/Guards/NoLegacyRunsTest.php passes (FR-009)
|
||||
|
||||
---
|
||||
|
||||
## Phase 7: Polish & Cross-Cutting Concerns
|
||||
|
||||
- [X] T041 Run formatting for touched files with vendor/bin/sail bin pint --dirty and fix any style issues in app/ and tests/
|
||||
- [X] T042 Run focused test pack for this spec (at minimum): tests/Feature/Guards/NoLegacyRunsTest.php, tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php, tests/Feature/Monitoring/MonitoringOperationsTest.php, tests/Feature/Drift/ (directory)
|
||||
|
||||
---
|
||||
|
||||
## Dependencies & Execution Order
|
||||
|
||||
### Story dependency graph
|
||||
|
||||
- Phase 2 blocks everything.
|
||||
- US1 (canonical run creation) should land before US2 (removing UI) to avoid removing the only user-visible run history.
|
||||
- US3 (drift cutover) can proceed after Phase 2, but MUST complete before Phase 6 (dropping legacy tables).
|
||||
|
||||
### Parallel opportunities (examples)
|
||||
|
||||
- After Phase 2:
|
||||
- US1 implementation tasks in app/Services/Inventory/InventorySyncService.php and app/Jobs/EntraGroupSyncJob.php can be done in parallel.
|
||||
- US2 removal tasks (Filament resource deletion) can proceed in parallel with US3 migrations/code, as long as any shared files are coordinated.
|
||||
|
||||
## Implementation Strategy (MVP)
|
||||
|
||||
- MVP = Phase 2 + US1 + the minimum of US2 needed to remove navigation entry points.
|
||||
- Then complete US3 cutover and only then drop legacy tables.
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
use App\Jobs\ApplyBackupScheduleRetentionJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use Filament\Facades\Filament;
|
||||
|
||||
test('retention keeps last N backup sets per schedule', function () {
|
||||
@ -35,18 +35,33 @@
|
||||
]);
|
||||
});
|
||||
|
||||
// Oldest → newest
|
||||
$scheduledFor = now('UTC')->startOfMinute()->subMinutes(10);
|
||||
$completedAt = now('UTC')->startOfMinute()->subMinutes(10);
|
||||
|
||||
foreach ($sets as $set) {
|
||||
BackupScheduleRun::query()->create([
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'scheduled_for' => $scheduledFor,
|
||||
'status' => BackupScheduleRun::STATUS_SUCCESS,
|
||||
'summary' => ['policies_total' => 0, 'policies_backed_up' => 0, 'errors_count' => 0],
|
||||
'backup_set_id' => $set->id,
|
||||
OperationRun::query()->create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => (int) $tenant->id,
|
||||
'user_id' => null,
|
||||
'initiator_name' => 'System',
|
||||
'type' => 'backup_schedule_run',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'run_identity_hash' => hash('sha256', 'retention-test:'.$schedule->id.':'.$set->id),
|
||||
'summary_counts' => [
|
||||
'total' => 0,
|
||||
'processed' => 0,
|
||||
'succeeded' => 0,
|
||||
],
|
||||
'failure_summary' => [],
|
||||
'context' => [
|
||||
'backup_schedule_id' => (int) $schedule->id,
|
||||
'backup_set_id' => (int) $set->id,
|
||||
],
|
||||
'started_at' => $completedAt,
|
||||
'completed_at' => $completedAt,
|
||||
]);
|
||||
$scheduledFor = $scheduledFor->addMinute();
|
||||
|
||||
$completedAt = $completedAt->addMinute();
|
||||
}
|
||||
|
||||
ApplyBackupScheduleRetentionJob::dispatchSync($schedule->id);
|
||||
@ -64,4 +79,15 @@
|
||||
foreach ($deleted as $set) {
|
||||
$this->assertSoftDeleted('backup_sets', ['id' => $set->id]);
|
||||
}
|
||||
|
||||
$retentionRun = OperationRun::query()
|
||||
->where('tenant_id', (int) $tenant->id)
|
||||
->where('type', 'backup_schedule_retention')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
expect($retentionRun)->not->toBeNull();
|
||||
expect($retentionRun?->status)->toBe('completed');
|
||||
expect($retentionRun?->outcome)->toBe('succeeded');
|
||||
expect($retentionRun?->summary_counts['succeeded'] ?? null)->toBe(3);
|
||||
});
|
||||
|
||||
@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSet;
|
||||
|
||||
test('backup schedule run view modal renders run details', function () {
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$schedule = BackupSchedule::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Nightly',
|
||||
'is_enabled' => true,
|
||||
'timezone' => 'UTC',
|
||||
'frequency' => 'daily',
|
||||
'time_of_day' => '01:00:00',
|
||||
'days_of_week' => null,
|
||||
'policy_types' => ['deviceConfiguration'],
|
||||
'include_foundations' => true,
|
||||
'retention_keep_last' => 30,
|
||||
]);
|
||||
|
||||
$backupSet = BackupSet::query()->create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => 'Set 174',
|
||||
'status' => 'completed',
|
||||
'item_count' => 0,
|
||||
]);
|
||||
|
||||
$run = BackupScheduleRun::query()->create([
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'scheduled_for' => now('UTC')->startOfMinute()->toDateTimeString(),
|
||||
'status' => BackupScheduleRun::STATUS_SUCCESS,
|
||||
'summary' => [
|
||||
'policies_total' => 7,
|
||||
'policies_backed_up' => 7,
|
||||
'errors_count' => 0,
|
||||
],
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'backup_set_id' => $backupSet->id,
|
||||
]);
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
$html = view('filament.modals.backup-schedule-run-view', ['run' => $run])->render();
|
||||
|
||||
expect($html)->toContain('Scheduled for');
|
||||
expect($html)->toContain('Status');
|
||||
expect($html)->toContain('Summary');
|
||||
expect($html)->toContain((string) $backupSet->id);
|
||||
});
|
||||
@ -35,11 +35,9 @@
|
||||
$dispatcher->dispatchDue([$tenant->external_id]);
|
||||
$dispatcher->dispatchDue([$tenant->external_id]);
|
||||
|
||||
expect(\App\Models\BackupScheduleRun::query()->count())->toBe(0);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'backup_schedule.scheduled')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->count())->toBe(1);
|
||||
|
||||
Bus::assertDispatchedTimes(RunBackupScheduleJob::class, 1);
|
||||
@ -48,7 +46,7 @@
|
||||
return $job->backupScheduleId !== null
|
||||
&& $job->backupScheduleRunId === 0
|
||||
&& $job->operationRun?->tenant_id === $tenant->getKey()
|
||||
&& $job->operationRun?->type === 'backup_schedule.scheduled';
|
||||
&& $job->operationRun?->type === 'backup_schedule_run';
|
||||
});
|
||||
});
|
||||
|
||||
@ -77,7 +75,7 @@
|
||||
|
||||
$operationRunService->ensureRunWithIdentityStrict(
|
||||
tenant: $tenant,
|
||||
type: 'backup_schedule.scheduled',
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $schedule->id,
|
||||
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute()->toDateTimeString(),
|
||||
@ -93,13 +91,11 @@
|
||||
|
||||
$dispatcher = app(BackupScheduleDispatcher::class);
|
||||
$dispatcher->dispatchDue([$tenant->external_id]);
|
||||
|
||||
expect(\App\Models\BackupScheduleRun::query()->count())->toBe(0);
|
||||
Bus::assertNotDispatched(RunBackupScheduleJob::class);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'backup_schedule.scheduled')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->count())->toBe(1);
|
||||
|
||||
$schedule->refresh();
|
||||
|
||||
@ -1,17 +1,24 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplyBackupScheduleRetentionJob;
|
||||
use App\Jobs\RunBackupScheduleJob;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSet;
|
||||
use App\Services\BackupScheduling\PolicyTypeResolver;
|
||||
use App\Services\BackupScheduling\RunErrorMapper;
|
||||
use App\Services\BackupScheduling\ScheduleTimeService;
|
||||
use App\Services\Intune\AuditLogger;
|
||||
use App\Services\Intune\BackupService;
|
||||
use App\Services\Intune\PolicySyncService;
|
||||
use App\Services\OperationRunService;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Contracts\Queue\Job;
|
||||
use Illuminate\Support\Facades\Bus;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
it('creates a backup set and marks the operation run successful', function () {
|
||||
Bus::fake();
|
||||
|
||||
it('creates a backup set and marks the run successful', function () {
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
@ -31,18 +38,11 @@
|
||||
'next_run_at' => null,
|
||||
]);
|
||||
|
||||
$run = BackupScheduleRun::query()->create([
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
]);
|
||||
|
||||
/** @var OperationRunService $operationRunService */
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
$operationRun = $operationRunService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'backup_schedule.run_now',
|
||||
type: 'backup_schedule_run',
|
||||
inputs: ['backup_schedule_id' => (int) $schedule->id],
|
||||
initiator: $user,
|
||||
);
|
||||
@ -75,33 +75,35 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
|
||||
|
||||
Cache::flush();
|
||||
|
||||
(new RunBackupScheduleJob($run->id, $operationRun))->handle(
|
||||
(new RunBackupScheduleJob(0, $operationRun, (int) $schedule->id))->handle(
|
||||
app(PolicySyncService::class),
|
||||
app(BackupService::class),
|
||||
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
|
||||
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
|
||||
app(\App\Services\Intune\AuditLogger::class),
|
||||
app(\App\Services\BackupScheduling\RunErrorMapper::class),
|
||||
app(PolicyTypeResolver::class),
|
||||
app(ScheduleTimeService::class),
|
||||
app(AuditLogger::class),
|
||||
app(RunErrorMapper::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe(BackupScheduleRun::STATUS_SUCCESS);
|
||||
expect($run->backup_set_id)->toBe($backupSet->id);
|
||||
$schedule->refresh();
|
||||
expect($schedule->last_run_status)->toBe('success');
|
||||
|
||||
$operationRun->refresh();
|
||||
expect($operationRun->status)->toBe('completed');
|
||||
expect($operationRun->outcome)->toBe('succeeded');
|
||||
expect($operationRun->context)->toMatchArray([
|
||||
'backup_schedule_id' => (int) $schedule->id,
|
||||
'backup_schedule_run_id' => (int) $run->id,
|
||||
'backup_set_id' => (int) $backupSet->id,
|
||||
]);
|
||||
expect($operationRun->summary_counts)->toMatchArray([
|
||||
'created' => 1,
|
||||
]);
|
||||
|
||||
Bus::assertDispatched(ApplyBackupScheduleRetentionJob::class);
|
||||
});
|
||||
|
||||
it('skips runs when all policy types are unknown', function () {
|
||||
Bus::fake();
|
||||
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::create(2026, 1, 5, 10, 0, 30, 'UTC'));
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'owner');
|
||||
@ -121,44 +123,41 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
|
||||
'next_run_at' => null,
|
||||
]);
|
||||
|
||||
$run = BackupScheduleRun::query()->create([
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
]);
|
||||
|
||||
Cache::flush();
|
||||
|
||||
/** @var OperationRunService $operationRunService */
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
$operationRun = $operationRunService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'backup_schedule.run_now',
|
||||
type: 'backup_schedule_run',
|
||||
inputs: ['backup_schedule_id' => (int) $schedule->id],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
(new RunBackupScheduleJob($run->id, $operationRun))->handle(
|
||||
Cache::flush();
|
||||
|
||||
(new RunBackupScheduleJob(0, $operationRun, (int) $schedule->id))->handle(
|
||||
app(PolicySyncService::class),
|
||||
app(BackupService::class),
|
||||
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
|
||||
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
|
||||
app(\App\Services\Intune\AuditLogger::class),
|
||||
app(\App\Services\BackupScheduling\RunErrorMapper::class),
|
||||
app(PolicyTypeResolver::class),
|
||||
app(ScheduleTimeService::class),
|
||||
app(AuditLogger::class),
|
||||
app(RunErrorMapper::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe(BackupScheduleRun::STATUS_SKIPPED);
|
||||
expect($run->error_code)->toBe('UNKNOWN_POLICY_TYPE');
|
||||
expect($run->backup_set_id)->toBeNull();
|
||||
$schedule->refresh();
|
||||
expect($schedule->last_run_status)->toBe('skipped');
|
||||
|
||||
$operationRun->refresh();
|
||||
expect($operationRun->status)->toBe('completed');
|
||||
expect($operationRun->outcome)->toBe('failed');
|
||||
expect($operationRun->outcome)->toBe('blocked');
|
||||
expect($operationRun->failure_summary)->toMatchArray([
|
||||
['code' => 'unknown_policy_type', 'message' => $run->error_message, 'reason_code' => 'unknown_error'],
|
||||
[
|
||||
'code' => 'unknown_policy_type',
|
||||
'message' => 'All configured policy types are unknown.',
|
||||
'reason_code' => 'unknown_error',
|
||||
],
|
||||
]);
|
||||
|
||||
Bus::assertNotDispatched(ApplyBackupScheduleRetentionJob::class);
|
||||
});
|
||||
|
||||
it('fails fast when operation run context is not passed into the job', function () {
|
||||
@ -181,28 +180,18 @@ public function createBackupSet($tenant, $policyIds, ?string $actorEmail = null,
|
||||
'next_run_at' => null,
|
||||
]);
|
||||
|
||||
$run = BackupScheduleRun::query()->create([
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'scheduled_for' => CarbonImmutable::now('UTC')->startOfMinute(),
|
||||
'status' => BackupScheduleRun::STATUS_RUNNING,
|
||||
]);
|
||||
|
||||
$queueJob = \Mockery::mock(Job::class);
|
||||
$queueJob->shouldReceive('fail')->once();
|
||||
|
||||
$job = new RunBackupScheduleJob($run->id);
|
||||
$job = new RunBackupScheduleJob(0, null, (int) $schedule->id);
|
||||
$job->setJob($queueJob);
|
||||
|
||||
$job->handle(
|
||||
app(PolicySyncService::class),
|
||||
app(BackupService::class),
|
||||
app(\App\Services\BackupScheduling\PolicyTypeResolver::class),
|
||||
app(\App\Services\BackupScheduling\ScheduleTimeService::class),
|
||||
app(\App\Services\Intune\AuditLogger::class),
|
||||
app(\App\Services\BackupScheduling\RunErrorMapper::class),
|
||||
app(PolicyTypeResolver::class),
|
||||
app(ScheduleTimeService::class),
|
||||
app(AuditLogger::class),
|
||||
app(RunErrorMapper::class),
|
||||
);
|
||||
|
||||
$run->refresh();
|
||||
expect($run->status)->toBe(BackupScheduleRun::STATUS_RUNNING);
|
||||
});
|
||||
|
||||
@ -49,12 +49,9 @@
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableAction('runNow', $schedule);
|
||||
|
||||
expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
|
||||
->toBe(0);
|
||||
|
||||
$operationRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup_schedule.run_now')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->first();
|
||||
|
||||
expect($operationRun)->not->toBeNull();
|
||||
@ -113,12 +110,9 @@
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableAction('runNow', $schedule);
|
||||
|
||||
expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
|
||||
->toBe(0);
|
||||
|
||||
$runs = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup_schedule.run_now')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
@ -153,12 +147,9 @@
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableAction('retry', $schedule);
|
||||
|
||||
expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
|
||||
->toBe(0);
|
||||
|
||||
$operationRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup_schedule.retry')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->first();
|
||||
|
||||
expect($operationRun)->not->toBeNull();
|
||||
@ -180,7 +171,7 @@
|
||||
'notifiable_type' => User::class,
|
||||
'type' => OperationRunQueued::class,
|
||||
'data->format' => 'filament',
|
||||
'data->title' => 'Backup schedule retry queued',
|
||||
'data->title' => 'Backup schedule run queued',
|
||||
]);
|
||||
|
||||
$notification = $user->notifications()->latest('id')->first();
|
||||
@ -216,12 +207,9 @@
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableAction('retry', $schedule);
|
||||
|
||||
expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
|
||||
->toBe(0);
|
||||
|
||||
$runs = OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup_schedule.retry')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->pluck('id')
|
||||
->all();
|
||||
|
||||
@ -265,12 +253,9 @@
|
||||
// Action should be hidden/blocked for readonly users.
|
||||
}
|
||||
|
||||
expect(\App\Models\BackupScheduleRun::query()->where('backup_schedule_id', $schedule->id)->count())
|
||||
->toBe(0);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->whereIn('type', ['backup_schedule.run_now', 'backup_schedule.retry'])
|
||||
->whereIn('type', ['backup_schedule_run', 'backup_schedule_run'])
|
||||
->count())
|
||||
->toBe(0);
|
||||
});
|
||||
@ -312,18 +297,15 @@
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableBulkAction('bulk_run_now', collect([$scheduleA, $scheduleB]));
|
||||
|
||||
expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
|
||||
->toBe(0);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup_schedule.run_now')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->count())
|
||||
->toBe(2);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup_schedule.run_now')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->pluck('user_id')
|
||||
->unique()
|
||||
->values()
|
||||
@ -381,18 +363,15 @@
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB]));
|
||||
|
||||
expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
|
||||
->toBe(0);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup_schedule.retry')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->count())
|
||||
->toBe(2);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup_schedule.retry')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->pluck('user_id')
|
||||
->unique()
|
||||
->values()
|
||||
@ -452,7 +431,7 @@
|
||||
$operationRunService = app(OperationRunService::class);
|
||||
$existing = $operationRunService->ensureRunWithIdentity(
|
||||
tenant: $tenant,
|
||||
type: 'backup_schedule.retry',
|
||||
type: 'backup_schedule_run',
|
||||
identityInputs: [
|
||||
'backup_schedule_id' => (int) $scheduleA->getKey(),
|
||||
'nonce' => 'existing',
|
||||
@ -471,12 +450,9 @@
|
||||
Livewire::test(ListBackupSchedules::class)
|
||||
->callTableBulkAction('bulk_retry', collect([$scheduleA, $scheduleB]));
|
||||
|
||||
expect(\App\Models\BackupScheduleRun::query()->whereIn('backup_schedule_id', [$scheduleA->id, $scheduleB->id])->count())
|
||||
->toBe(0);
|
||||
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenant->id)
|
||||
->where('type', 'backup_schedule.retry')
|
||||
->where('type', 'backup_schedule_run')
|
||||
->count())
|
||||
->toBe(3);
|
||||
|
||||
|
||||
@ -50,7 +50,7 @@
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => $user->id,
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'backup_schedule.run_now',
|
||||
'type' => 'backup_schedule_run',
|
||||
'status' => 'queued',
|
||||
'outcome' => 'pending',
|
||||
'context' => ['scope' => 'scheduled'],
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
use App\Models\AuditLog;
|
||||
use App\Models\BackupItem;
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\BackupSet;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Policy;
|
||||
@ -101,22 +100,9 @@
|
||||
'next_run_at' => now()->addHour(),
|
||||
]);
|
||||
|
||||
BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $scheduleA->id,
|
||||
'tenant_id' => $tenantA->id,
|
||||
'scheduled_for' => now()->startOfMinute(),
|
||||
'started_at' => null,
|
||||
'finished_at' => null,
|
||||
'status' => BackupScheduleRun::STATUS_SUCCESS,
|
||||
'summary' => null,
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'backup_set_id' => $backupSetA->id,
|
||||
]);
|
||||
|
||||
expect(Policy::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0);
|
||||
expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0);
|
||||
expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0);
|
||||
expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBeGreaterThan(0);
|
||||
|
||||
$this->artisan('tenantpilot:purge-nonpersistent', [
|
||||
'tenant' => $tenantA->id,
|
||||
@ -130,8 +116,11 @@
|
||||
expect(BackupSet::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(RestoreRun::withTrashed()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(AuditLog::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(BackupScheduleRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
expect(OperationRun::query()->where('tenant_id', $tenantA->id)->count())->toBe(1);
|
||||
expect(OperationRun::query()
|
||||
->where('tenant_id', $tenantA->id)
|
||||
->where('type', 'backup_schedule_purge')
|
||||
->exists())->toBeTrue();
|
||||
|
||||
expect(BackupSchedule::query()->where('tenant_id', $tenantA->id)->count())->toBe(0);
|
||||
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\BackupSchedule;
|
||||
use App\Models\BackupScheduleRun;
|
||||
use App\Models\OperationRun;
|
||||
use App\Models\Tenant;
|
||||
use Carbon\CarbonImmutable;
|
||||
@ -9,7 +8,7 @@
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
it('reconciles completed backup schedule runs into operation runs', function () {
|
||||
it('reconciles stale running backup schedule operation runs', function () {
|
||||
$tenant = Tenant::factory()->create();
|
||||
|
||||
$schedule = BackupSchedule::create([
|
||||
@ -29,39 +28,24 @@
|
||||
]);
|
||||
|
||||
$startedAt = CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC');
|
||||
$finishedAt = CarbonImmutable::parse('2026-01-01 00:00:05', 'UTC');
|
||||
|
||||
$scheduleRun = BackupScheduleRun::create([
|
||||
'backup_schedule_id' => $schedule->id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'scheduled_for' => CarbonImmutable::parse('2026-01-01 00:00:00', 'UTC'),
|
||||
'started_at' => $startedAt,
|
||||
'finished_at' => $finishedAt,
|
||||
'status' => BackupScheduleRun::STATUS_SUCCESS,
|
||||
'summary' => [
|
||||
'policies_total' => 5,
|
||||
'policies_backed_up' => 18,
|
||||
'sync_failures' => [],
|
||||
],
|
||||
'error_code' => null,
|
||||
'error_message' => null,
|
||||
'backup_set_id' => null,
|
||||
]);
|
||||
|
||||
$operationRun = OperationRun::create([
|
||||
'workspace_id' => (int) $tenant->workspace_id,
|
||||
'tenant_id' => $tenant->id,
|
||||
'user_id' => null,
|
||||
'initiator_name' => 'System',
|
||||
'type' => 'backup_schedule.run_now',
|
||||
'status' => 'queued',
|
||||
'type' => 'backup_schedule_run',
|
||||
'status' => 'running',
|
||||
'outcome' => 'pending',
|
||||
'run_identity_hash' => hash('sha256', 'backup_schedule.run_now|'.$scheduleRun->id),
|
||||
'run_identity_hash' => hash('sha256', 'backup_schedule_run:'.$schedule->id),
|
||||
'summary_counts' => [],
|
||||
'failure_summary' => [],
|
||||
'context' => [
|
||||
'backup_schedule_id' => (int) $schedule->id,
|
||||
'backup_schedule_run_id' => (int) $scheduleRun->id,
|
||||
],
|
||||
'started_at' => $startedAt,
|
||||
'created_at' => $startedAt,
|
||||
'updated_at' => $startedAt,
|
||||
]);
|
||||
|
||||
$this->artisan('tenantpilot:operation-runs:reconcile-backup-schedules', [
|
||||
@ -72,21 +56,15 @@
|
||||
$operationRun->refresh();
|
||||
|
||||
expect($operationRun->status)->toBe('completed');
|
||||
expect($operationRun->outcome)->toBe('succeeded');
|
||||
expect($operationRun->failure_summary)->toBe([]);
|
||||
|
||||
expect($operationRun->started_at?->format('Y-m-d H:i:s'))->toBe($startedAt->format('Y-m-d H:i:s'));
|
||||
expect($operationRun->completed_at?->format('Y-m-d H:i:s'))->toBe($finishedAt->format('Y-m-d H:i:s'));
|
||||
|
||||
expect($operationRun->outcome)->toBe('failed');
|
||||
expect($operationRun->failure_summary)->toMatchArray([
|
||||
[
|
||||
'code' => 'backup_schedule.stalled',
|
||||
'message' => 'Backup schedule run exceeded reconciliation timeout and was marked failed.',
|
||||
'reason_code' => 'unknown_error',
|
||||
],
|
||||
]);
|
||||
expect($operationRun->context)->toMatchArray([
|
||||
'backup_schedule_id' => (int) $schedule->id,
|
||||
'backup_schedule_run_id' => (int) $scheduleRun->id,
|
||||
]);
|
||||
|
||||
expect($operationRun->summary_counts)->toMatchArray([
|
||||
'total' => 5,
|
||||
'processed' => 5,
|
||||
'succeeded' => 18,
|
||||
'items' => 5,
|
||||
]);
|
||||
});
|
||||
|
||||
@ -17,25 +17,15 @@
|
||||
|
||||
CarbonImmutable::setTestNow(CarbonImmutable::parse('2026-01-11 02:00:00', 'UTC'));
|
||||
|
||||
$legacyCountBefore = \App\Models\EntraGroupSyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->count();
|
||||
|
||||
Artisan::call('tenantpilot:directory-groups:dispatch', [
|
||||
'--tenant' => [$tenant->tenant_id],
|
||||
]);
|
||||
|
||||
$slotKey = CarbonImmutable::now('UTC')->format('YmdHi').'Z';
|
||||
|
||||
$legacyCountAfter = \App\Models\EntraGroupSyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->count();
|
||||
|
||||
expect($legacyCountAfter)->toBe($legacyCountBefore);
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'directory_groups.sync')
|
||||
->where('type', 'entra_group_sync')
|
||||
->where('context->slot_key', $slotKey)
|
||||
->first();
|
||||
|
||||
|
||||
@ -26,22 +26,12 @@
|
||||
$tenant->makeCurrent();
|
||||
Filament::setTenant($tenant, true);
|
||||
|
||||
$legacyCountBefore = \App\Models\EntraGroupSyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->count();
|
||||
|
||||
Livewire::test(ListEntraGroups::class)
|
||||
->callAction('sync_groups');
|
||||
|
||||
$legacyCountAfter = \App\Models\EntraGroupSyncRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->count();
|
||||
|
||||
expect($legacyCountAfter)->toBe($legacyCountBefore);
|
||||
|
||||
$opRun = OperationRun::query()
|
||||
->where('tenant_id', $tenant->getKey())
|
||||
->where('type', 'directory_groups.sync')
|
||||
->where('type', 'entra_group_sync')
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
expect($run)->toBeInstanceOf(OperationRun::class)
|
||||
->and($run->tenant_id)->toBe($tenant->getKey())
|
||||
->and($run->user_id)->toBe($user->getKey())
|
||||
->and($run->type)->toBe('directory_groups.sync')
|
||||
->and($run->type)->toBe('entra_group_sync')
|
||||
->and($run->status)->toBe('queued')
|
||||
->and($run->context['selection_key'] ?? null)->toBe('groups-v1:all');
|
||||
|
||||
|
||||
@ -54,7 +54,7 @@
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'directory_groups.sync',
|
||||
type: 'entra_group_sync',
|
||||
inputs: ['selection_key' => 'groups-v1:all'],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
@ -34,7 +34,7 @@
|
||||
$opService = app(OperationRunService::class);
|
||||
$opRun = $opService->ensureRun(
|
||||
tenant: $tenant,
|
||||
type: 'directory_groups.sync',
|
||||
type: 'entra_group_sync',
|
||||
inputs: ['selection_key' => 'groups-v1:all'],
|
||||
initiator: $user,
|
||||
);
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Drift\DriftFindingGenerator;
|
||||
@ -11,17 +10,17 @@
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-assignments');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Services\Drift\DriftRunSelector;
|
||||
|
||||
test('it selects the previous and latest successful runs for the same scope', function () {
|
||||
@ -9,27 +8,27 @@
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-a');
|
||||
|
||||
InventorySyncRun::factory()->for($tenant)->create([
|
||||
createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(3),
|
||||
]);
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
InventorySyncRun::factory()->for($tenant)->create([
|
||||
createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_FAILED,
|
||||
'status' => 'failed',
|
||||
'finished_at' => now(),
|
||||
]);
|
||||
|
||||
@ -48,9 +47,9 @@
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-b');
|
||||
|
||||
InventorySyncRun::factory()->for($tenant)->create([
|
||||
createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
|
||||
use App\Filament\Pages\DriftLanding;
|
||||
use App\Jobs\GenerateDriftFindingsJob;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\OperationRun;
|
||||
use Filament\Facades\Filament;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
@ -17,17 +16,17 @@
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-zero-findings');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
@ -35,7 +34,7 @@
|
||||
'tenant_id' => $tenant->getKey(),
|
||||
'user_id' => $user->getKey(),
|
||||
'initiator_name' => $user->name,
|
||||
'type' => 'drift.generate',
|
||||
'type' => 'drift_generate_findings',
|
||||
'status' => 'completed',
|
||||
'outcome' => 'succeeded',
|
||||
'run_identity_hash' => 'drift-zero-findings',
|
||||
@ -48,8 +47,8 @@
|
||||
],
|
||||
'context' => [
|
||||
'scope_key' => $scopeKey,
|
||||
'baseline_run_id' => (int) $baseline->getKey(),
|
||||
'current_run_id' => (int) $current->getKey(),
|
||||
'baseline_operation_run_id' => (int) $baseline->getKey(),
|
||||
'current_operation_run_id' => (int) $current->getKey(),
|
||||
],
|
||||
]);
|
||||
|
||||
|
||||
@ -4,7 +4,6 @@
|
||||
use App\Models\EntraGroup;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Directory\EntraGroupLabelResolver;
|
||||
@ -14,15 +13,15 @@
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => hash('sha256', 'scope-assignments-diff'),
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $baseline->selection_hash,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
@ -104,8 +103,8 @@
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => (string) $current->selection_hash,
|
||||
'baseline_run_id' => $baseline->getKey(),
|
||||
'current_run_id' => $current->getKey(),
|
||||
'baseline_operation_run_id' => $baseline->getKey(),
|
||||
'current_operation_run_id' => $current->getKey(),
|
||||
'subject_type' => 'assignment',
|
||||
'subject_external_id' => $policy->external_id,
|
||||
'evidence_jsonb' => [
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
|
||||
@ -12,15 +11,15 @@
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => hash('sha256', 'scope-scope-tags-diff'),
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $baseline->selection_hash,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
@ -61,8 +60,8 @@
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => (string) $current->selection_hash,
|
||||
'baseline_run_id' => $baseline->getKey(),
|
||||
'current_run_id' => $current->getKey(),
|
||||
'baseline_operation_run_id' => $baseline->getKey(),
|
||||
'current_operation_run_id' => $current->getKey(),
|
||||
'subject_type' => 'scope_tag',
|
||||
'subject_external_id' => $policy->external_id,
|
||||
'evidence_jsonb' => [
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
|
||||
@ -12,15 +11,15 @@
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => hash('sha256', 'scope-settings-diff'),
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $baseline->selection_hash,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
@ -59,8 +58,8 @@
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => (string) $current->selection_hash,
|
||||
'baseline_run_id' => $baseline->getKey(),
|
||||
'current_run_id' => $current->getKey(),
|
||||
'baseline_operation_run_id' => $baseline->getKey(),
|
||||
'current_operation_run_id' => $current->getKey(),
|
||||
'subject_type' => 'policy',
|
||||
'subject_external_id' => $policy->external_id,
|
||||
'evidence_jsonb' => [
|
||||
|
||||
@ -3,30 +3,29 @@
|
||||
use App\Filament\Resources\FindingResource;
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventoryItem;
|
||||
use App\Models\InventorySyncRun;
|
||||
|
||||
test('finding detail renders without Graph calls', function () {
|
||||
bindFailHardGraphClient();
|
||||
|
||||
[$user, $tenant] = createUserWithTenant(role: 'manager');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => hash('sha256', 'scope-detail'),
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $baseline->selection_hash,
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
$finding = Finding::factory()->for($tenant)->create([
|
||||
'finding_type' => Finding::FINDING_TYPE_DRIFT,
|
||||
'scope_key' => (string) $current->selection_hash,
|
||||
'baseline_run_id' => $baseline->getKey(),
|
||||
'current_run_id' => $current->getKey(),
|
||||
'baseline_operation_run_id' => $baseline->getKey(),
|
||||
'current_operation_run_id' => $current->getKey(),
|
||||
'subject_type' => 'deviceConfiguration',
|
||||
'subject_external_id' => 'policy-123',
|
||||
'evidence_jsonb' => [
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Finding;
|
||||
use App\Models\InventorySyncRun;
|
||||
use App\Models\Policy;
|
||||
use App\Models\PolicyVersion;
|
||||
use App\Services\Drift\DriftFindingGenerator;
|
||||
@ -11,17 +10,17 @@
|
||||
|
||||
$scopeKey = hash('sha256', 'scope-determinism');
|
||||
|
||||
$baseline = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$baseline = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDays(2),
|
||||
]);
|
||||
|
||||
$current = InventorySyncRun::factory()->for($tenant)->create([
|
||||
$current = createInventorySyncOperationRun($tenant, [
|
||||
'selection_hash' => $scopeKey,
|
||||
'selection_payload' => ['policy_types' => ['settingsCatalogPolicy']],
|
||||
'status' => InventorySyncRun::STATUS_SUCCESS,
|
||||
'status' => 'success',
|
||||
'finished_at' => now()->subDay(),
|
||||
]);
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user