fix(service): support metadata-only deletion on unreachable servers

Add a Coolify-only deletion path, improve Docker cleanup handling, and notify teams when service deletion fails.
This commit is contained in:
Andras Bacsai
2026-09-23 10:50:31 +02:00
parent 9c7c601154
commit 7c86e53422
11 changed files with 296 additions and 21 deletions
-1
View File
@@ -127,7 +127,6 @@ Thank you so much!
<a href="https://www.movavi.com/mac.html?utm_source=coolify.io"><img width="60px" alt="Movavi" src="https://cdn.coollabs.io/sponsors/movavi.png"/></a>
<a href="https://usefoil.com/"><img width="60px" alt="ABXY" src="https://usefoil.com/favicon.svg"/></a>
<a href="https://www.launchfa.st/?utm_source=coolify.io"><img width="60px" alt="LaunchFast Boilerplates" src="https://github.com/LaunchFast-Boilerplates.png"/></a>
<a href="https://vanaways.co.uk/?utm_source=coolify.io"><img width="60px" alt="Vanaways" src="https://github.com/Vanaways.png"/></a>
<a href="https://www.netrouting.com/?utm_source=coolify.io"><img width="60px" alt="Netrouting" src="https://github.com/netroutingcom.png"/></a>
<a href="https://github.com/mindedtech"><img width="60px" alt="MindEd Tech" src="https://github.com/mindedtech.png"/></a>
<a href="https://youstable.com/?utm_source=coolify.io"><img width="60px" alt="YouStable" src="https://github.com/youstable.png"/></a>
+33 -3
View File
@@ -3,13 +3,22 @@
namespace App\Actions\Service;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use RuntimeException;
class DeleteService
{
public function cleanupRemote(Service $service, bool $deleteVolumes, bool $deleteConnectedNetworks, bool $deleteConfigurations): void
{
$server = data_get($service, 'server');
if ($deleteVolumes && $server->isFunctional()) {
if (! $server?->isFunctional()) {
throw new RuntimeException('Server is not functional.');
}
$this->removeContainers($service);
if ($deleteVolumes) {
$commands = [];
foreach ($service->applications()->get() as $application) {
foreach ($application->persistentStorages()->get() as $storage) {
@@ -22,7 +31,7 @@ class DeleteService
}
}
foreach ($commands as $command) {
instant_remote_process([$command], $server, false);
instant_remote_process([$command], $server);
}
}
@@ -32,7 +41,28 @@ class DeleteService
if ($deleteConfigurations) {
$service->deleteConfigurations();
}
instant_remote_process(["docker rm -f $service->uuid"], $server, throwError: false);
}
public function removeSubresourceContainer(ServiceApplication|ServiceDatabase $resource): void
{
$service = $resource->service;
$server = $service?->server;
if (! $server?->isFunctional()) {
throw new RuntimeException('Server is not functional.');
}
$this->removeContainers($service, $resource->id);
}
private function removeContainers(Service $service, ?int $subresourceId = null): void
{
$filters = "--filter 'label=coolify.serviceId={$service->id}'";
if ($subresourceId !== null) {
$filters .= " --filter 'label=coolify.service.subId={$subresourceId}'";
}
$command = "container_ids=\$(docker ps -aq {$filters}); [ -z \"\$container_ids\" ] || docker rm -f \$container_ids";
instant_remote_process([$command], $service->server);
}
public function deleteLocal(Service $service): void
@@ -972,6 +972,7 @@ class ServicesController extends Controller
new OA\Parameter(name: 'delete_volumes', in: 'query', required: false, description: 'Delete volumes.', schema: new OA\Schema(type: 'boolean', default: true)),
new OA\Parameter(name: 'docker_cleanup', in: 'query', required: false, description: 'Run docker cleanup.', schema: new OA\Schema(type: 'boolean', default: true)),
new OA\Parameter(name: 'delete_connected_networks', in: 'query', required: false, description: 'Delete connected networks.', schema: new OA\Schema(type: 'boolean', default: true)),
new OA\Parameter(name: 'delete_from_coolify_only', in: 'query', required: false, description: 'Remove only Coolify metadata without deleting Docker resources.', schema: new OA\Schema(type: 'boolean', default: false)),
],
responses: [
new OA\Response(
@@ -1021,22 +1022,28 @@ class ServicesController extends Controller
$service->delete();
$deleteFromCoolifyOnly = $request->boolean('delete_from_coolify_only') || ! $service->server?->isFunctional();
DeleteResourceJob::dispatch(
resource: $service,
deleteVolumes: $request->boolean('delete_volumes', true),
deleteConnectedNetworks: $request->boolean('delete_connected_networks', true),
deleteConfigurations: $request->boolean('delete_configurations', true),
dockerCleanup: $request->boolean('docker_cleanup', true)
dockerCleanup: $request->boolean('docker_cleanup', true),
deleteFromCoolifyOnly: $deleteFromCoolifyOnly,
);
auditLog('api.service.deleted', [
'team_id' => $teamId,
'service_uuid' => $service->uuid,
'service_name' => $service->name,
'delete_from_coolify_only' => $deleteFromCoolifyOnly,
]);
return response()->json([
'message' => 'Service deletion request queued.',
'message' => $deleteFromCoolifyOnly
? 'Server is not reachable. The service will be removed from Coolify only; Docker resources may remain.'
: 'Service deletion request queued.',
]);
}
+28 -2
View File
@@ -20,6 +20,7 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Notifications\Internal\GeneralNotification;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -38,7 +39,8 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
public bool $deleteVolumes = true,
public bool $deleteConnectedNetworks = true,
public bool $deleteConfigurations = true,
public bool $dockerCleanup = true
public bool $dockerCleanup = true,
public bool $deleteFromCoolifyOnly = false,
) {
$this->onQueue('high');
}
@@ -51,6 +53,12 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
return;
}
if ($this->deleteFromCoolifyOnly && $this->resource instanceof Service) {
$this->deleteLocalResource();
return;
}
try {
switch ($this->resource->type()) {
case 'application':
@@ -89,6 +97,19 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
}
}
} catch (\Throwable $e) {
if ($this->resource instanceof Service) {
if ($this->resource->trashed()) {
$this->resource->restore();
}
$this->resource->server?->team?->notify(new GeneralNotification(
"Service deletion failed for '{$this->resource->name}'. Docker resources may still exist on server '{$this->resource->server?->name}'. You can retry the cleanup or select 'Remove from Coolify only' in the deletion dialog. Error: {$e->getMessage()}",
success: false,
));
throw $e;
}
Log::warning('Remote cleanup failed while deleting resource; continuing with local deletion.', [
'resource_id' => $this->resource->id,
'resource_type' => $this->resource->type(),
@@ -106,6 +127,12 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
]);
}
$this->deleteLocalResource();
}
private function deleteLocalResource(): void
{
DB::transaction(function (): void {
if ($this->resource instanceof Service) {
app(DeleteService::class)->deleteLocal($this->resource);
@@ -126,7 +153,6 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
$this->resource->environment_variables()->delete();
$this->resource->forceDelete();
});
}
private function isDatabase(): bool
+3
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Project\Service;
use App\Actions\Database\StartDatabaseProxy;
use App\Actions\Database\StopDatabaseProxy;
use App\Actions\Service\DeleteService;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
@@ -254,6 +255,7 @@ class Index extends Component
return 'The provided password is incorrect.';
}
app(DeleteService::class)->removeSubresourceContainer($this->serviceDatabase);
$this->serviceDatabase->delete();
$this->dispatch('success', 'Database deleted.');
@@ -501,6 +503,7 @@ class Index extends Component
return 'The provided password is incorrect.';
}
app(DeleteService::class)->removeSubresourceContainer($this->serviceApplication);
$this->serviceApplication->delete();
$this->dispatch('success', 'Application deleted.');
+27 -1
View File
@@ -111,7 +111,33 @@ class Danger extends Component
$this->delete_volumes,
$this->delete_connected_networks,
$this->delete_configurations,
$this->docker_cleanup
$this->docker_cleanup,
)->afterResponse();
return redirectRoute($this, 'project.resource.index', [
'project_uuid' => $this->projectUuid,
'environment_uuid' => $this->environmentUuid,
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function deleteFromCoolifyOnly(string $password): mixed
{
if (! verifyPasswordConfirmation($password, $this)) {
return 'The provided password is incorrect.';
}
if (! $this->resource instanceof Service) {
return 'Service not found.';
}
try {
$this->authorize('delete', $this->resource);
DeleteResourceJob::dispatch(
resource: $this->resource,
deleteFromCoolifyOnly: true,
)->afterResponse();
return redirectRoute($this, 'project.resource.index', [
@@ -15,7 +15,7 @@ class GeneralNotification extends Notification implements ShouldQueue
public $tries = 1;
public function __construct(public string $message)
public function __construct(public string $message, public bool $success = true)
{
$this->onQueue('high');
}
@@ -28,9 +28,9 @@ class GeneralNotification extends Notification implements ShouldQueue
public function toDiscord(): DiscordMessage
{
return new DiscordMessage(
title: 'Coolify: General Notification',
title: $this->success ? 'Coolify: General Notification' : 'Coolify: Action required',
description: $this->message,
color: DiscordMessage::infoColor(),
color: $this->success ? DiscordMessage::infoColor() : DiscordMessage::errorColor(),
);
}
@@ -44,8 +44,8 @@ class GeneralNotification extends Notification implements ShouldQueue
public function toPushover(): PushoverMessage
{
return new PushoverMessage(
title: 'General Notification',
level: 'info',
title: $this->success ? 'General Notification' : 'Action required',
level: $this->success ? 'info' : 'error',
message: $this->message,
);
}
@@ -53,16 +53,16 @@ class GeneralNotification extends Notification implements ShouldQueue
public function toSlack(): SlackMessage
{
return new SlackMessage(
title: 'Coolify: General Notification',
title: $this->success ? 'Coolify: General Notification' : 'Coolify: Action required',
description: $this->message,
color: SlackMessage::infoColor(),
color: $this->success ? SlackMessage::infoColor() : SlackMessage::errorColor(),
);
}
public function toWebhook(): array
{
return [
'success' => true,
'success' => $this->success,
'message' => $this->message,
'event' => 'general',
'url' => base_url(),
@@ -8,6 +8,13 @@
default => 'resource',
};
@endphp
@if ($resource instanceof \App\Models\Service && !$resource->server?->isFunctional())
<x-callout type="warning" title="Server is not reachable" class="mb-4">
Coolify cannot remove or verify Docker resources on this server. The deletion dialog will default to
removing this service from Coolify only. Its containers, volumes, networks, and configuration files may
remain on the server.
</x-callout>
@endif
<x-application.settings-section id="danger-zone-section" title="Danger zone"
helper="Destructive resource actions cannot be undone.">
<x-danger-zone title="Delete {{ $resourceLabel }}">
@@ -30,6 +37,19 @@
confirmationText="{{ $resourceName }}"
confirmationLabel="Enter the resource name to confirm permanent deletion"
shortConfirmationLabel="Resource name" />
@if ($resource instanceof \App\Models\Service)
<x-modal-confirmation title="Remove service from Coolify only?"
buttonTitle="Remove from Coolify only"
isErrorButton submitAction="deleteFromCoolifyOnly"
:actions="[
'Permanently remove this service from Coolify.',
'Leave all containers, volumes, networks, and configuration files on the server.',
]"
warningMessage="Coolify will no longer track or manage the Docker resources for this service. Use this only when normal cleanup cannot complete."
confirmationText="{{ $resourceName }}"
confirmationLabel="Enter the resource name to confirm metadata-only deletion"
shortConfirmationLabel="Resource name" />
@endif
@else
<x-forms.button isError disabled tooltip="You do not have permission to delete this resource.">
Delete {{ $resourceLabel }}
+9 -2
View File
@@ -312,13 +312,20 @@ describe('DELETE resource endpoints', function () {
'server_id' => $this->server->id,
]);
$this->withHeaders($this->headers)
$response = $this->withHeaders($this->headers)
->deleteJson("/api/v1/services/{$service->uuid}")
->assertOk();
$response->assertJson([
'message' => 'Server is not reachable. The service will be removed from Coolify only; Docker resources may remain.',
]);
expect(Service::find($service->id))->toBeNull()
->and(Service::withTrashed()->find($service->id)?->trashed())->toBeTrue();
Queue::assertPushed(DeleteResourceJob::class);
Queue::assertPushed(
DeleteResourceJob::class,
fn (DeleteResourceJob $job): bool => $job->deleteFromCoolifyOnly
);
});
test('soft deletes a database before queuing cleanup', function () {
@@ -1,18 +1,24 @@
<?php
use App\Actions\Service\DeleteService;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\ScheduledVolumeBackup;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Notifications\Internal\GeneralNotification;
use Illuminate\Foundation\Console\QueuedCommand;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
@@ -43,7 +49,7 @@ beforeEach(function () {
Queue::fake();
});
it('deletes the Coolify resource when remote cleanup fails', function () {
it('deletes a non-service Coolify resource when remote cleanup fails', function () {
Process::fake(['*' => Process::result(errorOutput: 'SSH connection timed out', exitCode: 255)]);
(new DeleteResourceJob($this->application))->handle();
@@ -52,6 +58,130 @@ it('deletes the Coolify resource when remote cleanup fails', function () {
Queue::assertNotPushed(QueuedCommand::class);
});
it('keeps a service when remote cleanup fails', function () {
Notification::fake();
$service = Service::factory()->create([
'environment_id' => $this->application->environment_id,
'server_id' => $this->application->destination->server_id,
'destination_id' => $this->application->destination_id,
'destination_type' => $this->application->destination_type,
]);
ServiceApplication::create([
'service_id' => $service->id,
'name' => 'web',
'image' => 'nginx:alpine',
]);
$service->server->team->webhookNotificationSettings()->update([
'webhook_enabled' => true,
'webhook_url' => 'https://example.com/webhook',
]);
$service->delete();
Process::fake();
expect(fn () => (new DeleteResourceJob($service))->handle())
->toThrow(RuntimeException::class, 'Server is not functional.');
expect(Service::find($service->id))->not->toBeNull()
->and(ServiceApplication::where('service_id', $service->id)->exists())->toBeTrue();
Notification::assertCount(1);
Notification::assertSentTo(
$service->team(),
GeneralNotification::class,
fn (GeneralNotification $notification): bool => ! $notification->success
&& str_contains($notification->message, 'Remove from Coolify only')
);
});
it('deletes only local service metadata when explicitly requested', function () {
$service = Service::factory()->create([
'environment_id' => $this->application->environment_id,
'server_id' => $this->application->destination->server_id,
'destination_id' => $this->application->destination_id,
'destination_type' => $this->application->destination_type,
]);
ServiceApplication::create([
'service_id' => $service->id,
'name' => 'web',
'image' => 'nginx:alpine',
]);
Process::fake();
(new DeleteResourceJob($service, deleteFromCoolifyOnly: true))->handle();
Process::assertNothingRan();
expect(Service::withTrashed()->find($service->id))->toBeNull()
->and(ServiceApplication::withTrashed()->where('service_id', $service->id)->exists())->toBeFalse();
});
it('removes service containers before its volumes and local metadata', function () {
$service = Service::factory()->create([
'environment_id' => $this->application->environment_id,
'server_id' => $this->application->destination->server_id,
'destination_id' => $this->application->destination_id,
'destination_type' => $this->application->destination_type,
]);
$application = ServiceApplication::create([
'service_id' => $service->id,
'name' => 'web',
'image' => 'nginx:alpine',
]);
$application->persistentStorages()->create([
'name' => "{$service->uuid}_web-data",
'mount_path' => '/data',
'host_path' => null,
]);
$privateKey = PrivateKey::factory()->create(['team_id' => $service->server->team_id]);
$service->server->update(['private_key_id' => $privateKey->id]);
$service->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
$commands = collect();
Process::fake(function ($process) use ($commands) {
$commands->push($process->command);
return Process::result(output: '');
});
(new DeleteResourceJob($service))->handle();
$commandList = $commands->implode("\n");
expect($commandList)
->toContain("label=coolify.serviceId={$service->id}")
->toContain('docker rm -f $container_ids')
->toContain("docker volume rm -f '{$service->uuid}_web-data'")
->and(strpos($commandList, 'docker rm -f $container_ids'))
->toBeLessThan(strpos($commandList, 'docker volume rm -f'));
expect(Service::withTrashed()->find($service->id))->toBeNull();
});
it('targets a service subresource container by its Docker labels', function () {
$service = Service::factory()->create([
'environment_id' => $this->application->environment_id,
'server_id' => $this->application->destination->server_id,
'destination_id' => $this->application->destination_id,
'destination_type' => $this->application->destination_type,
]);
$application = ServiceApplication::create([
'service_id' => $service->id,
'name' => 'web',
'image' => 'nginx:alpine',
]);
$privateKey = PrivateKey::factory()->create(['team_id' => $service->server->team_id]);
$service->server->update(['private_key_id' => $privateKey->id]);
$service->server->settings()->update(['is_reachable' => true, 'is_usable' => true]);
$commands = collect();
Process::fake(function ($process) use ($commands) {
$commands->push($process->command);
return Process::result(output: '');
});
app(DeleteService::class)->removeSubresourceContainer($application);
expect($commands->implode("\n"))
->toContain("label=coolify.serviceId={$service->id}")
->toContain("label=coolify.service.subId={$application->id}")
->toContain('docker rm -f $container_ids');
});
it('rolls back local metadata deletion when deleting the resource fails', function () {
Process::fake(['*' => Process::result(output: '')]);
$applicationUuid = $this->application->uuid;
+28 -1
View File
@@ -100,7 +100,8 @@ test('delete succeeds without password for an oauth user', function () {
->call('delete', '')
->assertHasNoErrors();
expect(Application::find($this->application->id))->toBeNull();
expect(Application::find($this->application->id))->not->toBeNull();
Queue::assertPushed(DeleteResourceJob::class, fn (DeleteResourceJob $job) => $job->resource->is($this->application));
});
test('delete applies selectedActions from checkbox state', function () {
@@ -112,3 +113,29 @@ test('delete applies selectedActions from checkbox state', function () {
expect($component->get('delete_configurations'))->toBeTrue();
expect($component->get('docker_cleanup'))->toBeTrue();
});
test('service can be removed from Coolify without remote cleanup', function () {
$service = Service::factory()->create([
'environment_id' => $this->environment->id,
'server_id' => $this->server->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
]);
Livewire::test(Danger::class, ['resource' => $service])
->set('projectUuid', $this->project->uuid)
->set('environmentUuid', $this->environment->uuid)
->assertSee('Server is not reachable')
->assertSee('Remove from Coolify only')
->call('deleteFromCoolifyOnly', 'test-password')
->assertHasNoErrors()
->assertRedirectToRoute('project.resource.index', [
'project_uuid' => $this->project->uuid,
'environment_uuid' => $this->environment->uuid,
]);
Queue::assertPushed(
DeleteResourceJob::class,
fn (DeleteResourceJob $job): bool => $job->resource->is($service) && $job->deleteFromCoolifyOnly
);
});