From 9e8fb36bc819a59ed5100b230ebf3aa2ca35433c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 16 Sep 2025 13:40:51 +0200 Subject: [PATCH] feat(deployment): implement cancellation checks during deployment process to enhance user control and prevent unnecessary execution --- app/Jobs/ApplicationDeploymentJob.php | 30 ++++++++++++++- .../Project/Application/DeploymentNavbar.php | 37 +++++++++++++++++-- app/Traits/ExecuteRemoteCommand.php | 23 +++++++++++- 3 files changed, 84 insertions(+), 6 deletions(-) diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index e0e0f519e..207164ec0 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -250,6 +250,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue public function handle(): void { + // Check if deployment was cancelled before we even started + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + $this->application_deployment_queue->addLogEntry('Deployment was cancelled before starting.'); + + return; + } + $this->application_deployment_queue->update([ 'status' => ApplicationDeploymentStatus::IN_PROGRESS->value, 'horizon_job_worker' => gethostname(), @@ -1146,6 +1154,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function rolling_update() { + $this->checkForCancellation(); if ($this->server->isSwarm()) { $this->application_deployment_queue->addLogEntry('Rolling update started.'); $this->execute_remote_command( @@ -1342,6 +1351,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function prepare_builder_image() { + $this->checkForCancellation(); $settings = instanceSettings(); $helperImage = config('constants.coolify.helper_image'); $helperImage = "{$helperImage}:{$settings->helper_version}"; @@ -1813,6 +1823,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue private function generate_compose_file() { + $this->checkForCancellation(); $this->create_workdir(); $ports = $this->application->main_port(); $persistent_storages = $this->generate_local_persistent_volumes(); @@ -2546,8 +2557,23 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); throw new RuntimeException('Post-deployment command: Could not find a valid container. Is the container name correct?'); } + /** + * Check if the deployment was cancelled and abort if it was + */ + private function checkForCancellation(): void + { + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.'); + throw new \RuntimeException('Deployment cancelled by user', 69420); + } + } + private function next(string $status) { + // Refresh to get latest status + $this->application_deployment_queue->refresh(); + // Never allow changing status from FAILED or CANCELLED_BY_USER to anything else if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::FAILED->value) { $this->application->environment->project->team?->notify(new DeploymentFailed($this->application, $this->deployment_uuid, $this->preview)); @@ -2555,7 +2581,9 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); return; } if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { - return; + // Job was cancelled, stop execution + $this->application_deployment_queue->addLogEntry('Deployment cancelled by user, stopping execution.'); + throw new \RuntimeException('Deployment cancelled by user', 69420); } $this->application_deployment_queue->update([ diff --git a/app/Livewire/Project/Application/DeploymentNavbar.php b/app/Livewire/Project/Application/DeploymentNavbar.php index 66f387fcf..dccd1e499 100644 --- a/app/Livewire/Project/Application/DeploymentNavbar.php +++ b/app/Livewire/Project/Application/DeploymentNavbar.php @@ -52,15 +52,24 @@ class DeploymentNavbar extends Component public function cancel() { - $kill_command = "docker rm -f {$this->application_deployment_queue->deployment_uuid}"; + $deployment_uuid = $this->application_deployment_queue->deployment_uuid; + $kill_command = "docker rm -f {$deployment_uuid}"; $build_server_id = $this->application_deployment_queue->build_server_id ?? $this->application->destination->server_id; $server_id = $this->application_deployment_queue->server_id ?? $this->application->destination->server_id; + + // First, mark the deployment as cancelled to prevent further processing + $this->application_deployment_queue->update([ + 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, + ]); + try { if ($this->application->settings->is_build_server_enabled) { $server = Server::ownedByCurrentTeam()->find($build_server_id); } else { $server = Server::ownedByCurrentTeam()->find($server_id); } + + // Add cancellation log entry if ($this->application_deployment_queue->logs) { $previous_logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR); @@ -77,13 +86,35 @@ class DeploymentNavbar extends Component 'logs' => json_encode($previous_logs, flags: JSON_THROW_ON_ERROR), ]); } - instant_remote_process([$kill_command], $server); + + // Try to stop the helper container if it exists + // Check if container exists first + $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; + $containerExists = instant_remote_process([$checkCommand], $server); + + if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { + // Container exists, kill it + instant_remote_process([$kill_command], $server); + } else { + // Container hasn't started yet + $this->application_deployment_queue->addLogEntry('Helper container not yet started. Deployment will be cancelled when job checks status.'); + } + + // Also try to kill any running process if we have a process ID + if ($this->application_deployment_queue->current_process_id) { + try { + $processKillCommand = "kill -9 {$this->application_deployment_queue->current_process_id}"; + instant_remote_process([$processKillCommand], $server); + } catch (\Throwable $e) { + // Process might already be gone, that's ok + } + } } catch (\Throwable $e) { + // Still mark as cancelled even if cleanup fails return handleError($e, $this); } finally { $this->application_deployment_queue->update([ 'current_process_id' => null, - 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, ]); next_after_cancel($server); } diff --git a/app/Traits/ExecuteRemoteCommand.php b/app/Traits/ExecuteRemoteCommand.php index 0e7961368..0c3414efe 100644 --- a/app/Traits/ExecuteRemoteCommand.php +++ b/app/Traits/ExecuteRemoteCommand.php @@ -46,6 +46,14 @@ trait ExecuteRemoteCommand } } + // Check for cancellation before executing commands + if (isset($this->application_deployment_queue)) { + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + throw new \RuntimeException('Deployment cancelled by user', 69420); + } + } + $maxRetries = config('constants.ssh.max_retries'); $attempt = 0; $lastError = null; @@ -73,6 +81,12 @@ trait ExecuteRemoteCommand // Add log entry for the retry if (isset($this->application_deployment_queue)) { $this->addRetryLogEntry($attempt, $maxRetries, $delay, $errorMessage); + + // Check for cancellation during retry wait + $this->application_deployment_queue->refresh(); + if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) { + throw new \RuntimeException('Deployment cancelled by user during retry', 69420); + } } sleep($delay); @@ -85,6 +99,11 @@ trait ExecuteRemoteCommand // If we exhausted all retries and still failed if (! $commandExecuted && $lastError) { + // Now we can set the status to FAILED since all retries have been exhausted + if (isset($this->application_deployment_queue)) { + $this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value; + $this->application_deployment_queue->save(); + } throw $lastError; } }); @@ -160,8 +179,8 @@ trait ExecuteRemoteCommand $process_result = $process->wait(); if ($process_result->exitCode() !== 0) { if (! $ignore_errors) { - $this->application_deployment_queue->status = ApplicationDeploymentStatus::FAILED->value; - $this->application_deployment_queue->save(); + // Don't immediately set to FAILED - let the retry logic handle it + // This prevents premature status changes during retryable SSH errors throw new \RuntimeException($process_result->errorOutput()); } }