fix: Add comprehensive PR cleanup to GitLab, Bitbucket, and Gitea webhooks

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 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai 2025-12-08 17:10:39 +01:00
parent bade9186fd
commit d27070b215
5 changed files with 205 additions and 97 deletions

View file

@ -0,0 +1,175 @@
<?php
namespace App\Actions\Application;
use App\Enums\ApplicationDeploymentStatus;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationPreview;
use Lorisleiva\Actions\Concerns\AsAction;
class CleanupPreviewDeployment
{
use AsAction;
public string $jobQueue = 'high';
/**
* Clean up a PR preview deployment completely.
*
* This handles:
* 1. Cancelling active deployments for the PR (QUEUED/IN_PROGRESS CANCELLED_BY_USER)
* 2. Killing helper containers by deployment_uuid
* 3. Stopping/removing all running PR containers
* 4. Dispatching DeleteResourceJob for thorough cleanup (volumes, networks, database records)
*
* This unifies the cleanup logic from GitHub webhook handler to be used across all providers.
*/
public function handle(
Application $application,
int $pull_request_id,
?ApplicationPreview $preview = null
): array {
$result = [
'cancelled_deployments' => 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;
}
}

View file

@ -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',

View file

@ -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',

View file

@ -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,

View file

@ -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,