From d27070b215fa96edf0fcae2650e6a2b6dd9e11a2 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 8 Dec 2025 17:10:39 +0100 Subject: [PATCH] fix: Add comprehensive PR cleanup to GitLab, Bitbucket, and Gitea webhooks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create a shared CleanupPreviewDeployment action that unifies PR cleanup logic across all Git providers. Previously, GitHub had comprehensive cleanup (cancels active deployments, kills helper containers, removes all PR containers), while GitLab, Bitbucket, and Gitea only did basic cleanup (delete preview record and remove one container by name). This fix ensures all providers properly clean up orphaned PR containers when a PR is closed/merged, preventing security issues and resource waste. Also fixes early return bug in GitLab webhook handler. Fixes #2610 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../Application/CleanupPreviewDeployment.php | 175 ++++++++++++++++++ app/Http/Controllers/Webhook/Bitbucket.php | 8 +- app/Http/Controllers/Webhook/Gitea.php | 8 +- app/Http/Controllers/Webhook/Github.php | 88 +-------- app/Http/Controllers/Webhook/Gitlab.php | 23 +-- 5 files changed, 205 insertions(+), 97 deletions(-) create mode 100644 app/Actions/Application/CleanupPreviewDeployment.php diff --git a/app/Actions/Application/CleanupPreviewDeployment.php b/app/Actions/Application/CleanupPreviewDeployment.php new file mode 100644 index 000000000..83f729959 --- /dev/null +++ b/app/Actions/Application/CleanupPreviewDeployment.php @@ -0,0 +1,175 @@ + 0, + 'killed_containers' => 0, + 'status' => 'success', + ]; + + $server = $application->destination->server; + + if (! $server->isFunctional()) { + return [ + ...$result, + 'status' => 'failed', + 'message' => 'Server is not functional', + ]; + } + + // Step 1: Cancel all active deployments for this PR and kill helper containers + $result['cancelled_deployments'] = $this->cancelActiveDeployments( + $application, + $pull_request_id, + $server + ); + + // Step 2: Stop and remove all running PR containers + $result['killed_containers'] = $this->stopRunningContainers( + $application, + $pull_request_id, + $server + ); + + // Step 3: Find or use provided preview, then dispatch cleanup job for thorough cleanup + if (! $preview) { + $preview = ApplicationPreview::where('application_id', $application->id) + ->where('pull_request_id', $pull_request_id) + ->first(); + } + + if ($preview) { + DeleteResourceJob::dispatch($preview); + } + + return $result; + } + + /** + * Cancel all active (QUEUED/IN_PROGRESS) deployments for this PR. + */ + private function cancelActiveDeployments( + Application $application, + int $pull_request_id, + $server + ): int { + $activeDeployments = ApplicationDeploymentQueue::where('application_id', $application->id) + ->where('pull_request_id', $pull_request_id) + ->whereIn('status', [ + ApplicationDeploymentStatus::QUEUED->value, + ApplicationDeploymentStatus::IN_PROGRESS->value, + ]) + ->get(); + + $cancelled = 0; + foreach ($activeDeployments as $deployment) { + try { + // Mark deployment as cancelled + $deployment->update([ + 'status' => ApplicationDeploymentStatus::CANCELLED_BY_USER->value, + ]); + + // Add cancellation log entry + $deployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr'); + + // Try to kill helper container if it exists + $this->killHelperContainer($deployment->deployment_uuid, $server); + $cancelled++; + } catch (\Throwable $e) { + \Log::warning("Failed to cancel deployment {$deployment->id}: {$e->getMessage()}"); + } + } + + return $cancelled; + } + + /** + * Kill the helper container used during deployment. + */ + private function killHelperContainer(string $deployment_uuid, $server): void + { + try { + $escapedUuid = escapeshellarg($deployment_uuid); + $checkCommand = "docker ps -a --filter name={$escapedUuid} --format '{{.Names}}'"; + $containerExists = instant_remote_process([$checkCommand], $server); + + if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { + instant_remote_process(["docker rm -f {$escapedUuid}"], $server); + } + } catch (\Throwable $e) { + // Silently handle - container may already be gone + } + } + + /** + * Stop and remove all running containers for this PR. + */ + private function stopRunningContainers( + Application $application, + int $pull_request_id, + $server + ): int { + $killed = 0; + + try { + if ($server->isSwarm()) { + $escapedStackName = escapeshellarg("{$application->uuid}-{$pull_request_id}"); + instant_remote_process(["docker stack rm {$escapedStackName}"], $server); + $killed++; + } else { + $containers = getCurrentApplicationContainerStatus( + $server, + $application->id, + $pull_request_id + ); + + if ($containers->isNotEmpty()) { + foreach ($containers as $container) { + $containerName = data_get($container, 'Names'); + if ($containerName) { + instant_remote_process( + ["docker rm -f $containerName"], + $server + ); + $killed++; + } + } + } + } + } catch (\Throwable $e) { + \Log::warning("Error stopping containers for PR #{$pull_request_id}: {$e->getMessage()}"); + } + + return $killed; + } +} diff --git a/app/Http/Controllers/Webhook/Bitbucket.php b/app/Http/Controllers/Webhook/Bitbucket.php index 2f228119d..d322452d3 100644 --- a/app/Http/Controllers/Webhook/Bitbucket.php +++ b/app/Http/Controllers/Webhook/Bitbucket.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Webhook; +use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Models\Application; use App\Models\ApplicationPreview; @@ -167,9 +168,10 @@ public function manual(Request $request) if ($x_bitbucket_event === 'pullrequest:rejected' || $x_bitbucket_event === 'pullrequest:fulfilled') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - $found->delete(); - $container_name = generateApplicationContainerName($application, $pull_request_id); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); + // Use comprehensive cleanup that cancels active deployments, + // kills helper containers, and removes all PR containers + CleanupPreviewDeployment::run($application, $pull_request_id, $found); + $return_payloads->push([ 'application' => $application->name, 'status' => 'success', diff --git a/app/Http/Controllers/Webhook/Gitea.php b/app/Http/Controllers/Webhook/Gitea.php index e41825aba..f85d14089 100644 --- a/app/Http/Controllers/Webhook/Gitea.php +++ b/app/Http/Controllers/Webhook/Gitea.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Webhook; +use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Models\Application; use App\Models\ApplicationPreview; @@ -192,9 +193,10 @@ public function manual(Request $request) if ($action === 'closed') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - $found->delete(); - $container_name = generateApplicationContainerName($application, $pull_request_id); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); + // Use comprehensive cleanup that cancels active deployments, + // kills helper containers, and removes all PR containers + CleanupPreviewDeployment::run($application, $pull_request_id, $found); + $return_payloads->push([ 'application' => $application->name, 'status' => 'success', diff --git a/app/Http/Controllers/Webhook/Github.php b/app/Http/Controllers/Webhook/Github.php index 2402b71ae..93f225773 100644 --- a/app/Http/Controllers/Webhook/Github.php +++ b/app/Http/Controllers/Webhook/Github.php @@ -2,10 +2,10 @@ namespace App\Http\Controllers\Webhook; +use App\Actions\Application\CleanupPreviewDeployment; use App\Enums\ProcessStatus; use App\Http\Controllers\Controller; use App\Jobs\ApplicationPullRequestUpdateJob; -use App\Jobs\DeleteResourceJob; use App\Jobs\GithubAppPermissionJob; use App\Models\Application; use App\Models\ApplicationPreview; @@ -221,41 +221,10 @@ public function manual(Request $request) if ($action === 'closed') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - // Cancel any active deployments for this PR immediately - $activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id) - ->where('pull_request_id', $pull_request_id) - ->whereIn('status', [ - \App\Enums\ApplicationDeploymentStatus::QUEUED->value, - \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value, - ]) - ->first(); + // Use comprehensive cleanup that cancels active deployments, + // kills helper containers, and removes all PR containers + CleanupPreviewDeployment::run($application, $pull_request_id, $found); - if ($activeDeployment) { - try { - // Mark deployment as cancelled - $activeDeployment->update([ - 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value, - ]); - - // Add cancellation log entry - $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr'); - - // Check if helper container exists and kill it - $deployment_uuid = $activeDeployment->deployment_uuid; - $server = $application->destination->server; - $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; - $containerExists = instant_remote_process([$checkCommand], $server); - - if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { - instant_remote_process(["docker rm -f {$deployment_uuid}"], $server); - $activeDeployment->addLogEntry('Deployment container stopped.'); - } - } catch (\Throwable $e) { - // Silently handle errors during deployment cancellation - } - } - - DeleteResourceJob::dispatch($found); $return_payloads->push([ 'application' => $application->name, 'status' => 'success', @@ -466,53 +435,12 @@ public function normal(Request $request) if ($action === 'closed' || $action === 'close') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - // Cancel any active deployments for this PR immediately - $activeDeployment = \App\Models\ApplicationDeploymentQueue::where('application_id', $application->id) - ->where('pull_request_id', $pull_request_id) - ->whereIn('status', [ - \App\Enums\ApplicationDeploymentStatus::QUEUED->value, - \App\Enums\ApplicationDeploymentStatus::IN_PROGRESS->value, - ]) - ->first(); - - if ($activeDeployment) { - try { - // Mark deployment as cancelled - $activeDeployment->update([ - 'status' => \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value, - ]); - - // Add cancellation log entry - $activeDeployment->addLogEntry('Deployment cancelled: Pull request closed.', 'stderr'); - - // Check if helper container exists and kill it - $deployment_uuid = $activeDeployment->deployment_uuid; - $server = $application->destination->server; - $checkCommand = "docker ps -a --filter name={$deployment_uuid} --format '{{.Names}}'"; - $containerExists = instant_remote_process([$checkCommand], $server); - - if ($containerExists && str($containerExists)->trim()->isNotEmpty()) { - instant_remote_process(["docker rm -f {$deployment_uuid}"], $server); - $activeDeployment->addLogEntry('Deployment container stopped.'); - } - - } catch (\Throwable $e) { - // Silently handle errors during deployment cancellation - } - } - - // Clean up any deployed containers - $containers = getCurrentApplicationContainerStatus($application->destination->server, $application->id, $pull_request_id); - if ($containers->isNotEmpty()) { - $containers->each(function ($container) use ($application) { - $container_name = data_get($container, 'Names'); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); - }); - } - + // Delete the PR comment on GitHub (GitHub-specific feature) ApplicationPullRequestUpdateJob::dispatchSync(application: $application, preview: $found, status: ProcessStatus::CLOSED); - DeleteResourceJob::dispatch($found); + // Use comprehensive cleanup that cancels active deployments, + // kills helper containers, and removes all PR containers + CleanupPreviewDeployment::run($application, $pull_request_id, $found); $return_payloads->push([ 'application' => $application->name, diff --git a/app/Http/Controllers/Webhook/Gitlab.php b/app/Http/Controllers/Webhook/Gitlab.php index 56a9c0d1b..9062d2875 100644 --- a/app/Http/Controllers/Webhook/Gitlab.php +++ b/app/Http/Controllers/Webhook/Gitlab.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Webhook; +use App\Actions\Application\CleanupPreviewDeployment; use App\Http\Controllers\Controller; use App\Models\Application; use App\Models\ApplicationPreview; @@ -224,22 +225,22 @@ public function manual(Request $request) } elseif ($action === 'closed' || $action === 'close' || $action === 'merge') { $found = ApplicationPreview::where('application_id', $application->id)->where('pull_request_id', $pull_request_id)->first(); if ($found) { - $found->delete(); - $container_name = generateApplicationContainerName($application, $pull_request_id); - instant_remote_process(["docker rm -f $container_name"], $application->destination->server); + // Use comprehensive cleanup that cancels active deployments, + // kills helper containers, and removes all PR containers + CleanupPreviewDeployment::run($application, $pull_request_id, $found); + $return_payloads->push([ 'application' => $application->name, 'status' => 'success', - 'message' => 'Preview Deployment closed', + 'message' => 'Preview deployment closed.', + ]); + } else { + $return_payloads->push([ + 'application' => $application->name, + 'status' => 'failed', + 'message' => 'No preview deployment found.', ]); - - return response($return_payloads); } - $return_payloads->push([ - 'application' => $application->name, - 'status' => 'failed', - 'message' => 'No Preview Deployment found', - ]); } else { $return_payloads->push([ 'application' => $application->name,