diff --git a/.github/workflows/dependency-review.yaml b/.github/workflows/dependency-review.yaml index 8e4a0c00..2f72e77c 100644 --- a/.github/workflows/dependency-review.yaml +++ b/.github/workflows/dependency-review.yaml @@ -14,6 +14,6 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + - uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: config-file: .github/dependency-review-config.yaml diff --git a/app/Console/Commands/DispatchProjectPurgeJobsCommand.php b/app/Console/Commands/DispatchProjectPurgeJobsCommand.php new file mode 100644 index 00000000..48dc20e6 --- /dev/null +++ b/app/Console/Commands/DispatchProjectPurgeJobsCommand.php @@ -0,0 +1,124 @@ +option('dry-run'); + $limit = (int) ($this->option('limit') ?? config('polydock.cleanup.purge_max_per_run', 25)); + $pollInterval = (int) config('polydock.cleanup.purge_poll_interval_minutes', 10); + $maxAttempts = (int) config('polydock.cleanup.purge_max_poll_attempts', 144); + + $now = now(); + $backoffCutoff = $now->copy()->subMinutes($pollInterval); + + // First, push instances that have exceeded the polling cap to PURGE_FAILED + // so they stop being re-dispatched (admin must explicitly retry). + $stuck = PolydockAppInstance::query() + ->where('status', PolydockAppInstanceStatus::REMOVED) + ->where('purge_attempts', '>=', $maxAttempts) + ->get(); + + foreach ($stuck as $instance) { + $message = sprintf( + 'Purge polling cap reached (%d attempts) without success', + $instance->purge_attempts, + ); + + $this->warn(sprintf('[stuck] %s (id=%d): %s', $instance->name, $instance->id, $message)); + Log::warning('Marking instance PURGE_FAILED after polling cap', [ + 'app_instance_id' => $instance->id, + 'attempts' => $instance->purge_attempts, + ]); + + if (! $isDryRun) { + $instance->purge_failure_reason = $message; + $instance->setStatus(PolydockAppInstanceStatus::PURGE_FAILED, $message); + $instance->save(); + } + } + + // Now find eligible candidates. + $candidates = PolydockAppInstance::query() + ->where('status', PolydockAppInstanceStatus::REMOVED) + ->where('purge_attempts', '<', $maxAttempts) + ->where(function ($query) use ($now) { + // Either grace period elapsed naturally... + $query->where(function ($q) use ($now) { + $q->whereNotNull('purge_eligible_at') + ->where('purge_eligible_at', '<=', $now); + }) + // ...or admin-forced. + ->orWhereNotNull('force_purge_requested_at'); + }) + ->where(function ($query) use ($backoffCutoff) { + $query->whereNull('purge_last_attempted_at') + ->orWhere('purge_last_attempted_at', '<=', $backoffCutoff); + }) + ->orderBy('purge_eligible_at') + ->limit($limit) + ->get(); + + if ($candidates->isEmpty()) { + $this->info('No instances eligible for project purge.'); + + return self::SUCCESS; + } + + $this->info(sprintf('Found %d instance(s) eligible for project purge.', $candidates->count())); + + foreach ($candidates as $instance) { + $reason = $instance->force_purge_requested_at !== null ? 'forced' : 'grace-period elapsed'; + $line = sprintf( + ' - %s (id=%d, attempts=%d, %s)', + $instance->name, + $instance->id, + $instance->purge_attempts ?? 0, + $reason, + ); + + if ($isDryRun) { + $this->line('[dry-run]'.$line); + + continue; + } + + $this->line($line); + + Log::info('Dispatching ProcessProjectPurgeJob', [ + 'app_instance_id' => $instance->id, + 'reason' => $reason, + 'attempts' => $instance->purge_attempts ?? 0, + ]); + + // Setting status triggers the listener which dispatches the job. + $instance->setStatus(PolydockAppInstanceStatus::PENDING_PURGE, 'Project purge dispatched ('.$reason.')'); + $instance->save(); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/MarkStuckInstancesFailedCommand.php b/app/Console/Commands/MarkStuckInstancesFailedCommand.php new file mode 100644 index 00000000..7323e8e5 --- /dev/null +++ b/app/Console/Commands/MarkStuckInstancesFailedCommand.php @@ -0,0 +1,133 @@ + + */ + private static function intermediateStatuses(): array + { + return PolydockAppInstance::unallocatedInProgressStatuses(); + } + + /** + * Resolve the corresponding failed status for a given intermediate status. + */ + private static function resolveFailedStatus(PolydockAppInstanceStatus $status): PolydockAppInstanceStatus + { + return match ($status) { + PolydockAppInstanceStatus::NEW, + PolydockAppInstanceStatus::PENDING_PRE_CREATE, + PolydockAppInstanceStatus::PRE_CREATE_RUNNING, + PolydockAppInstanceStatus::PRE_CREATE_COMPLETED => PolydockAppInstanceStatus::PRE_CREATE_FAILED, + + PolydockAppInstanceStatus::PENDING_CREATE, + PolydockAppInstanceStatus::CREATE_RUNNING, + PolydockAppInstanceStatus::CREATE_COMPLETED => PolydockAppInstanceStatus::CREATE_FAILED, + + PolydockAppInstanceStatus::PENDING_POST_CREATE, + PolydockAppInstanceStatus::POST_CREATE_RUNNING, + PolydockAppInstanceStatus::POST_CREATE_COMPLETED => PolydockAppInstanceStatus::POST_CREATE_FAILED, + + PolydockAppInstanceStatus::PENDING_PRE_DEPLOY, + PolydockAppInstanceStatus::PRE_DEPLOY_RUNNING, + PolydockAppInstanceStatus::PRE_DEPLOY_COMPLETED => PolydockAppInstanceStatus::PRE_DEPLOY_FAILED, + + PolydockAppInstanceStatus::PENDING_DEPLOY, + PolydockAppInstanceStatus::DEPLOY_RUNNING, + PolydockAppInstanceStatus::DEPLOY_COMPLETED => PolydockAppInstanceStatus::DEPLOY_FAILED, + + PolydockAppInstanceStatus::PENDING_POST_DEPLOY, + PolydockAppInstanceStatus::POST_DEPLOY_RUNNING, + PolydockAppInstanceStatus::POST_DEPLOY_COMPLETED => PolydockAppInstanceStatus::POST_DEPLOY_FAILED, + + PolydockAppInstanceStatus::PENDING_POLYDOCK_CLAIM, + PolydockAppInstanceStatus::POLYDOCK_CLAIM_RUNNING, + PolydockAppInstanceStatus::POLYDOCK_CLAIM_COMPLETED => PolydockAppInstanceStatus::POLYDOCK_CLAIM_FAILED, + + default => throw new \LogicException("No failed status mapping for: {$status->value}"), + }; + } + + public function handle(): int + { + $threshold = (int) $this->option('threshold'); + $dryRun = (bool) $this->option('dry-run'); + $chunkSize = (int) $this->option('chunk'); + $cutoff = now()->subMinutes($threshold); + + $totalMarked = 0; + $rows = []; + + PolydockAppInstance::query() + ->whereIn('status', self::intermediateStatuses()) + ->where('updated_at', '<=', $cutoff) + ->chunkById($chunkSize, function ($instances) use ($dryRun, $threshold, &$totalMarked, &$rows) { + foreach ($instances as $instance) { + $failedStatus = self::resolveFailedStatus($instance->status); + + $rows[] = [ + $instance->id, + $instance->uuid ?? $instance->id, + $instance->status->value, + $failedStatus->value, + $instance->updated_at->diffForHumans(), + ]; + + if (! $dryRun) { + $previousStatus = $instance->status->value; + + $instance->update([ + 'status' => $failedStatus, + 'status_message' => "Automatically marked failed: stuck at {$previousStatus} for >{$threshold} minutes", + ]); + } + + $totalMarked++; + } + }); + + if ($totalMarked === 0) { + $this->info('No stuck instances found.'); + Log::info('polydock:mark-stuck-instances-failed: no stuck instances found', [ + 'threshold_minutes' => $threshold, + ]); + + return Command::SUCCESS; + } + + $this->info("Found {$totalMarked} stuck instance(s) (threshold: {$threshold} minutes):"); + $this->table(['ID', 'UUID', 'Was', 'Now', 'Last Updated'], $rows); + + if ($dryRun) { + $this->warn('Dry run — no changes made.'); + } else { + $this->info("Marked {$totalMarked} instance(s) as failed."); + Log::warning('polydock:mark-stuck-instances-failed: marked instances as failed', [ + 'count' => $totalMarked, + 'threshold_minutes' => $threshold, + ]); + } + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/RemoveEmptyProjectsCommand.php b/app/Console/Commands/RemoveEmptyProjectsCommand.php index 76fc6e38..8657be99 100644 --- a/app/Console/Commands/RemoveEmptyProjectsCommand.php +++ b/app/Console/Commands/RemoveEmptyProjectsCommand.php @@ -1,91 +1,87 @@ option('dry-run'); - $force = $this->option('force'); + $isDryRun = (bool) $this->option('dry-run'); + $force = (bool) $this->option('force'); - $this->info('Searching for app instances in REMOVED state with no environments...'); + $this->info('Searching for app instances in REMOVED state...'); - // Find all app instances in REMOVED state $removedInstances = PolydockAppInstance::where('status', PolydockAppInstanceStatus::REMOVED)->get(); if ($removedInstances->isEmpty()) { $this->info('No app instances found in REMOVED state.'); - return 0; + return self::SUCCESS; } $this->info("Found {$removedInstances->count()} app instance(s) in REMOVED state."); $this->newLine(); - $lagoonServiceProvider = new PolydockServiceProviderFTLagoon( - config('polydock.service_providers_singletons.PolydockServiceProviderFTLagoon'), - $this->getLogger(), - ); + $service = LagoonProjectPurgeService::makeWithDefaults($this->makeLogger()); - $lagoonClient = $lagoonServiceProvider->getLagoonClient(); + if ($isDryRun) { + $this->info('Dry-run: probing each project to find empty ones.'); + } else { + $this->info('Probing each project to find empty ones.'); + } - // Filter instances that have no environments (empty projects) - $emptyProjects = collect(); + // First pass: find which are actually empty. + $candidates = collect(); $apiErrorCount = 0; foreach ($removedInstances as $instance) { - try { - $projectName = $instance->data['project_name'] ?? $instance->name; + $projectName = $service->resolveProjectName($instance); - if (! $projectName) { - $this->warn("Instance {$instance->id} has no project name, skipping."); + if ($projectName === null) { + $this->warn("Instance {$instance->id} has no project name, skipping."); - continue; - } + continue; + } - // Get project details from Lagoon API - $projectData = $lagoonClient->getProjectByName($projectName); + $projectProbe = $this->probeEnvironments($service, $projectName); + if ($projectProbe === null) { + $apiErrorCount++; - // Check if project has any environments - $environments = $projectData['environments'] ?? []; + continue; + } - if (empty($environments)) { - $emptyProjects->push($instance); - $this->line("✓ Project '{$projectName}' has no environments - marked for cleanup"); - } else { - $this->line("- Project '{$projectName}' has ".count($environments).' environment(s) - keeping'); - } - } catch (\Exception $e) { - $this->error("✗ Failed to check project for instance {$instance->id}: {$e->getMessage()}"); - $apiErrorCount++; + if ($projectProbe['status'] === 'missing') { + $this->line("- Project '{$projectName}' no longer exists in Lagoon"); - // Continue processing other instances even if one fails + continue; + } + + if ($projectProbe['status'] === 'empty') { + $candidates->push($instance); + $this->line("✓ Project '{$projectName}' has no environments"); + } else { + $this->line("- Project '{$projectName}' has {$projectProbe['environment_count']} environment(s)"); } } @@ -93,26 +89,25 @@ public function handle() $this->warn("Warning: {$apiErrorCount} API call(s) failed during environment checking."); } - if ($emptyProjects->isEmpty()) { - $this->info('All REMOVED instances still have environments. No empty projects to clean up.'); + if ($candidates->isEmpty()) { + $this->info('No empty projects to clean up.'); - return 0; + return self::SUCCESS; } - $this->info("Found {$emptyProjects->count()} empty project(s) ready for cleanup."); + $this->info("Found {$candidates->count()} empty project(s) ready for cleanup."); $this->newLine(); - // Display the empty projects $headers = ['ID', 'Name', 'Project Name', 'Store App', 'Removed At']; $rows = []; - foreach ($emptyProjects as $instance) { + foreach ($candidates as $instance) { $rows[] = [ $instance->id, $instance->name ?: 'N/A', - $instance->data['project_name'] ?? 'N/A', + $service->resolveProjectName($instance) ?? 'N/A', $instance->storeApp->name ?? 'N/A', - $instance->updated_at->format('Y-m-d H:i:s'), + ($instance->removed_at ?? $instance->updated_at)?->format('Y-m-d H:i:s') ?? 'N/A', ]; } @@ -122,51 +117,56 @@ public function handle() if ($isDryRun) { $this->info('DRY RUN: The projects listed above would be deleted.'); - return 0; + return self::SUCCESS; } - // Confirm removal unless force flag is used if (! $force) { $confirmed = $this->confirm( - "Are you sure you want to remove these {$emptyProjects->count()} empty Lagoon project(s)?", + "Are you sure you want to remove these {$candidates->count()} empty Lagoon project(s)?", false, ); if (! $confirmed) { $this->info('Operation cancelled.'); - return 0; + return self::SUCCESS; } } - // Remove empty projects $successCount = 0; $errorCount = 0; - foreach ($emptyProjects as $instance) { - try { - $projectName = $instance->data['project_name'] ?? $instance->name; + foreach ($candidates as $instance) { + $result = $service->attemptPurge($instance); + + switch ($result) { + case PurgeResult::Purged: + case PurgeResult::AlreadyGone: + $instance->setStatus( + PolydockAppInstanceStatus::REMOVED, + $result === PurgeResult::AlreadyGone + ? 'Lagoon project already deleted (manual sweep)' + : 'Lagoon project deleted (manual sweep)', + ); + $instance->save(); + $instance->delete(); + $this->info("✓ Purged instance {$instance->id} (".($service->resolveProjectName($instance) ?? 'n/a').')'); + $successCount++; + break; - // Remove the empty project from Lagoon - $deleteResponse = $lagoonClient->deleteProjectByName($projectName); + case PurgeResult::StillHasEnvironments: + // Project picked up envs between probe and delete. Skip. + $this->warn( + "- Project for instance {$instance->id} now has environments again, skipping", + ); + break; - if (isset($deleteResponse['error'])) { + default: $this->error( - "✗ Failed to delete Lagoon project '{$projectName}': ".json_encode($deleteResponse['error']), + "✗ Failed to purge instance {$instance->id}: ".($service->lastFailureReason ?? 'unknown'), ); $errorCount++; - } else { - $this->info("✓ Successfully removed Lagoon project: {$projectName} (Instance ID: {$instance->id})"); - - // TODO: I think we might want to introduce a soft-deleted kind of process here - // ideally we'd want some record that the instance existed, surely, but not have it show up - // anywhere. - - $successCount++; - } - } catch (\Exception $e) { - $this->error("✗ Failed to remove project for instance {$instance->id}: {$e->getMessage()}"); - $errorCount++; + break; } } @@ -177,17 +177,63 @@ public function handle() $this->warn("- Failed to process: {$errorCount}"); } - return $errorCount > 0 ? 1 : 0; + return $errorCount > 0 ? self::FAILURE : self::SUCCESS; } - protected function getLogger(): PolydockAppLoggerInterface + /** + * Quick probe of the env list. + * + * Returns: + * - ['status' => 'missing'] when Lagoon no longer has this project + * - ['status' => 'empty', 'environment_count' => 0] for existing empty projects + * - ['status' => 'has_environments', 'environment_count' => N] + * - null on probe failure + */ + protected function probeEnvironments(LagoonProjectPurgeService $service, string $projectName): ?array { - // Create logger that delegates to command output methods - $logger = new class($this) implements PolydockAppLoggerInterface + try { + $data = $service->getProjectByName($projectName); + if (empty($data)) { + return ['status' => 'missing']; + } + + if (! is_array($data)) { + $this->error("Probe failed for {$projectName}: unexpected payload type"); + + return null; + } + + if (isset($data['error']) && $data['error']) { + $this->error("Probe failed for {$projectName}: ".json_encode($data['error'])); + + return null; + } + + if (! array_key_exists('environments', $data) || ! is_array($data['environments'])) { + $this->error("Probe failed for {$projectName}: missing environments in Lagoon response"); + + return null; + } + + $environmentCount = count($data['environments']); + + if ($environmentCount === 0) { + return ['status' => 'empty', 'environment_count' => 0]; + } + + return ['status' => 'has_environments', 'environment_count' => $environmentCount]; + } catch (\Throwable $e) { + $this->error("Probe failed for {$projectName}: {$e->getMessage()}"); + + return null; + } + } + + protected function makeLogger(): PolydockAppLoggerInterface + { + return new class($this) implements PolydockAppLoggerInterface { - public function __construct( - private $command, - ) {} + public function __construct(private $command) {} public function info(string $message, array $context = []): void { @@ -206,10 +252,8 @@ public function warning(string $message, array $context = []): void public function debug(string $message, array $context = []): void { - $this->command->info(sprintf('debug - %s', $message)); + $this->command->info('debug - '.$message); } }; - - return $logger; } } diff --git a/app/Filament/Admin/Resources/PolydockAppInstanceResource.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource.php index 892be426..6c1e833c 100644 --- a/app/Filament/Admin/Resources/PolydockAppInstanceResource.php +++ b/app/Filament/Admin/Resources/PolydockAppInstanceResource.php @@ -21,9 +21,12 @@ use Filament\Tables\Actions\ExportAction; use Filament\Tables\Columns\TextColumn; use Filament\Tables\Filters\SelectFilter; +use Filament\Tables\Filters\TrashedFilter; use Filament\Tables\Table; use FreedomtechHosting\PolydockApp\Attributes\PolydockAppInstanceFields; use FreedomtechHosting\PolydockApp\Enums\PolydockAppInstanceStatus; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\SoftDeletingScope; class PolydockAppInstanceResource extends Resource { @@ -65,9 +68,9 @@ public static function table(Table $table): Table ->searchable(), TextColumn::make('status') ->badge() - ->color(fn ($state) => PolydockAppInstanceStatus::from($state->value)->getColor()) - ->icon(fn ($state) => PolydockAppInstanceStatus::from($state->value)->getIcon()) - ->formatStateUsing(fn ($state) => PolydockAppInstanceStatus::from($state->value)->getLabel()) + ->color(fn ($state, $record) => $record->trashed() ? 'gray' : PolydockAppInstanceStatus::from($state->value)->getColor()) + ->icon(fn ($state, $record) => $record->trashed() ? 'heroicon-o-archive-box-x-mark' : PolydockAppInstanceStatus::from($state->value)->getIcon()) + ->formatStateUsing(fn ($state, $record) => $record->trashed() ? 'Purged' : PolydockAppInstanceStatus::from($state->value)->getLabel()) ->sortable(), TextColumn::make('is_trial') ->state(fn ($record) => $record->is_trial ? 'Yes' : 'No') @@ -102,6 +105,25 @@ public static function table(Table $table): Table ->dateTime() ->sortable() ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('removed_at') + ->dateTime() + ->label('Removed At') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('purge_eligible_at') + ->dateTime() + ->label('Purge Eligible') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('purge_attempts') + ->label('Purge Attempts') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), + TextColumn::make('force_purge_requested_at') + ->dateTime() + ->label('Force Purge Requested') + ->sortable() + ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ SelectFilter::make('status') @@ -146,6 +168,7 @@ public static function table(Table $table): Table 'remove' => 'Remove Stage', 'upgrade' => 'Upgrade Stage', 'running' => 'Running Stage', + 'purge' => 'Purge Stage', ]) ->query(function ($query, array $data) { if (! $data['value']) { @@ -159,6 +182,7 @@ public static function table(Table $table): Table 'remove' => $query->whereIn('status', PolydockAppInstance::$stageRemoveStatuses), 'upgrade' => $query->whereIn('status', PolydockAppInstance::$stageUpgradeStatuses), 'running' => $query->whereIn('status', PolydockAppInstance::$stageRunningStatuses), + 'purge' => $query->whereIn('status', PolydockAppInstance::$stagePurgeStatuses), default => null, }; }); @@ -187,6 +211,9 @@ public static function table(Table $table): Table ->searchable() ->preload() ->indicator('Store App'), + + TrashedFilter::make() + ->label('Include Purged'), ]) ->actions([ Tables\Actions\ViewAction::make(), @@ -220,10 +247,10 @@ public static function infolist(Infolist $infolist): Infolist ->schema([ TextEntry::make('status') ->badge() - ->color(fn ($state) => PolydockAppInstanceStatus::from($state->value)->getColor()) - ->icon(fn ($state) => PolydockAppInstanceStatus::from($state->value)->getIcon()) + ->color(fn ($state, $record) => $record->trashed() ? 'gray' : PolydockAppInstanceStatus::from($state->value)->getColor()) + ->icon(fn ($state, $record) => $record->trashed() ? 'heroicon-o-archive-box-x-mark' : PolydockAppInstanceStatus::from($state->value)->getIcon()) ->formatStateUsing( - fn ($state) => PolydockAppInstanceStatus::from($state->value)->getLabel(), + fn ($state, $record) => $record->trashed() ? 'Purged' : PolydockAppInstanceStatus::from($state->value)->getLabel(), ), TextEntry::make('status_message') ->label('Status Message'), @@ -427,4 +454,13 @@ public static function getPages(): array 'edit' => Pages\EditPolydockAppInstance::route('/{record}/edit'), ]; } + + #[\Override] + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } } diff --git a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/ViewPolydockAppInstance.php b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/ViewPolydockAppInstance.php index 831ddc36..b6047ac8 100644 --- a/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/ViewPolydockAppInstance.php +++ b/app/Filament/Admin/Resources/PolydockAppInstanceResource/Pages/ViewPolydockAppInstance.php @@ -3,12 +3,14 @@ namespace App\Filament\Admin\Resources\PolydockAppInstanceResource\Pages; use App\Filament\Admin\Resources\PolydockAppInstanceResource; +use App\Models\PolydockAppInstance; use App\Services\LagoonClientService; use Carbon\Carbon; use Filament\Actions; use Filament\Actions\Action; use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\Placeholder; +use Filament\Forms\Components\Toggle; use Filament\Notifications\Notification; use Filament\Resources\Pages\ViewRecord; use FreedomtechHosting\PolydockApp\Enums\PolydockAppInstanceStatus; @@ -192,6 +194,96 @@ protected function getHeaderActions(): array ->send(); } }), + Action::make('retry_failed_instance') + ->label('Retry Failed Instance') + ->icon('heroicon-o-arrow-path-rounded-square') + ->color('danger') + ->visible(function ($record): bool { + return in_array($record->status, [ + PolydockAppInstanceStatus::POLYDOCK_CLAIM_FAILED, + PolydockAppInstanceStatus::DEPLOY_FAILED, + PolydockAppInstanceStatus::POST_DEPLOY_FAILED, + PolydockAppInstanceStatus::RUNNING_UNHEALTHY, + PolydockAppInstanceStatus::RUNNING_UNRESPONSIVE, + ], true); + }) + ->requiresConfirmation() + ->modalHeading('Retry Failed Instance') + ->modalDescription('This will check the Lagoon project/environment state and take corrective action: deploy the environment if missing, trigger a new deployment if it exists, then re-queue the claim process.') + ->action(function ($record): void { + $projectName = $record->getKeyValue('lagoon-project-name'); + $environment = $record->getKeyValue('lagoon-deploy-branch') ?: 'main'; + + if (empty($projectName)) { + Notification::make() + ->title('Retry Failed') + ->danger() + ->body('Missing Lagoon project name on this instance.') + ->send(); + + return; + } + + try { + $client = app(LagoonClientService::class)->getAuthenticatedClient(); + + // Step 1: Check if the project exists + $projectExists = $client->projectExistsByName($projectName); + + if (! $projectExists) { + Notification::make() + ->title('Retry Failed') + ->danger() + ->body("Lagoon project '{$projectName}' does not exist. The instance needs to be re-created from scratch.") + ->send(); + + return; + } + + // Step 2: Check if the environment exists + $environmentExists = $client->projectEnvironmentExistsByName($projectName, $environment); + + $action = $environmentExists ? 'Re-deployment' : 'Initial deployment'; + + // Step 4: Queue the claim process + $skipReadyNotification = in_array($record->status, [ + PolydockAppInstanceStatus::RUNNING_UNHEALTHY, + PolydockAppInstanceStatus::RUNNING_UNRESPONSIVE, + ], true); + + $data = $record->data ?? []; + $data['manual_hook_rerun'] = [ + 'hook' => 'claim', + 'skip_ready_notification' => $skipReadyNotification, + 'retry_context' => [ + 'environment_existed' => $environmentExists, + 'triggered_at' => now()->toIso8601String(), + ], + ]; + + $record->data = $data; + $record->saveQuietly(); + + $record->setStatus( + PolydockAppInstanceStatus::PENDING_DEPLOY, + "Retry: {$action} queued for branch {$environment}", + )->save(); + + Notification::make() + ->title('Retry Initiated') + ->success() + ->body("{$action} queued for '{$environment}'. Instance will progress through the normal deployment flow before claim runs.") + ->send(); + + $this->refreshFormData(['status', 'status_message']); + } catch (\Throwable $e) { + Notification::make() + ->title('Retry Failed') + ->danger() + ->body($e->getMessage()) + ->send(); + } + }), Action::make('extend_trial') ->label('Extend Trial') ->icon('heroicon-o-calendar') @@ -223,6 +315,123 @@ protected function getHeaderActions(): array ->send(); } }), + Action::make('delete_instance') + ->label('Delete Instance') + ->icon('heroicon-o-trash') + ->color('danger') + ->visible(function ($record): bool { + // Hide once the instance is already being torn down or gone. + $alreadyTearingDown = array_merge( + PolydockAppInstance::$stageRemoveStatuses, + PolydockAppInstance::$stagePurgeStatuses, + ); + + return ! in_array($record->status, $alreadyTearingDown, true); + }) + ->requiresConfirmation() + ->modalHeading('Delete this app instance?') + ->modalDescription('This will start the standard removal pipeline: the Lagoon environment will be deleted first, then the Lagoon project will be fully deleted after the grace period (or immediately if you tick "Skip grace period").') + ->modalSubmitActionLabel('Delete') + ->form([ + Toggle::make('skip_grace_period') + ->label('Skip grace period and force-purge the Lagoon project as soon as the environment is gone') + ->helperText('Equivalent to clicking "Force Full Delete" the moment the instance reaches REMOVED.') + ->default(false), + ]) + ->action(function (array $data, $record): void { + $skipGrace = (bool) ($data['skip_grace_period'] ?? false); + + if ($skipGrace) { + $record->force_purge_requested_at = now(); + } + + $record->setStatus( + PolydockAppInstanceStatus::PENDING_PRE_REMOVE, + $skipGrace + ? 'Deletion requested via admin UI (force-purge)' + : 'Deletion requested via admin UI', + ); + $record->save(); + + Notification::make() + ->title('Deletion Queued') + ->success() + ->body($skipGrace + ? 'Removal pipeline started. The Lagoon project will be fully purged as soon as the environment is gone.' + : 'Removal pipeline started. The Lagoon project will be fully purged after the grace period.') + ->send(); + + $this->refreshFormData(['status', 'status_message']); + }), + Action::make('force_full_delete') + ->label('Force Full Delete (Lagoon)') + ->icon('heroicon-o-trash') + ->color('danger') + ->visible(fn ($record): bool => $record->status === PolydockAppInstanceStatus::REMOVED && ! $record->trashed()) + ->requiresConfirmation() + ->modalHeading('Force full Lagoon project deletion?') + ->modalDescription('This skips the grace period and immediately tries to delete the Lagoon project. If environments are still being torn down, this will keep retrying until they are gone or the polling cap is reached.') + ->action(function ($record): void { + $now = now(); + $record->force_purge_requested_at = $now; + $record->purge_eligible_at = $now; + $record->purge_attempts = 0; + $record->purge_failure_reason = null; + $record->purge_last_attempted_at = null; + $record->setStatus(PolydockAppInstanceStatus::PENDING_PURGE, 'Force purge requested via admin UI'); + $record->save(); + + Notification::make() + ->title('Force Purge Queued') + ->success() + ->body('A full Lagoon project deletion has been queued for this instance.') + ->send(); + + $this->refreshFormData(['status', 'status_message']); + }), + Action::make('retry_purge') + ->label('Retry Purge') + ->icon('heroicon-o-arrow-path') + ->color('warning') + ->visible(fn ($record): bool => $record->status === PolydockAppInstanceStatus::PURGE_FAILED && ! $record->trashed()) + ->requiresConfirmation() + ->modalDescription('Resets purge attempts and returns the instance to REMOVED with a fresh grace period before purge dispatch.') + ->action(function ($record): void { + $graceDays = (int) config('polydock.cleanup.purge_grace_days', 14); + $record->purge_attempts = 0; + $record->purge_failure_reason = null; + $record->purge_last_attempted_at = null; + $record->force_purge_requested_at = null; + $record->purge_eligible_at = now()->addDays($graceDays); + $record->setStatus(PolydockAppInstanceStatus::REMOVED, 'Purge retry requested via admin UI; grace period restarted'); + $record->save(); + + Notification::make() + ->title('Purge Retry Scheduled') + ->success() + ->body("The purge counters were reset and the grace period was restarted ({$graceDays} day(s)).") + ->send(); + + $this->refreshFormData(['status', 'status_message', 'purge_eligible_at', 'force_purge_requested_at']); + }), + Action::make('cancel_force_purge') + ->label('Cancel Force Delete') + ->icon('heroicon-o-x-circle') + ->color('gray') + ->visible(fn ($record): bool => $record->force_purge_requested_at !== null + && $record->status === PolydockAppInstanceStatus::REMOVED + && ! $record->trashed()) + ->requiresConfirmation() + ->action(function ($record): void { + $record->force_purge_requested_at = null; + $record->save(); + + Notification::make() + ->title('Force Purge Cancelled') + ->success() + ->body('The instance will fall back to the standard grace period.') + ->send(); + }), ]; } } diff --git a/app/Http/Controllers/Api/AuthenticatedApiController.php b/app/Http/Controllers/Api/AuthenticatedApiController.php index 03a57191..3359364a 100644 --- a/app/Http/Controllers/Api/AuthenticatedApiController.php +++ b/app/Http/Controllers/Api/AuthenticatedApiController.php @@ -139,6 +139,7 @@ public function getStoreApps(): JsonResponse 'app_status' => $app->status?->value, 'git_url' => $app->lagoon_deploy_git, 'store' => [ + 'id' => $app->store->id, 'name' => $app->store->name, 'status' => $app->store->status?->value, 'listed_in_marketplace' => $app->store->listed_in_marketplace, diff --git a/app/Jobs/ProcessPolydockAppInstanceJobs/BaseJob.php b/app/Jobs/ProcessPolydockAppInstanceJobs/BaseJob.php index f8ed775c..f2dcc090 100644 --- a/app/Jobs/ProcessPolydockAppInstanceJobs/BaseJob.php +++ b/app/Jobs/ProcessPolydockAppInstanceJobs/BaseJob.php @@ -206,6 +206,12 @@ private static function lifecycleStageOrdinal(PolydockAppInstanceStatus $status) PolydockAppInstanceStatus::REMOVED => 150, + PolydockAppInstanceStatus::PENDING_PURGE => 160, + + PolydockAppInstanceStatus::PURGE_RUNNING => 170, + + PolydockAppInstanceStatus::PURGE_FAILED => 180, + default => null, }; } diff --git a/app/Jobs/ProcessPolydockAppInstanceJobs/Purge/ProcessProjectPurgeJob.php b/app/Jobs/ProcessPolydockAppInstanceJobs/Purge/ProcessProjectPurgeJob.php new file mode 100644 index 00000000..5955c65b --- /dev/null +++ b/app/Jobs/ProcessPolydockAppInstanceJobs/Purge/ProcessProjectPurgeJob.php @@ -0,0 +1,132 @@ +polydockJobStart(); + $appInstance = $this->appInstance; + + if (! $appInstance) { + throw new \Exception('Failed to process PolydockAppInstance in '.class_basename(self::class).' - not found'); + } + + if ($appInstance->status !== PolydockAppInstanceStatus::PENDING_PURGE) { + if ($this->shouldSkipBecauseStatusAdvanced(PolydockAppInstanceStatus::PENDING_PURGE)) { + $this->polydockJobDone(); + + return; + } + + throw new PolydockAppInstanceStatusFlowException( + 'ProcessProjectPurgeJob must be in status PENDING_PURGE', + ); + } + + $appInstance->setStatus(PolydockAppInstanceStatus::PURGE_RUNNING, 'Attempting Lagoon project purge'); + $appInstance->purge_attempts = (int) ($appInstance->purge_attempts ?? 0) + 1; + $appInstance->purge_last_attempted_at = now(); + $appInstance->purge_failure_reason = null; + $appInstance->save(); + + $service = LagoonProjectPurgeService::makeWithDefaults(); + $maxAttempts = (int) config('polydock.cleanup.purge_max_poll_attempts', 144); + + try { + $result = $service->attemptPurge($appInstance); + } catch (\Throwable $e) { + // Defensive: any unexpected error is treated as a Failed result so + // we apply the same backoff/retry rules. + $service->lastFailureReason = 'Unhandled exception: '.$e->getMessage(); + $result = PurgeResult::Failed; + } + + switch ($result) { + case PurgeResult::Purged: + case PurgeResult::AlreadyGone: + $appInstance->setStatus( + PolydockAppInstanceStatus::REMOVED, + $result === PurgeResult::AlreadyGone + ? 'Lagoon project already deleted' + : 'Lagoon project deleted', + ); + $appInstance->purge_failure_reason = null; + $appInstance->save(); + // Soft-delete the row so it disappears from default queries. + $appInstance->delete(); + break; + + case PurgeResult::StillHasEnvironments: + // Drop back to REMOVED so the dispatcher will re-pick this up + // on the next polling tick. + $appInstance->purge_failure_reason = $service->lastFailureReason; + $appInstance->setStatus( + PolydockAppInstanceStatus::REMOVED, + sprintf( + 'Purge attempt %d/%d: %s', + $appInstance->purge_attempts, + $maxAttempts, + $service->lastFailureReason ?? 'still has environments', + ), + ); + $appInstance->save(); + break; + + case PurgeResult::MissingProjectName: + // Non-retryable; flag for admin attention. + $appInstance->purge_failure_reason = $service->lastFailureReason; + $appInstance->setStatus( + PolydockAppInstanceStatus::PURGE_FAILED, + $service->lastFailureReason ?? 'No Lagoon project name', + ); + $appInstance->save(); + break; + + case PurgeResult::Failed: + $appInstance->purge_failure_reason = $service->lastFailureReason; + if ($appInstance->purge_attempts >= $maxAttempts) { + $appInstance->setStatus( + PolydockAppInstanceStatus::PURGE_FAILED, + sprintf( + 'Purge failed after %d attempts: %s', + $appInstance->purge_attempts, + $service->lastFailureReason ?? 'unknown error', + ), + ); + } else { + // Retry on next dispatcher tick. + $appInstance->setStatus( + PolydockAppInstanceStatus::REMOVED, + sprintf( + 'Purge attempt %d/%d failed: %s', + $appInstance->purge_attempts, + $maxAttempts, + $service->lastFailureReason ?? 'unknown error', + ), + ); + } + $appInstance->save(); + break; + } + + $this->polydockJobDone(); + } +} diff --git a/app/Listeners/ProcessPolydockAppInstanceStatusChange.php b/app/Listeners/ProcessPolydockAppInstanceStatusChange.php index 672dd11d..9beabada 100644 --- a/app/Listeners/ProcessPolydockAppInstanceStatusChange.php +++ b/app/Listeners/ProcessPolydockAppInstanceStatusChange.php @@ -12,6 +12,7 @@ use App\Jobs\ProcessPolydockAppInstanceJobs\Deploy\PostDeployJob; use App\Jobs\ProcessPolydockAppInstanceJobs\Deploy\PreDeployJob; use App\Jobs\ProcessPolydockAppInstanceJobs\ProgressToNextStageJob; +use App\Jobs\ProcessPolydockAppInstanceJobs\Purge\ProcessProjectPurgeJob; use App\Jobs\ProcessPolydockAppInstanceJobs\Remove\PostRemoveJob; use App\Jobs\ProcessPolydockAppInstanceJobs\Remove\PreRemoveJob; use App\Jobs\ProcessPolydockAppInstanceJobs\Remove\RemoveJob; @@ -175,6 +176,14 @@ public function switchOnStatus(PolydockAppInstanceStatusChanged $event) PostRemoveJob::dispatch($event->appInstance->id) ->onQueue('polydock-app-instance-processing-remove'); break; + case PolydockAppInstanceStatus::PENDING_PURGE: + Log::info('Dispatching ProcessProjectPurgeJob', [ + 'app_instance_id' => $event->appInstance->id, + ]); + + ProcessProjectPurgeJob::dispatch($event->appInstance->id) + ->onQueue('polydock-app-instance-processing-remove'); + break; case PolydockAppInstanceStatus::PENDING_PRE_UPGRADE: Log::info('Dispatching PreUpgradeJob', [ 'app_instance_id' => $event->appInstance->id, @@ -255,6 +264,25 @@ public function switchOnStatus(PolydockAppInstanceStatusChanged $event) } } break; + case PolydockAppInstanceStatus::REMOVED: + // If force-purge was requested and this is not a retry coming back + // from the purge job (which sets purge_last_attempted_at), immediately + // transition to PENDING_PURGE. Retries are handled by the scheduled + // DispatchProjectPurgeJobsCommand which enforces backoff. + if ($event->appInstance->force_purge_requested_at !== null + && $event->appInstance->purge_last_attempted_at === null) { + Log::info('Force purge requested, immediately dispatching PENDING_PURGE', [ + 'app_instance_id' => $event->appInstance->id, + ]); + + $event->appInstance->purge_eligible_at = now(); + $event->appInstance->setStatus( + PolydockAppInstanceStatus::PENDING_PURGE, + 'Force purge: skipping grace period', + ); + $event->appInstance->save(); + } + break; default: Log::warning('No job to dispatch for status '.$event->appInstance->status->value); } diff --git a/app/Models/PolydockAppInstance.php b/app/Models/PolydockAppInstance.php index 2d2a01a1..7c6402cd 100644 --- a/app/Models/PolydockAppInstance.php +++ b/app/Models/PolydockAppInstance.php @@ -18,6 +18,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Support\Facades\Log; use Illuminate\Support\Str; @@ -42,6 +43,13 @@ * @property Carbon|null $app_one_time_login_valid_until * @property array|null $data * @property string $uuid + * @property Carbon|null $removed_at + * @property Carbon|null $purge_eligible_at + * @property Carbon|null $force_purge_requested_at + * @property int $purge_attempts + * @property Carbon|null $purge_last_attempted_at + * @property string|null $purge_failure_reason + * @property Carbon|null $deleted_at * @property Carbon|null $created_at * @property Carbon|null $updated_at * @property PolydockStoreApp $storeApp @@ -51,6 +59,7 @@ class PolydockAppInstance extends Model implements PolydockAppInstanceInterface { use HasPolydockVariables; use HasWebhookSensitiveData; + use SoftDeletes; /** * The fillable attributes for the model @@ -75,6 +84,12 @@ class PolydockAppInstance extends Model implements PolydockAppInstanceInterface 'app_url', 'app_one_time_login_url', 'app_one_time_login_valid_until', + 'removed_at', + 'purge_eligible_at', + 'force_purge_requested_at', + 'purge_attempts', + 'purge_last_attempted_at', + 'purge_failure_reason', ]; /** @@ -94,6 +109,11 @@ class PolydockAppInstance extends Model implements PolydockAppInstanceInterface 'one_day_left_email_sent' => 'boolean', 'trial_complete_email_sent' => 'boolean', 'app_one_time_login_valid_until' => 'datetime', + 'removed_at' => 'datetime', + 'purge_eligible_at' => 'datetime', + 'force_purge_requested_at' => 'datetime', + 'purge_last_attempted_at' => 'datetime', + 'purge_attempts' => 'integer', ]; /** @@ -147,6 +167,7 @@ class PolydockAppInstance extends Model implements PolydockAppInstanceInterface PolydockAppInstanceStatus::PENDING_PRE_UPGRADE, PolydockAppInstanceStatus::PENDING_UPGRADE, PolydockAppInstanceStatus::PENDING_POST_UPGRADE, + PolydockAppInstanceStatus::PENDING_PURGE, ]; public static array $completedStatuses = [ @@ -179,6 +200,7 @@ class PolydockAppInstance extends Model implements PolydockAppInstanceInterface PolydockAppInstanceStatus::UPGRADE_FAILED, PolydockAppInstanceStatus::POST_UPGRADE_FAILED, PolydockAppInstanceStatus::POLYDOCK_CLAIM_FAILED, + PolydockAppInstanceStatus::PURGE_FAILED, ]; public static array $pollingStatuses = [ @@ -228,6 +250,12 @@ class PolydockAppInstance extends Model implements PolydockAppInstanceInterface PolydockAppInstanceStatus::REMOVED, ]; + public static array $stagePurgeStatuses = [ + PolydockAppInstanceStatus::PENDING_PURGE, + PolydockAppInstanceStatus::PURGE_RUNNING, + PolydockAppInstanceStatus::PURGE_FAILED, + ]; + public static array $stageUpgradeStatuses = [ PolydockAppInstanceStatus::PENDING_PRE_UPGRADE, PolydockAppInstanceStatus::PENDING_UPGRADE, @@ -248,6 +276,29 @@ class PolydockAppInstance extends Model implements PolydockAppInstanceInterface PolydockAppInstanceStatus::RUNNING_UNRESPONSIVE, ]; + public static array $stageClaimStatuses = [ + PolydockAppInstanceStatus::PENDING_POLYDOCK_CLAIM, + PolydockAppInstanceStatus::POLYDOCK_CLAIM_RUNNING, + PolydockAppInstanceStatus::POLYDOCK_CLAIM_COMPLETED, + ]; + + /** + * Statuses indicating an unallocated instance is progressing through the + * create → deploy → claim pipeline. Used to count in-progress pool instances + * and to detect stuck instances. + * + * @return array + */ + public static function unallocatedInProgressStatuses(): array + { + return [ + PolydockAppInstanceStatus::NEW, + ...self::$stageCreateStatuses, + ...self::$stageDeployStatuses, + ...self::$stageClaimStatuses, + ]; + } + /** * Get the route key for the model. * @@ -490,6 +541,14 @@ public function setStatus(PolydockAppInstanceStatus $status, string $statusMessa 'new_status' => $status, ]); + // Stamp the grace clock the first time we land on REMOVED. + if ($status === PolydockAppInstanceStatus::REMOVED && $this->removed_at === null) { + $now = now(); + $graceDays = (int) config('polydock.cleanup.project_grace_period_days', 14); + $this->removed_at = $now; + $this->purge_eligible_at = $now->copy()->addDays($graceDays); + } + if (! empty($statusMessage)) { $this->setStatusMessage($statusMessage); } diff --git a/app/Models/PolydockStoreApp.php b/app/Models/PolydockStoreApp.php index 28e359cf..46e64508 100644 --- a/app/Models/PolydockStoreApp.php +++ b/app/Models/PolydockStoreApp.php @@ -7,6 +7,7 @@ use Carbon\Carbon; use Carbon\CarbonInterface; use FreedomtechHosting\PolydockApp\Enums\PolydockAppInstanceStatus; +use App\Models\PolydockAppInstance; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -247,12 +248,20 @@ public function getAmazeeAiBackendRegionIdExtAttribute(): ?string } /** - * Get the number of unallocated instances for this app + * Get the number of unallocated instances for this app. + * + * Only counts instances that are either claimable (RUNNING_HEALTHY_UNCLAIMED) + * or actively progressing through the create/deploy pipeline (non-failed, non-terminal). + * Stuck/failed instances are excluded so they don't block pool refill. */ public function getUnallocatedInstancesCountAttribute(): int { return $this->instances() ->whereNull('user_group_id') + ->where(function ($query) { + $query->where('status', PolydockAppInstanceStatus::RUNNING_HEALTHY_UNCLAIMED) + ->orWhereIn('status', PolydockAppInstance::unallocatedInProgressStatuses()); + }) ->count(); } diff --git a/app/Services/LagoonProjectPurgeService.php b/app/Services/LagoonProjectPurgeService.php new file mode 100644 index 00000000..e07ab941 --- /dev/null +++ b/app/Services/LagoonProjectPurgeService.php @@ -0,0 +1,243 @@ +getLagoonClient()); + } + + /** + * Resolve the Lagoon client lazily so consumers that already have a + * configured client (e.g. a long-running command) don't re-auth per call. + */ + protected function client(): Client + { + if ($this->client === null) { + $serviceProvider = new PolydockServiceProviderFTLagoon( + config('polydock.service_providers_singletons.PolydockServiceProviderFTLagoon'), + $this->logger, + ); + $this->client = $serviceProvider->getLagoonClient(); + } + + return $this->client; + } + + /** + * Fetch the raw Lagoon project payload by name. + */ + public function getProjectByName(string $projectName): mixed + { + return $this->client()->getProjectByName($projectName); + } + + /** + * Resolve the Lagoon project name for an instance the same way the rest of + * the engine does (matches RemoveEmptyProjectsCommand). + */ + public function resolveProjectName(PolydockAppInstance $instance): ?string + { + $name = $instance->data['project_name'] ?? $instance->name ?? null; + + return is_string($name) && $name !== '' ? $name : null; + } + + /** + * Make one attempt to fully delete the Lagoon project. + * + * Behavior: + * - Returns AlreadyGone if Lagoon does not know about the project. + * - Returns StillHasEnvironments if any environments are still listed. + * - Calls deleteProjectByName when the project has zero environments. + */ + public function attemptPurge(PolydockAppInstance $instance): PurgeResult + { + $this->lastFailureReason = null; + $this->lastEnvironmentCount = null; + + $projectName = $this->resolveProjectName($instance); + + if ($projectName === null) { + $this->lastFailureReason = 'No Lagoon project name on instance'; + $this->logger->warning('Cannot purge instance: no project name', [ + 'app_instance_id' => $instance->id, + ]); + + return PurgeResult::MissingProjectName; + } + + try { + $projectData = $this->getProjectByName($projectName); + } catch (Throwable $e) { + $this->lastFailureReason = 'getProjectByName threw: '.$e->getMessage(); + $this->logger->error('Failed to fetch Lagoon project for purge', [ + 'app_instance_id' => $instance->id, + 'project_name' => $projectName, + 'error' => $e->getMessage(), + ]); + + return PurgeResult::Failed; + } + + // Treat null/empty project payload as already-gone. + if (empty($projectData) || (isset($projectData['error']) && $projectData['error'])) { + // If the API reported an explicit error (other than "not found"), + // record it. Lagoon's getProjectByName returns null/empty for + // missing projects rather than an error key, so this branch is for + // genuine API failures. + if (isset($projectData['error'])) { + $this->lastFailureReason = 'Lagoon API error: '.json_encode($projectData['error']); + + return PurgeResult::Failed; + } + + $this->logger->info('Lagoon project already gone', [ + 'app_instance_id' => $instance->id, + 'project_name' => $projectName, + ]); + + return PurgeResult::AlreadyGone; + } + + if (! is_array($projectData)) { + $this->lastFailureReason = 'Lagoon project payload had unexpected type'; + $this->logger->error('Unexpected Lagoon project payload type while purging', [ + 'app_instance_id' => $instance->id, + 'project_name' => $projectName, + 'payload_type' => gettype($projectData), + ]); + + return PurgeResult::Failed; + } + + if (! array_key_exists('environments', $projectData) || ! is_array($projectData['environments'])) { + $this->lastFailureReason = 'Lagoon project payload missing environments list'; + $this->logger->error('Unexpected Lagoon project payload shape while purging', [ + 'app_instance_id' => $instance->id, + 'project_name' => $projectName, + 'response_keys' => is_array($projectData) ? array_keys($projectData) : null, + ]); + + return PurgeResult::Failed; + } + + $environments = $projectData['environments']; + $this->lastEnvironmentCount = count($environments); + + if ($this->lastEnvironmentCount > 0) { + // Actively delete each lingering environment. + $this->logger->info('Deleting lingering environments before project purge', [ + 'app_instance_id' => $instance->id, + 'project_name' => $projectName, + 'environment_count' => $this->lastEnvironmentCount, + ]); + + foreach ($environments as $env) { + $envName = $env['name'] ?? null; + if ($envName === null) { + continue; + } + + try { + $this->client()->deleteProjectEnvironmentByName($projectName, $envName); + $this->logger->info('Deleted lingering environment', [ + 'app_instance_id' => $instance->id, + 'project_name' => $projectName, + 'environment' => $envName, + ]); + } catch (Throwable $e) { + $this->logger->warning('Failed to delete lingering environment', [ + 'app_instance_id' => $instance->id, + 'project_name' => $projectName, + 'environment' => $envName, + 'error' => $e->getMessage(), + ]); + } + } + + // After issuing deletes, the environments won't be gone instantly. + // Return StillHasEnvironments so the caller retries on the next tick. + $this->lastFailureReason = sprintf( + 'Issued delete for %d environment(s); waiting for removal', + $this->lastEnvironmentCount, + ); + + return PurgeResult::StillHasEnvironments; + } + + try { + $deleteResponse = $this->client()->deleteProjectByName($projectName); + } catch (Throwable $e) { + $this->lastFailureReason = 'deleteProjectByName threw: '.$e->getMessage(); + $this->logger->error('Failed to call deleteProjectByName', [ + 'app_instance_id' => $instance->id, + 'project_name' => $projectName, + 'error' => $e->getMessage(), + ]); + + return PurgeResult::Failed; + } + + if (isset($deleteResponse['error'])) { + $this->lastFailureReason = 'Lagoon deleteProject error: '.json_encode($deleteResponse['error']); + $this->logger->error('Lagoon refused to delete project', [ + 'app_instance_id' => $instance->id, + 'project_name' => $projectName, + 'response' => $deleteResponse, + ]); + + return PurgeResult::Failed; + } + + $this->logger->info('Lagoon project successfully deleted', [ + 'app_instance_id' => $instance->id, + 'project_name' => $projectName, + ]); + + return PurgeResult::Purged; + } +} diff --git a/app/Services/PurgeResult.php b/app/Services/PurgeResult.php new file mode 100644 index 00000000..58b64e3c --- /dev/null +++ b/app/Services/PurgeResult.php @@ -0,0 +1,26 @@ +=8.4", + "php": ">=8.4.1", "psr/clock": "^1.0" }, "provide": { @@ -6707,7 +6707,7 @@ "time" ], "support": { - "source": "https://github.com/symfony/clock/tree/v8.0.8" + "source": "https://github.com/symfony/clock/tree/v8.1.0" }, "funding": [ { @@ -6727,20 +6727,20 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/console", - "version": "v7.4.11", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075" + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/ed0107e43ab452aa77ae99e005b95e56b556e075", - "reference": "ed0107e43ab452aa77ae99e005b95e56b556e075", + "url": "https://api.github.com/repos/symfony/console/zipball/85095d2573eaefaf35e40b9513a9bf09f72cd217", + "reference": "85095d2573eaefaf35e40b9513a9bf09f72cd217", "shasum": "" }, "require": { @@ -6805,7 +6805,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.11" + "source": "https://github.com/symfony/console/tree/v7.4.13" }, "funding": [ { @@ -6825,24 +6825,24 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:04:42+00:00" + "time": "2026-05-24T08:56:14+00:00" }, { "name": "symfony/css-selector", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/css-selector.git", - "reference": "3665cfade90565430909b906394c73c8739e57d0" + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/3665cfade90565430909b906394c73c8739e57d0", - "reference": "3665cfade90565430909b906394c73c8739e57d0", + "url": "https://api.github.com/repos/symfony/css-selector/zipball/dc0e2be45c9b5588c82414f02ac574b4b986abcd", + "reference": "dc0e2be45c9b5588c82414f02ac574b4b986abcd", "shasum": "" }, "require": { - "php": ">=8.4" + "php": ">=8.4.1" }, "type": "library", "autoload": { @@ -6874,7 +6874,7 @@ "description": "Converts CSS selectors to XPath expressions", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/css-selector/tree/v8.0.9" + "source": "https://github.com/symfony/css-selector/tree/v8.1.0" }, "funding": [ { @@ -6894,7 +6894,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:51:42+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/deprecation-contracts", @@ -7051,20 +7051,21 @@ }, { "name": "symfony/event-dispatcher", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/event-dispatcher.git", - "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f" + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/0c3c1a17604c4dbbec4b93fe162c538482096e1f", - "reference": "0c3c1a17604c4dbbec4b93fe162c538482096e1f", + "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f249ae3f680958b6f1f9dd76e5747cf0695b4102", + "reference": "f249ae3f680958b6f1f9dd76e5747cf0695b4102", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/event-dispatcher-contracts": "^2.5|^3" }, "conflict": { @@ -7112,7 +7113,7 @@ "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/event-dispatcher/tree/v8.0.9" + "source": "https://github.com/symfony/event-dispatcher/tree/v8.1.0" }, "funding": [ { @@ -7132,7 +7133,7 @@ "type": "tidelift" } ], - "time": "2026-04-18T13:51:42+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/event-dispatcher-contracts", @@ -7284,16 +7285,16 @@ }, { "name": "symfony/html-sanitizer", - "version": "v7.4.12", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "51cb4f68195883f7ac403abb58ecf7adc74e0f9e" + "reference": "761f6c49dfd103ee08b3cd09ece588b069e18ec9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/51cb4f68195883f7ac403abb58ecf7adc74e0f9e", - "reference": "51cb4f68195883f7ac403abb58ecf7adc74e0f9e", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/761f6c49dfd103ee08b3cd09ece588b069e18ec9", + "reference": "761f6c49dfd103ee08b3cd09ece588b069e18ec9", "shasum": "" }, "require": { @@ -7334,7 +7335,7 @@ "sanitizer" ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v7.4.12" + "source": "https://github.com/symfony/html-sanitizer/tree/v7.4.13" }, "funding": [ { @@ -7354,26 +7355,27 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-05-24T11:20:33+00:00" }, { "name": "symfony/http-client", - "version": "v8.0.9", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/http-client.git", - "reference": "537c7f164078975b800f3f1c56810791024e4c77" + "reference": "68a48e4c31f63fcd1bdff997a85a09e55efe8cdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-client/zipball/537c7f164078975b800f3f1c56810791024e4c77", - "reference": "537c7f164078975b800f3f1c56810791024e4c77", + "url": "https://api.github.com/repos/symfony/http-client/zipball/68a48e4c31f63fcd1bdff997a85a09e55efe8cdb", + "reference": "68a48e4c31f63fcd1bdff997a85a09e55efe8cdb", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "psr/log": "^1|^2|^3", - "symfony/http-client-contracts": "~3.4.4|^3.5.2", + "symfony/deprecation-contracts": "^2.5|^3.0", + "symfony/http-client-contracts": "^3.7", "symfony/service-contracts": "^2.5|^3" }, "conflict": { @@ -7389,7 +7391,7 @@ "require-dev": { "amphp/http-client": "^5.3.2", "amphp/http-tunnel": "^2.0", - "guzzlehttp/promises": "^1.4|^2.0", + "guzzlehttp/guzzle": "^7.10", "nyholm/psr7": "^1.0", "php-http/httplug": "^1.0|^2.0", "psr/http-client": "^1.0", @@ -7430,7 +7432,7 @@ "http" ], "support": { - "source": "https://github.com/symfony/http-client/tree/v8.0.9" + "source": "https://github.com/symfony/http-client/tree/v8.1.0" }, "funding": [ { @@ -7450,7 +7452,7 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/http-client-contracts", @@ -7536,16 +7538,16 @@ }, { "name": "symfony/http-foundation", - "version": "v7.4.8", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "9381209597ec66c25be154cbf2289076e64d1eab" + "reference": "bc354f47c62301e990b7874fa662326368508e2c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/9381209597ec66c25be154cbf2289076e64d1eab", - "reference": "9381209597ec66c25be154cbf2289076e64d1eab", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/bc354f47c62301e990b7874fa662326368508e2c", + "reference": "bc354f47c62301e990b7874fa662326368508e2c", "shasum": "" }, "require": { @@ -7594,7 +7596,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.8" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.13" }, "funding": [ { @@ -7614,20 +7616,20 @@ "type": "tidelift" } ], - "time": "2026-03-24T13:12:05+00:00" + "time": "2026-05-24T11:20:33+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.12", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d" + "reference": "9df847980c436451f4f51d1284491bb4356dd989" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", - "reference": "7922b53e70d2ba2027af8bb6a59d91eb3541ea4d", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/9df847980c436451f4f51d1284491bb4356dd989", + "reference": "9df847980c436451f4f51d1284491bb4356dd989", "shasum": "" }, "require": { @@ -7713,7 +7715,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.12" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.13" }, "funding": [ { @@ -7733,7 +7735,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T09:27:11+00:00" + "time": "2026-05-27T08:31:43+00:00" }, { "name": "symfony/mailer", @@ -7821,16 +7823,16 @@ }, { "name": "symfony/mime", - "version": "v7.4.12", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470" + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/b198dd66c211c97119bcaaff7c13431dbbb5e470", - "reference": "b198dd66c211c97119bcaaff7c13431dbbb5e470", + "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", "shasum": "" }, "require": { @@ -7886,7 +7888,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.12" + "source": "https://github.com/symfony/mime/tree/v7.4.13" }, "funding": [ { @@ -7906,7 +7908,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-05-23T16:22:37+00:00" }, { "name": "symfony/polyfill-ctype", @@ -7993,16 +7995,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e" + "reference": "e9247d281d694a5120554d9afaf54e070e88a603" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e", - "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/e9247d281d694a5120554d9afaf54e070e88a603", + "reference": "e9247d281d694a5120554d9afaf54e070e88a603", "shasum": "" }, "require": { @@ -8051,7 +8053,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.38.1" }, "funding": [ { @@ -8071,20 +8073,20 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:13:48+00:00" + "time": "2026-05-26T05:58:03+00:00" }, { "name": "symfony/polyfill-intl-idn", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-idn.git", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3" + "reference": "dc21118016c039a66235cf93d96b435ffb282412" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/9614ac4d8061dc257ecc64cba1b140873dce8ad3", - "reference": "9614ac4d8061dc257ecc64cba1b140873dce8ad3", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", "shasum": "" }, "require": { @@ -8138,7 +8140,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" }, "funding": [ { @@ -8158,20 +8160,20 @@ "type": "tidelift" } ], - "time": "2024-09-10T14:38:51+00:00" + "time": "2026-05-25T15:22:23+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.37.0", + "version": "v1.38.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "3833d7255cc303546435cb650316bff708a1c75c" + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c", - "reference": "3833d7255cc303546435cb650316bff708a1c75c", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", "shasum": "" }, "require": { @@ -8223,7 +8225,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" }, "funding": [ { @@ -8243,20 +8245,20 @@ "type": "tidelift" } ], - "time": "2024-09-09T11:45:10+00:00" + "time": "2026-05-25T13:48:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315" + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315", - "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/14c5439eec4ccff081ac14eca2dc57feb2a66d92", + "reference": "14c5439eec4ccff081ac14eca2dc57feb2a66d92", "shasum": "" }, "require": { @@ -8308,7 +8310,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.1" }, "funding": [ { @@ -8328,7 +8330,7 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php80", @@ -8416,16 +8418,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149" + "reference": "8339098cae28673c15cce00d80734af0453054e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/3600c2cb22399e25bb226e4a135ce91eeb2a6149", - "reference": "3600c2cb22399e25bb226e4a135ce91eeb2a6149", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", + "reference": "8339098cae28673c15cce00d80734af0453054e2", "shasum": "" }, "require": { @@ -8472,7 +8474,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1" }, "funding": [ { @@ -8492,20 +8494,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T17:25:58+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php84", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php84.git", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06" + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06", - "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06", + "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", + "reference": "f4e1dfaee5b74aba5964fe1fd4dfc7ba5e3085fa", "shasum": "" }, "require": { @@ -8552,7 +8554,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php84/tree/v1.38.1" }, "funding": [ { @@ -8572,20 +8574,20 @@ "type": "tidelift" } ], - "time": "2026-04-10T18:47:49+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php85", - "version": "v1.37.0", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php85.git", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee" + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/fcfa4973a9917cef23f2e38774da74a2b7d115ee", - "reference": "fcfa4973a9917cef23f2e38774da74a2b7d115ee", + "url": "https://api.github.com/repos/symfony/polyfill-php85/zipball/ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", + "reference": "ba2ba04f3352cfa2dcbbcb90aee13ed967f505b1", "shasum": "" }, "require": { @@ -8632,7 +8634,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php85/tree/v1.37.0" + "source": "https://github.com/symfony/polyfill-php85/tree/v1.38.1" }, "funding": [ { @@ -8652,7 +8654,7 @@ "type": "tidelift" } ], - "time": "2026-04-26T13:10:57+00:00" + "time": "2026-05-26T02:25:22+00:00" }, { "name": "symfony/polyfill-uuid", @@ -8739,16 +8741,16 @@ }, { "name": "symfony/process", - "version": "v7.4.11", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0" + "reference": "f5804be144caceb570f6747519999636b664f24c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/d9593c9efa40499eb078b81144de42cbc28a31f0", - "reference": "d9593c9efa40499eb078b81144de42cbc28a31f0", + "url": "https://api.github.com/repos/symfony/process/zipball/f5804be144caceb570f6747519999636b664f24c", + "reference": "f5804be144caceb570f6747519999636b664f24c", "shasum": "" }, "require": { @@ -8780,7 +8782,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.11" + "source": "https://github.com/symfony/process/tree/v7.4.13" }, "funding": [ { @@ -8800,20 +8802,20 @@ "type": "tidelift" } ], - "time": "2026-05-11T16:55:21+00:00" + "time": "2026-05-23T16:05:06+00:00" }, { "name": "symfony/routing", - "version": "v7.4.12", + "version": "v7.4.13", "source": { "type": "git", "url": "https://github.com/symfony/routing.git", - "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204" + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/routing/zipball/3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", - "reference": "3b04a5ec4887a8135a12ebf0f4cbc5b8fc8ee204", + "url": "https://api.github.com/repos/symfony/routing/zipball/3a162171bb008e5e0f15dce6581373a4c0e8390d", + "reference": "3a162171bb008e5e0f15dce6581373a4c0e8390d", "shasum": "" }, "require": { @@ -8865,7 +8867,7 @@ "url" ], "support": { - "source": "https://github.com/symfony/routing/tree/v7.4.12" + "source": "https://github.com/symfony/routing/tree/v7.4.13" }, "funding": [ { @@ -8885,7 +8887,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:20:23+00:00" + "time": "2026-05-24T11:20:33+00:00" }, { "name": "symfony/service-contracts", @@ -8976,20 +8978,20 @@ }, { "name": "symfony/string", - "version": "v8.0.11", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", - "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", + "url": "https://api.github.com/repos/symfony/string/zipball/afd5944f4005862d961efb85c8bbd5c523c4e3c9", + "reference": "afd5944f4005862d961efb85c8bbd5c523c4e3c9", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8", "symfony/polyfill-intl-grapheme": "^1.33", "symfony/polyfill-intl-normalizer": "^1.0", @@ -9042,7 +9044,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.11" + "source": "https://github.com/symfony/string/tree/v8.1.0" }, "funding": [ { @@ -9062,24 +9064,24 @@ "type": "tidelift" } ], - "time": "2026-05-13T12:07:53+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/translation", - "version": "v8.0.10", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/translation.git", - "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67" + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/translation/zipball/f63e9342e12646a57c91ef8a366a4f9d8e557b67", - "reference": "f63e9342e12646a57c91ef8a366a4f9d8e557b67", + "url": "https://api.github.com/repos/symfony/translation/zipball/b2bd012ca28c4acae830ee1206a5b6e35dd99693", + "reference": "b2bd012ca28c4acae830ee1206a5b6e35dd99693", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-mbstring": "^1.0", "symfony/translation-contracts": "^3.6.1" }, @@ -9135,7 +9137,7 @@ "description": "Provides tools to internationalize your application", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/translation/tree/v8.0.10" + "source": "https://github.com/symfony/translation/tree/v8.1.0" }, "funding": [ { @@ -9155,7 +9157,7 @@ "type": "tidelift" } ], - "time": "2026-05-06T11:30:54+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "symfony/translation-contracts", @@ -10078,16 +10080,16 @@ }, { "name": "larastan/larastan", - "version": "v3.9.6", + "version": "v3.10.0", "source": { "type": "git", "url": "https://github.com/larastan/larastan.git", - "reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636" + "reference": "2970f83398154178a739609c244577267c7ee8eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/larastan/larastan/zipball/9ad17e83e96b63536cb6ac39c3d40d29ff9cf636", - "reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636", + "url": "https://api.github.com/repos/larastan/larastan/zipball/2970f83398154178a739609c244577267c7ee8eb", + "reference": "2970f83398154178a739609c244577267c7ee8eb", "shasum": "" }, "require": { @@ -10101,17 +10103,17 @@ "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", "php": "^8.2", - "phpstan/phpstan": "^2.1.44" + "phpstan/phpstan": "^2.2.0" }, "require-dev": { - "doctrine/coding-standard": "^13", + "doctrine/coding-standard": "^14", "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", "mockery/mockery": "^1.6.12", "nikic/php-parser": "^5.4", "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", "phpstan/phpstan-deprecation-rules": "^2.0.1", - "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8" + "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8 || ^13.1.8" }, "suggest": { "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", @@ -10156,7 +10158,7 @@ ], "support": { "issues": "https://github.com/larastan/larastan/issues", - "source": "https://github.com/larastan/larastan/tree/v3.9.6" + "source": "https://github.com/larastan/larastan/tree/v3.10.0" }, "funding": [ { @@ -10164,20 +10166,20 @@ "type": "github" } ], - "time": "2026-04-16T10:02:43+00:00" + "time": "2026-05-28T08:00:58+00:00" }, { "name": "laravel/pail", - "version": "v1.2.6", + "version": "v1.2.7", "source": { "type": "git", "url": "https://github.com/laravel/pail.git", - "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf" + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pail/zipball/aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", - "reference": "aa71a01c309e7f66bc2ec4fb1a59291b82eb4abf", + "url": "https://api.github.com/repos/laravel/pail/zipball/2f7d27dada8effc48b8c424445a69cca7007daaa", + "reference": "2f7d27dada8effc48b8c424445a69cca7007daaa", "shasum": "" }, "require": { @@ -10244,7 +10246,7 @@ "issues": "https://github.com/laravel/pail/issues", "source": "https://github.com/laravel/pail" }, - "time": "2026-02-09T13:44:54+00:00" + "time": "2026-05-20T22:24:57+00:00" }, { "name": "laravel/pint", @@ -10316,16 +10318,16 @@ }, { "name": "laravel/sail", - "version": "v1.60.0", + "version": "v1.61.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "2a1538ed22eed4210ac1e17904235032571bd89c" + "reference": "68ef35015630fe510432e63e11e21749006df688" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/2a1538ed22eed4210ac1e17904235032571bd89c", - "reference": "2a1538ed22eed4210ac1e17904235032571bd89c", + "url": "https://api.github.com/repos/laravel/sail/zipball/68ef35015630fe510432e63e11e21749006df688", + "reference": "68ef35015630fe510432e63e11e21749006df688", "shasum": "" }, "require": { @@ -10375,7 +10377,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-05-14T17:29:51+00:00" + "time": "2026-05-23T23:33:57+00:00" }, { "name": "marc-mabe/php-enum", @@ -10939,11 +10941,11 @@ }, { "name": "phpstan/phpstan", - "version": "2.1.55", + "version": "2.2.1", "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpstan/zipball/9eaac3826ed5e9b8427350a43cac825eeca3f566", - "reference": "9eaac3826ed5e9b8427350a43cac825eeca3f566", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/dea9c8f2d25cc849391042b71e429c1a4bf82660", + "reference": "dea9c8f2d25cc849391042b71e429c1a4bf82660", "shasum": "" }, "require": { @@ -10966,6 +10968,17 @@ "license": [ "MIT" ], + "authors": [ + { + "name": "Ondřej Mirtes" + }, + { + "name": "Markus Staab" + }, + { + "name": "Vincent Langlet" + } + ], "description": "PHPStan - PHP Static Analysis Tool", "keywords": [ "dev", @@ -10988,7 +11001,7 @@ "type": "github" } ], - "time": "2026-05-18T11:57:34+00:00" + "time": "2026-05-28T14:44:12+00:00" }, { "name": "phpunit/php-code-coverage", @@ -11449,21 +11462,21 @@ }, { "name": "rector/rector", - "version": "2.4.4", + "version": "2.4.5", "source": { "type": "git", "url": "https://github.com/rectorphp/rector.git", - "reference": "4661c582a20f03df585d2e3fdc4af1b83d67a091" + "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/rectorphp/rector/zipball/4661c582a20f03df585d2e3fdc4af1b83d67a091", - "reference": "4661c582a20f03df585d2e3fdc4af1b83d67a091", + "url": "https://api.github.com/repos/rectorphp/rector/zipball/cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", + "reference": "cbd86024be5014d3c14d9f0b3f7aae8ecbffd62c", "shasum": "" }, "require": { "php": "^7.4|^8.0", - "phpstan/phpstan": "^2.1.48" + "phpstan/phpstan": "^2.1.56" }, "conflict": { "rector/rector-doctrine": "*", @@ -11497,7 +11510,7 @@ ], "support": { "issues": "https://github.com/rectorphp/rector/issues", - "source": "https://github.com/rectorphp/rector/tree/2.4.4" + "source": "https://github.com/rectorphp/rector/tree/2.4.5" }, "funding": [ { @@ -11505,7 +11518,7 @@ "type": "github" } ], - "time": "2026-05-20T19:30:21+00:00" + "time": "2026-05-26T21:03:22+00:00" }, { "name": "sebastian/cli-parser", @@ -12626,27 +12639,28 @@ }, { "name": "symfony/yaml", - "version": "v8.0.12", + "version": "v8.1.0", "source": { "type": "git", "url": "https://github.com/symfony/yaml.git", - "reference": "2a36f4b8405d41fa31799b06874dbd45c1b16c30" + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/2a36f4b8405d41fa31799b06874dbd45c1b16c30", - "reference": "2a36f4b8405d41fa31799b06874dbd45c1b16c30", + "url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0", + "reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0", "shasum": "" }, "require": { - "php": ">=8.4", + "php": ">=8.4.1", "symfony/polyfill-ctype": "^1.8" }, "conflict": { "symfony/console": "<7.4" }, "require-dev": { - "symfony/console": "^7.4|^8.0" + "symfony/console": "^7.4|^8.0", + "yaml/yaml-test-suite": "*" }, "bin": [ "Resources/bin/yaml-lint" @@ -12677,7 +12691,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.12" + "source": "https://github.com/symfony/yaml/tree/v8.1.0" }, "funding": [ { @@ -12697,7 +12711,7 @@ "type": "tidelift" } ], - "time": "2026-05-20T07:22:03+00:00" + "time": "2026-05-29T05:06:50+00:00" }, { "name": "theseer/tokenizer", diff --git a/config/polydock.php b/config/polydock.php index 3d80048d..d836a8c9 100644 --- a/config/polydock.php +++ b/config/polydock.php @@ -55,6 +55,12 @@ 'max_per_run_dispatch_one_day_left_emails' => env('POLYDOCK_MAX_PER_RUN_DISPATCH_ONE_DAY_LEFT_EMAILS', 25), 'max_per_run_dispatch_trial_complete_emails' => env('POLYDOCK_MAX_PER_RUN_DISPATCH_TRIAL_COMPLETE_EMAILS', 25), 'max_per_run_dispatch_trial_complete_stage_removal' => env('POLYDOCK_MAX_PER_RUN_DISPATCH_TRIAL_COMPLETE_STAGE_REMOVAL', 5), + 'cleanup' => [ + 'project_grace_period_days' => (int) env('POLYDOCK_PROJECT_GRACE_DAYS', 14), + 'purge_poll_interval_minutes' => (int) env('POLYDOCK_PURGE_POLL_INTERVAL', 10), + 'purge_max_poll_attempts' => (int) env('POLYDOCK_PURGE_MAX_POLLS', 144), + 'purge_max_per_run' => (int) env('POLYDOCK_PURGE_MAX_PER_RUN', 25), + ], 'redirect_landing_page_to' => env('POLYDOCK_REDIRECT_LANDING_PAGE_TO', 'https://freedomtech.hosting/'), 'register_only_captures' => env('POLYDOCK_REGISTER_ONLY_CAPTURES', false), 'register_simulate_round_robin' => env('POLYDOCK_REGISTER_SIMULATE_ROUND_ROBIN', false), diff --git a/database/migrations/2026_05_27_000001_add_purge_fields_to_polydock_app_instances_table.php b/database/migrations/2026_05_27_000001_add_purge_fields_to_polydock_app_instances_table.php new file mode 100644 index 00000000..eab1d31f --- /dev/null +++ b/database/migrations/2026_05_27_000001_add_purge_fields_to_polydock_app_instances_table.php @@ -0,0 +1,60 @@ +timestamp('removed_at')->nullable()->after('trial_complete_email_sent'); + $table->timestamp('purge_eligible_at')->nullable()->after('removed_at')->index(); + $table->timestamp('force_purge_requested_at')->nullable()->after('purge_eligible_at'); + $table->unsignedInteger('purge_attempts')->default(0)->after('force_purge_requested_at'); + $table->timestamp('purge_last_attempted_at')->nullable()->after('purge_attempts'); + $table->text('purge_failure_reason')->nullable()->after('purge_last_attempted_at'); + $table->softDeletes()->after('purge_failure_reason'); + }); + + // Backfill existing REMOVED rows so the new dispatcher can operate on them. + // Done in PHP for portability between MySQL/SQLite/Postgres. + $graceDays = (int) config('polydock.cleanup.project_grace_period_days', 14); + + DB::table('polydock_app_instances') + ->where('status', PolydockAppInstanceStatus::REMOVED->value) + ->whereNull('removed_at') + ->orderBy('id') + ->chunkById(500, function ($rows) use ($graceDays) { + foreach ($rows as $row) { + $updatedAt = $row->updated_at ? Carbon::parse($row->updated_at) : Carbon::now(); + + DB::table('polydock_app_instances') + ->where('id', $row->id) + ->update([ + 'removed_at' => $updatedAt, + 'purge_eligible_at' => $updatedAt->copy()->addDays($graceDays), + ]); + } + }); + } + + public function down(): void + { + Schema::table('polydock_app_instances', function (Blueprint $table) { + $table->dropSoftDeletes(); + $table->dropColumn([ + 'removed_at', + 'purge_eligible_at', + 'force_purge_requested_at', + 'purge_attempts', + 'purge_last_attempted_at', + 'purge_failure_reason', + ]); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 4ad1373f..35f4f3b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ }, "devDependencies": { "autoprefixer": "^10.4.20", - "axios": "^1.15.2", + "axios": "^1.16.0", "concurrently": "^9.0.1", "laravel-vite-plugin": "^1.2.0", "postcss": "^8.4.47", @@ -1065,13 +1065,13 @@ } }, "node_modules/axios": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", - "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } @@ -2248,12 +2248,12 @@ } }, "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", + "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", "license": "MIT", "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/juice": { diff --git a/package.json b/package.json index f9f59b59..1f1e027b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ }, "devDependencies": { "autoprefixer": "^10.4.20", - "axios": "^1.15.2", + "axios": "^1.16.0", "concurrently": "^9.0.1", "laravel-vite-plugin": "^1.2.0", "postcss": "^8.4.47", diff --git a/routes/console.php b/routes/console.php index 6873c0d6..ac16156a 100644 --- a/routes/console.php +++ b/routes/console.php @@ -48,3 +48,13 @@ // Schedule::command('polydock:remove-unclaimed-instances --force --limit=5') // ->hourly() // ->withoutOverlapping(); + +// ///// Mark Stuck Instances as Failed /////// +Schedule::command('polydock:mark-stuck-instances-failed --threshold=30') + ->everyFifteenMinutes() + ->withoutOverlapping(); + +// ///// Project Purge (full Lagoon project deletion after grace period) /////// +Schedule::command('polydock:dispatch-project-purge') + ->everyTenMinutes() + ->withoutOverlapping(); diff --git a/tests/Feature/Api/AuthenticatedApiTest.php b/tests/Feature/Api/AuthenticatedApiTest.php index b47b8f01..8f7195ba 100644 --- a/tests/Feature/Api/AuthenticatedApiTest.php +++ b/tests/Feature/Api/AuthenticatedApiTest.php @@ -116,6 +116,7 @@ public function test_get_store_apps_returns_formatted_data(): void 'author', // etc... 'git_url', 'store' => [ + 'id', 'name', 'status', 'listed_in_marketplace', @@ -126,6 +127,7 @@ public function test_get_store_apps_returns_formatted_data(): void $this->assertCount(1, $response->json('data')); $this->assertEquals('Test App', $response->json('data.0.name')); + $this->assertSame($this->storeApp->store->id, $response->json('data.0.store.id')); $this->assertEquals('Test Store', $response->json('data.0.store.name')); $this->assertEquals('git@github.com:example/repo.git', $response->json('data.0.git_url')); } diff --git a/tests/Feature/Console/Commands/MarkStuckInstancesFailedCommandTest.php b/tests/Feature/Console/Commands/MarkStuckInstancesFailedCommandTest.php new file mode 100644 index 00000000..d040a023 --- /dev/null +++ b/tests/Feature/Console/Commands/MarkStuckInstancesFailedCommandTest.php @@ -0,0 +1,206 @@ +uuid = 'test-'.uniqid(); + $instance->polydock_store_app_id = $storeApp->id; + $instance->name = 'test-instance'; + $instance->status = $status; + $instance->app_type = 'test_app_type'; + $instance->data = []; + $instance->saveQuietly(); + + if ($updatedAt !== null) { + // Use query to bypass model events/casts updating the timestamp + PolydockAppInstance::where('id', $instance->id) + ->update(['updated_at' => $updatedAt]); + $instance->refresh(); + } + + return $instance; + } + + private function createStoreApp(): PolydockStoreApp + { + $store = PolydockStore::factory()->create(); + + return PolydockStoreApp::factory()->create([ + 'polydock_store_id' => $store->id, + ]); + } + + public function test_marks_stuck_instances_as_failed(): void + { + $storeApp = $this->createStoreApp(); + $stuckAt = now()->subMinutes(45)->toDateTimeString(); + + $instance = $this->createInstance( + $storeApp, + PolydockAppInstanceStatus::PRE_CREATE_RUNNING, + $stuckAt, + ); + + $this->artisan('polydock:mark-stuck-instances-failed', ['--threshold' => 30]) + ->assertSuccessful(); + + $instance->refresh(); + $this->assertEquals(PolydockAppInstanceStatus::PRE_CREATE_FAILED, $instance->status); + $this->assertStringContains('Automatically marked failed', $instance->status_message); + } + + public function test_does_not_mark_instances_within_threshold(): void + { + $storeApp = $this->createStoreApp(); + $recentAt = now()->subMinutes(10)->toDateTimeString(); + + $instance = $this->createInstance( + $storeApp, + PolydockAppInstanceStatus::CREATE_RUNNING, + $recentAt, + ); + + $this->artisan('polydock:mark-stuck-instances-failed', ['--threshold' => 30]) + ->assertSuccessful(); + + $instance->refresh(); + $this->assertEquals(PolydockAppInstanceStatus::CREATE_RUNNING, $instance->status); + } + + public function test_dry_run_does_not_mutate(): void + { + $storeApp = $this->createStoreApp(); + $stuckAt = now()->subMinutes(45)->toDateTimeString(); + + $instance = $this->createInstance( + $storeApp, + PolydockAppInstanceStatus::DEPLOY_RUNNING, + $stuckAt, + ); + + $this->artisan('polydock:mark-stuck-instances-failed', [ + '--threshold' => 30, + '--dry-run' => true, + ])->assertSuccessful(); + + $instance->refresh(); + $this->assertEquals(PolydockAppInstanceStatus::DEPLOY_RUNNING, $instance->status); + } + + public function test_does_not_mark_non_intermediate_statuses(): void + { + $storeApp = $this->createStoreApp(); + $stuckAt = now()->subMinutes(45)->toDateTimeString(); + + $instance = $this->createInstance( + $storeApp, + PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED, + $stuckAt, + ); + + $this->artisan('polydock:mark-stuck-instances-failed', ['--threshold' => 30]) + ->assertSuccessful(); + + $instance->refresh(); + $this->assertEquals(PolydockAppInstanceStatus::RUNNING_HEALTHY_CLAIMED, $instance->status); + } + + /** + * @dataProvider statusTransitionProvider + */ + public function test_correct_status_transitions( + PolydockAppInstanceStatus $from, + PolydockAppInstanceStatus $expectedTo, + ): void { + $storeApp = $this->createStoreApp(); + $stuckAt = now()->subMinutes(45)->toDateTimeString(); + + $instance = $this->createInstance($storeApp, $from, $stuckAt); + + $this->artisan('polydock:mark-stuck-instances-failed', ['--threshold' => 30]) + ->assertSuccessful(); + + $instance->refresh(); + $this->assertEquals($expectedTo, $instance->status); + } + + public static function statusTransitionProvider(): array + { + return [ + 'new -> pre-create-failed' => [ + PolydockAppInstanceStatus::NEW, + PolydockAppInstanceStatus::PRE_CREATE_FAILED, + ], + 'pending-create -> create-failed' => [ + PolydockAppInstanceStatus::PENDING_CREATE, + PolydockAppInstanceStatus::CREATE_FAILED, + ], + 'post-create-running -> post-create-failed' => [ + PolydockAppInstanceStatus::POST_CREATE_RUNNING, + PolydockAppInstanceStatus::POST_CREATE_FAILED, + ], + 'deploy-running -> deploy-failed' => [ + PolydockAppInstanceStatus::DEPLOY_RUNNING, + PolydockAppInstanceStatus::DEPLOY_FAILED, + ], + 'post-deploy-completed -> post-deploy-failed' => [ + PolydockAppInstanceStatus::POST_DEPLOY_COMPLETED, + PolydockAppInstanceStatus::POST_DEPLOY_FAILED, + ], + 'polydock-claim-running -> polydock-claim-failed' => [ + PolydockAppInstanceStatus::POLYDOCK_CLAIM_RUNNING, + PolydockAppInstanceStatus::POLYDOCK_CLAIM_FAILED, + ], + ]; + } + + public function test_logs_summary_not_per_instance(): void + { + Log::spy(); + + $storeApp = $this->createStoreApp(); + $stuckAt = now()->subMinutes(45)->toDateTimeString(); + + $this->createInstance($storeApp, PolydockAppInstanceStatus::CREATE_RUNNING, $stuckAt); + $this->createInstance($storeApp, PolydockAppInstanceStatus::DEPLOY_RUNNING, $stuckAt); + + $this->artisan('polydock:mark-stuck-instances-failed', ['--threshold' => 30]) + ->assertSuccessful(); + + Log::shouldHaveReceived('warning') + ->withArgs(function (...$args) { + $message = $args[0] ?? ''; + $context = $args[1] ?? []; + + return str_contains($message, 'marked instances as failed') + && ($context['count'] ?? null) === 2; + }) + ->once(); + } + + private function assertStringContains(string $needle, ?string $haystack): void + { + $this->assertNotNull($haystack); + $this->assertTrue( + str_contains($haystack, $needle), + "Failed asserting that '{$haystack}' contains '{$needle}'" + ); + } +}