fix(service): remove the "Remove from Coolify only" delete option

A normal delete now removes a resource from Coolify also when its server
is not reachable, so the separate option only left untracked containers
running. The danger zone button is gone, and the services API rejects
delete_from_coolify_only with 422 instead of ignoring it, so old clients
do not delete Docker resources they asked to keep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-10-24 10:18:51 +02:00
co-authored by Claude Opus 5.5
parent 73b1ad1587
commit 6c270a42db
9 changed files with 28 additions and 123 deletions
@@ -1023,7 +1023,6 @@ 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 when the server disk usage is at or above its cleanup threshold. Skipped when a cleanup ran on the server in the last hour.', 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(
@@ -1054,6 +1053,7 @@ class ServicesController extends Controller
ref: '#/components/responses/404',
),
new OA\Response(response: 403, description: 'The token needs the write and deploy permissions.'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
]
)]
public function delete_by_uuid(Request $request)
@@ -1072,10 +1072,14 @@ class ServicesController extends Controller
$this->authorize('delete', $service);
$service->delete();
// The parameter was removed. Reject it instead of ignoring it, so old clients do not delete Docker resources they asked to keep.
if ($request->boolean('delete_from_coolify_only')) {
return response()->json([
'message' => 'The delete_from_coolify_only parameter is no longer supported. Nothing was deleted. Send the request without it: Coolify removes the service also when the server is not reachable.',
], 422);
}
$deleteFromCoolifyOnlyRequested = $request->boolean('delete_from_coolify_only');
$deleteFromCoolifyOnly = $deleteFromCoolifyOnlyRequested || ! $service->server?->isFunctional();
$service->delete();
DeleteResourceJob::dispatch(
resource: $service,
@@ -1083,23 +1087,15 @@ class ServicesController extends Controller
deleteConnectedNetworks: $request->boolean('delete_connected_networks', true),
deleteConfigurations: $request->boolean('delete_configurations', 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,
]);
$message = match (true) {
$deleteFromCoolifyOnlyRequested => 'The service will be removed from Coolify only; Docker resources will remain.',
$deleteFromCoolifyOnly => 'Server is not reachable. The service will be removed from Coolify only; Docker resources may remain.',
default => 'Service deletion request queued.',
};
return response()->json(['message' => $message]);
return response()->json(['message' => 'Service deletion request queued.']);
}
#[OA\Patch(
-26
View File
@@ -132,32 +132,6 @@ class Danger extends Component
}
}
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', [
'project_uuid' => $this->projectUuid,
'environment_uuid' => $this->environmentUuid,
]);
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function flush($password)
{
try {
+1 -1
View File
@@ -17,7 +17,7 @@ class ManageService extends RestActionTool
{
return [
'update' => ['method' => 'PATCH', 'path' => 'services/{uuid}', 'ability' => 'write', 'description' => 'update the service (data: name, description, docker_compose_raw base64, connect_to_docker_network, urls, force_domain_override, is_container_label_escape_enabled, redact_all_env_values_in_logs, instant_deploy; instant_deploy needs deploy ability)'],
'delete' => ['method' => 'DELETE', 'path' => 'services/{uuid}', 'ability' => 'deploy', 'confirm' => true, 'description' => 'delete the service (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup, delete_from_coolify_only)'],
'delete' => ['method' => 'DELETE', 'path' => 'services/{uuid}', 'ability' => 'deploy', 'confirm' => true, 'description' => 'delete the service (data: delete_volumes, delete_connected_networks, delete_configurations, docker_cleanup)'],
'clone' => ['method' => 'POST', 'path' => 'services/{uuid}/clone', 'ability' => 'write', 'confirm' => true, 'description' => 'clone the service to a destination of the team (data: destination_uuid, name, clone_volumes; clone_volumes stops the source service while volumes are copied and needs deploy ability)'],
'move' => ['method' => 'POST', 'path' => 'services/{uuid}/move', 'ability' => 'write', 'confirm' => true, 'description' => 'move the service to another environment of the team (data: environment_uuid)'],
'update_application' => ['method' => 'PATCH', 'path' => 'services/{uuid}/applications/{app_uuid}', 'ability' => 'write', 'description' => 'update a service application (data: url, noindex_domains, human_name, description, image, exclude_from_status, is_log_drain_enabled, is_gzip_enabled, traefik_certificate_resolver, is_stripprefix_enabled, is_force_https_enabled, max_restart_count)'],
+3 -10
View File
@@ -20486,16 +20486,6 @@
"type": "boolean",
"default": true
}
},
{
"name": "delete_from_coolify_only",
"in": "query",
"description": "Remove only Coolify metadata without deleting Docker resources.",
"required": false,
"schema": {
"type": "boolean",
"default": false
}
}
],
"responses": {
@@ -20526,6 +20516,9 @@
},
"403": {
"description": "The token needs the write and deploy permissions."
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
+2 -8
View File
@@ -12946,14 +12946,6 @@ paths:
schema:
type: boolean
default: true
-
name: delete_from_coolify_only
in: query
description: 'Remove only Coolify metadata without deleting Docker resources.'
required: false
schema:
type: boolean
default: false
responses:
'200':
description: 'Delete a service by UUID'
@@ -12971,6 +12963,8 @@ paths:
$ref: '#/components/responses/404'
'403':
description: 'The token needs the write and deploy permissions.'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
@@ -54,19 +54,6 @@
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 }}
+2 -2
View File
@@ -332,14 +332,14 @@ describe('DELETE resource endpoints', function () {
->assertOk();
$response->assertJson([
'message' => 'Server is not reachable. The service will be removed from Coolify only; Docker resources may remain.',
'message' => 'Service deletion request queued.',
]);
expect(Service::find($service->id))->toBeNull()
->and(Service::withTrashed()->find($service->id)?->trashed())->toBeTrue();
Queue::assertPushed(
DeleteResourceJob::class,
fn (DeleteResourceJob $job): bool => $job->deleteFromCoolifyOnly
fn (DeleteResourceJob $job): bool => $job->resource->is($service) && ! $job->deleteFromCoolifyOnly
);
});
@@ -50,22 +50,17 @@ function setServiceDeleteServerReachable(Server $server, bool $reachable): void
Server::flushIdentityMap();
}
test('explicit delete from coolify only does not claim the server is unreachable', function (bool $serverReachable) {
setServiceDeleteServerReachable($this->server, $serverReachable);
test('delete from coolify only is rejected and deletes nothing', function () {
$this->withToken($this->bearerToken)
->deleteJson("/api/v1/services/{$this->service->uuid}?delete_from_coolify_only=true")
->assertOk()
->assertJsonPath('message', 'The service will be removed from Coolify only; Docker resources will remain.');
->assertUnprocessable();
Queue::assertPushed(DeleteResourceJob::class, fn (DeleteResourceJob $job): bool => $job->deleteFromCoolifyOnly);
})->with([
'reachable server' => [true],
'unreachable server' => [false],
]);
expect(Service::find($this->service->id))->not->toBeNull();
Queue::assertNotPushed(DeleteResourceJob::class);
});
test('delete on a reachable server queues a full deletion', function () {
setServiceDeleteServerReachable($this->server, true);
test('delete queues a full deletion whether the server is reachable or not', function (bool $serverReachable) {
setServiceDeleteServerReachable($this->server, $serverReachable);
$this->withToken($this->bearerToken)
->deleteJson("/api/v1/services/{$this->service->uuid}")
@@ -73,15 +68,7 @@ test('delete on a reachable server queues a full deletion', function () {
->assertJsonPath('message', 'Service deletion request queued.');
Queue::assertPushed(DeleteResourceJob::class, fn (DeleteResourceJob $job): bool => ! $job->deleteFromCoolifyOnly);
});
test('delete on an unreachable server falls back to removing from coolify only', function () {
setServiceDeleteServerReachable($this->server, false);
$this->withToken($this->bearerToken)
->deleteJson("/api/v1/services/{$this->service->uuid}")
->assertOk()
->assertJsonPath('message', 'Server is not reachable. The service will be removed from Coolify only; Docker resources may remain.');
Queue::assertPushed(DeleteResourceJob::class, fn (DeleteResourceJob $job): bool => $job->deleteFromCoolifyOnly);
});
})->with([
'reachable server' => [true],
'unreachable server' => [false],
]);
@@ -149,29 +149,3 @@ 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
);
});