TenantAtlas/app/Console/Commands/TenantpilotReconcileBackupScheduleOperationRuns.php
ahmido d6e7de597a feat(spec-087): remove legacy runs (#106)
Implements Spec 087: Legacy Runs Removal (rigorous).

### What changed
- Canonicalized run history: **`operation_runs` is the only run system** for inventory sync, Entra group sync, backup schedule execution/retention/purge.
- Removed legacy UI surfaces (Filament Resources / relation managers) for legacy run models.
- Legacy run URLs now return **404** (no redirects), with RBAC semantics preserved (404 vs 403 as specified).
- Canonicalized affected `operation_runs.type` values (dotted → underscore) via migration.
- Drift + inventory references now point to canonical operation runs; includes backfills and then drops legacy FK columns.
- Drops legacy run tables after cutover.
- Added regression guards to prevent reintroducing legacy run tokens or “backfilling” canonical runs from legacy tables.

### Migrations
- `2026_02_12_000001..000006_*` canonicalize types, add/backfill operation_run_id references, drop legacy columns, and drop legacy run tables.

### Tests
Focused pack for this spec passed:
- `tests/Feature/Guards/NoLegacyRunsTest.php`
- `tests/Feature/Guards/NoLegacyRunBackfillTest.php`
- `tests/Feature/Operations/LegacyRunRoutesNotFoundTest.php`
- `tests/Feature/Monitoring/MonitoringOperationsTest.php`
- `tests/Feature/Jobs/RunInventorySyncJobTest.php`

### Notes / impact
- Destructive cleanup is handled via migrations (drops legacy tables) after code cutover; deploy should run migrations in the same release.

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #106
2026-02-12 12:40:51 +00:00

167 lines
5.3 KiB
PHP

<?php
namespace App\Console\Commands;
use App\Models\BackupSchedule;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Services\OperationRunService;
use App\Support\OperationRunOutcome;
use Illuminate\Console\Command;
class TenantpilotReconcileBackupScheduleOperationRuns extends Command
{
protected $signature = 'tenantpilot:operation-runs:reconcile-backup-schedules
{--tenant=* : Limit to tenant_id/external_id}
{--older-than=5 : Only reconcile runs older than N minutes}
{--dry-run : Do not write changes}';
protected $description = 'Reconcile stuck backup schedule OperationRuns without legacy run-table lookups.';
public function handle(OperationRunService $operationRunService): int
{
$tenantIdentifiers = array_values(array_filter((array) $this->option('tenant')));
$olderThanMinutes = max(0, (int) $this->option('older-than'));
$dryRun = (bool) $this->option('dry-run');
$query = OperationRun::query()
->where('type', 'backup_schedule_run')
->whereIn('status', ['queued', 'running']);
if ($olderThanMinutes > 0) {
$query->where('created_at', '<', now()->subMinutes($olderThanMinutes));
}
if ($tenantIdentifiers !== []) {
$tenantIds = $this->resolveTenantIds($tenantIdentifiers);
if ($tenantIds === []) {
$this->info('No tenants matched the provided identifiers.');
return self::SUCCESS;
}
$query->whereIn('tenant_id', $tenantIds);
}
$reconciled = 0;
$skipped = 0;
$failed = 0;
foreach ($query->cursor() as $operationRun) {
$backupScheduleId = data_get($operationRun->context, 'backup_schedule_id');
if (! is_numeric($backupScheduleId)) {
if (! $dryRun) {
$operationRunService->updateRun(
$operationRun,
status: 'completed',
outcome: OperationRunOutcome::Failed->value,
failures: [
[
'code' => 'backup_schedule.missing_context',
'message' => 'Backup schedule context is missing from this operation run.',
],
],
);
}
$failed++;
continue;
}
$schedule = BackupSchedule::query()
->whereKey((int) $backupScheduleId)
->where('tenant_id', (int) $operationRun->tenant_id)
->first();
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++;
continue;
}
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;
}
$skipped++;
}
$this->info(sprintf(
'Reconciled %d run(s), skipped %d, failed %d.',
$reconciled,
$skipped,
$failed,
));
if ($dryRun) {
$this->comment('Dry-run: no changes written.');
}
return self::SUCCESS;
}
/**
* @param array<int, string> $tenantIdentifiers
* @return array<int>
*/
private function resolveTenantIds(array $tenantIdentifiers): array
{
$tenantIds = [];
foreach ($tenantIdentifiers as $identifier) {
$tenant = Tenant::query()
->forTenant($identifier)
->first();
if ($tenant) {
$tenantIds[] = (int) $tenant->getKey();
}
}
return array_values(array_unique($tenantIds));
}
}