diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d36a19140..59d8ff5a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), - Keep the dashboard proxy configuration of a server with the IP `host.docker.internal` that is not the default localhost server. Before, Coolify did not see this server as localhost after it saved the server, for example after a proxy restart, and deleted `coolify.yaml`, so the dashboard was not reachable at its domain ([#12444](https://github.com/coollabsio/coolify/issues/12444)) - Show the disk usage of a server on the **Metrics** tab, on the **Fleet** page, and in the MCP `get_server_metrics` tool when Sentinel reports the Coolify data disk under a different mount point than `/`, `/data`, or `/data/coolify`. For example, this occurs on a NAS where `/data` is a symlink into a data volume. Before, the disk usage was always empty on these servers ([#12448](https://github.com/coollabsio/coolify/issues/12448)) - Pin the Usesend service template to `usesend/usesend:v1.10.0`, which has the Next.js, Node.js 24, and nodemailer security updates. Before, the template used `latest`, so a server kept an old cached image until you pulled the images again. The template does not use MinIO, so the MinIO-to-RustFS change of v1.10.0 does not affect it ([#12443](https://github.com/coollabsio/coolify/issues/12443)) +- Keep an application that uses a server only as an additional destination when you delete that server with **Delete all resources** or with `DELETE /api/v1/servers/{uuid}?force=true`. Before, Coolify deleted the application on all its servers, also after **Promote** made another server its main server. Coolify now removes the application only from the deleted server and stops its containers there. A server that has only such applications can be deleted without **Delete all resources**. The delete confirmation lists the resources that it deletes and the applications that keep running on another server ## [v4.6.0] - 2026-11-02 diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index 899a1ea2f9..60ccf064a2 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -946,7 +946,7 @@ class ServersController extends Controller new OA\Parameter( name: 'force', in: 'query', - description: 'Also delete all resources on the server. Needs the deploy permission.', + description: 'Also delete all resources whose main destination is on the server. Applications that use the server only as an additional destination are not deleted. They are removed from the server and keep running on their other servers. Needs the deploy permission.', required: false, schema: new OA\Schema(type: 'boolean', default: false) ), @@ -1026,7 +1026,8 @@ class ServersController extends Controller return response()->json(['message' => 'Server deleted.']); } - if ($server->definedResources()->count() > 0 && ! $force) { + $resources = $server->primaryResources(); + if ($resources->isNotEmpty() && ! $force) { return response()->json(['message' => 'Server has resources. Use ?force=true to delete all resources and the server, or delete resources manually first.'], 400); } if ($server->is_coolify_host) { @@ -1053,8 +1054,10 @@ class ServersController extends Controller return $response; } + // Applications that use the server only as an additional destination keep running on their other servers. + $server->detachAdditionalApplications(); if ($force) { - foreach ($server->definedResources() as $resource) { + foreach ($resources as $resource) { DeleteResourceJob::dispatch($resource); } } diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index ce9313a9f9..1a224b676f 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -64,20 +64,19 @@ class Delete extends Component return; } - if ($this->server->hasDefinedResources() && ! $this->force_delete_resources) { + $resources = $this->server->primaryResources(); + if ($resources->isNotEmpty() && ! $this->force_delete_resources) { $this->dispatch('error', 'Server has defined resources. Please delete them first or select "Delete all resources".'); return; } - $resources = $this->server->definedResources(); if ($isArchive) { - $claimer = app(ServerTransferClaimer::class); - $claimer->forgetDnsRecords($this->server); - // Applications that use the server only as an additional destination stay; they lose this server. - $claimer->detachAdditionalApplications($this->server); - $resources = $claimer->archiveResources($this->server); + app(ServerTransferClaimer::class)->forgetDnsRecords($this->server); } + // Applications that use the server only as an additional destination stay; they lose this server. + // Another instance manages a transferred server, so the containers stay as they are there. + $this->server->detachAdditionalApplications(stopContainers: ! $isArchive); if ($this->force_delete_resources) { foreach ($resources as $resource) { DeleteResourceJob::dispatch($resource, deleteFromCoolifyOnly: $isArchive); @@ -109,11 +108,13 @@ class Delete extends Component { $checkboxes = []; - if ($this->server->hasDefinedResources() && ! $this->server->isTransferredAway()) { - $resourceCount = $this->server->definedResources()->count(); + $resourcesToDelete = $this->server->primaryResources(); + $detachedApplications = $this->server->additionalApplications(); + + if ($resourcesToDelete->isNotEmpty() && ! $this->server->isTransferredAway()) { $checkboxes[] = [ 'id' => 'force_delete_resources', - 'label' => "Delete all resources ({$resourceCount} total)", + 'label' => "Delete all resources ({$resourcesToDelete->count()} total): ".$resourcesToDelete->pluck('name')->implode(', '), 'default_warning' => 'Server cannot be deleted while it has resources.', ]; } @@ -154,6 +155,8 @@ class Delete extends Component return view('livewire.server.delete', [ 'checkboxes' => $checkboxes, + 'resourcesToDelete' => $resourcesToDelete, + 'detachedApplications' => $detachedApplications, ]); } } diff --git a/app/Models/Server.php b/app/Models/Server.php index 9ce8903655..5129fe5ad0 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Actions\Application\StopApplicationOneServer; use App\Actions\Proxy\StartProxy; use App\Actions\Server\InstallDocker; use App\Actions\Server\InstallPrerequisites; @@ -32,6 +33,7 @@ use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\MorphMany; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Http\Exceptions\ThrottleRequestsException; @@ -1751,6 +1753,47 @@ $siteAddress { return $applications->concat($databases)->concat($services->get()); } + /** + * The resources that deleting this server with "Delete all resources" deletes. An application that has this server + * only as an additional destination runs on another server, so it is detached instead. + * + * @return Collection + */ + public function primaryResources(): Collection + { + return $this->primaryApplications() + ->concat($this->databases()) + ->concat($this->services()->get()); + } + + /** + * Remove this server from the applications that use it only as an additional destination. + * The applications keep running on their other servers. + * + * @param bool $stopContainers stop and remove the containers of the applications on this server (best effort) + */ + public function detachAdditionalApplications(bool $stopContainers = true): void + { + $applications = $this->additionalApplications(); + if ($stopContainers) { + foreach ($applications as $application) { + try { + StopApplicationOneServer::run($application, $this); + } catch (\Throwable $e) { + logger()->warning('Could not stop the containers of an application on a deleted server.', [ + 'application_id' => $application->id, + 'server_id' => $this->id, + 'error' => $e->getMessage(), + ]); + } + } + } + DB::table('additional_destinations') + ->where('server_id', $this->id) + ->whereIn('application_id', $applications->pluck('id')) + ->delete(); + } + public function stopUnmanaged($id) { return instant_remote_process([dockerStopCommand(0, escapeshellarg($id), $this)], $this); @@ -1841,18 +1884,6 @@ $siteAddress { } } - public function hasDefinedResources() - { - $applications = $this->applications()->count() > 0; - $databases = $this->databases()->count() > 0; - $services = $this->services()->count() > 0; - if ($applications || $databases || $services) { - return true; - } - - return false; - } - public function databases() { // Get destination IDs for this server in two efficient queries @@ -1892,22 +1923,13 @@ $siteAddress { ->reject(fn ($item) => $item instanceof StandalonePostgresql && $item->isInstanceDatabase()); } + /** + * The applications whose primary destination is on this server, plus the applications that use this server as an + * additional destination. + */ public function applications() { - // Get destination IDs for this server in two efficient queries - $standaloneDockerIds = StandaloneDocker::where('server_id', $this->id)->pluck('id'); - $swarmDockerIds = SwarmDocker::where('server_id', $this->id)->pluck('id'); - - // Query all applications in a single query using polymorphic conditions - $applications = Application::where(function ($query) use ($standaloneDockerIds, $swarmDockerIds) { - $query->where(function ($q) use ($standaloneDockerIds) { - $q->where('destination_type', StandaloneDocker::class) - ->whereIn('destination_id', $standaloneDockerIds); - })->orWhere(function ($q) use ($swarmDockerIds) { - $q->where('destination_type', SwarmDocker::class) - ->whereIn('destination_id', $swarmDockerIds); - }); - })->get(); + $applications = $this->primaryApplications(); // Get additional server applications $additionalApplicationIds = DB::table('additional_destinations') @@ -1922,6 +1944,45 @@ $siteAddress { return $applications; } + /** + * The applications whose primary destination is on this server. + * + * @return Collection + */ + public function primaryApplications(): Collection + { + // Get destination IDs for this server in two efficient queries + $standaloneDockerIds = StandaloneDocker::where('server_id', $this->id)->pluck('id'); + $swarmDockerIds = SwarmDocker::where('server_id', $this->id)->pluck('id'); + + // Query all applications in a single query using polymorphic conditions + return Application::where(function ($query) use ($standaloneDockerIds, $swarmDockerIds) { + $query->where(function ($q) use ($standaloneDockerIds) { + $q->where('destination_type', StandaloneDocker::class) + ->whereIn('destination_id', $standaloneDockerIds); + })->orWhere(function ($q) use ($swarmDockerIds) { + $q->where('destination_type', SwarmDocker::class) + ->whereIn('destination_id', $swarmDockerIds); + }); + })->get(); + } + + /** + * The applications that use this server only as an additional destination. Their primary destination is on + * another server. + * + * @return Collection + */ + public function additionalApplications(): Collection + { + $applicationIds = DB::table('additional_destinations') + ->where('server_id', $this->id) + ->whereNotIn('application_id', $this->primaryApplications()->pluck('id')) + ->pluck('application_id'); + + return Application::query()->with('destination.server')->whereIn('id', $applicationIds)->get(); + } + public function dockerComposeBasedApplications() { return $this->applications()->filter(function ($application) { diff --git a/app/Services/ServerTransfer/ServerTransferClaimer.php b/app/Services/ServerTransfer/ServerTransferClaimer.php index 9a3862f7df..63e397065e 100644 --- a/app/Services/ServerTransfer/ServerTransferClaimer.php +++ b/app/Services/ServerTransfer/ServerTransferClaimer.php @@ -4,7 +4,6 @@ namespace App\Services\ServerTransfer; use App\Enums\GithubRunnerStatus; use App\Jobs\DeleteResourceJob; -use App\Models\Application; use App\Models\GithubRunnerExecution; use App\Models\ManagedDnsRecord; use App\Models\Server; @@ -185,49 +184,14 @@ class ServerTransferClaimer } $this->forgetDnsRecords($server); - $this->detachAdditionalApplications($server); - foreach ($this->archiveResources($server) as $resource) { + // Another instance manages the server, so the containers of its additional applications stay as they are. + $server->detachAdditionalApplications(stopContainers: false); + foreach ($server->primaryResources() as $resource) { (new DeleteResourceJob($resource, deleteFromCoolifyOnly: true))->handle(); } $server->forceDelete(); } - /** - * The resources that the archive of a transferred server deletes. An application that has the server only as an - * additional destination runs on another server of this instance, so it is not one of them. - * - * @return Collection - */ - public function archiveResources(Server $server): Collection - { - return $this->primaryApplications($server) - ->concat($server->databases()) - ->concat($server->services()->get()); - } - - /** - * Remove the server from applications that run on it only as an additional destination. - */ - public function detachAdditionalApplications(Server $server): void - { - $primaryIds = $this->primaryApplications($server)->pluck('id'); - DB::table('additional_destinations') - ->where('server_id', $server->id) - ->whereNotIn('application_id', $primaryIds) - ->delete(); - } - - /** - * @return Collection - */ - private function primaryApplications(Server $server): Collection - { - return $server->applications() - ->filter(fn (Application $application) => (int) data_get($application, 'destination.server_id') === (int) $server->id) - ->unique('id') - ->values(); - } - /** * @return Collection */ @@ -235,7 +199,7 @@ class ServerTransferClaimer { // The server itself owns the DNS record of its Cloudflare tunnel. return collect([$server]) - ->concat($this->primaryApplications($server)) + ->concat($server->primaryApplications()) ->concat($server->services()->with('applications')->get()->flatMap->applications); } diff --git a/openapi.json b/openapi.json index a0f2ce37dc..de82427496 100644 --- a/openapi.json +++ b/openapi.json @@ -19974,7 +19974,7 @@ { "name": "force", "in": "query", - "description": "Also delete all resources on the server. Needs the deploy permission.", + "description": "Also delete all resources whose main destination is on the server. Applications that use the server only as an additional destination are not deleted. They are removed from the server and keep running on their other servers. Needs the deploy permission.", "required": false, "schema": { "type": "boolean", diff --git a/openapi.yaml b/openapi.yaml index 2c03255fa2..4b4f0f2d87 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -12620,7 +12620,7 @@ paths: - name: force in: query - description: 'Also delete all resources on the server. Needs the deploy permission.' + description: 'Also delete all resources whose main destination is on the server. Applications that use the server only as an additional destination are not deleted. They are removed from the server and keep running on their other servers. Needs the deploy permission.' required: false schema: type: boolean diff --git a/resources/views/livewire/server/delete.blade.php b/resources/views/livewire/server/delete.blade.php index 5c13c0b9fd..f80a29e1cc 100644 --- a/resources/views/livewire/server/delete.blade.php +++ b/resources/views/livewire/server/delete.blade.php @@ -21,22 +21,47 @@ This server was transferred to another Coolify instance. Only the records on this instance are deleted, also the records of its resources. The server, its containers, and its data stay as they are. - @elseif ($server->definedResources()->count() > 0) + @elseif ($resourcesToDelete->isNotEmpty()) It currently contains managed resources. Enable force deletion in the confirmation only if those resources should also be removed. @endif +

+ @if (! $server->isTransferredAway() && $resourcesToDelete->isNotEmpty()) +
+

Deleted with "Delete all resources":

+
    + @foreach ($resourcesToDelete as $resource) +
  • {{ $resource->name }}
  • + @endforeach +
+
+ @endif + @if ($detachedApplications->isNotEmpty()) +
+

Removed only from this server (not deleted):

+
    + @foreach ($detachedApplications as $application) +
  • {{ $application->name }} will keep running on {{ data_get($application, 'destination.server.name') }}
  • + @endforeach +
+
+ @endif @if ($server->hostinger_virtual_machine_id && ! $server->isTransferredAway()) +

Hostinger deletes the VPS only at the end of its paid period, and only if you select this in the confirmation dialog. Manage the VPS and billing in hPanel. +

@endif -

Type the server name in the confirmation dialog to continue.

@php $deleteActions = $server->isTransferredAway() ? ['This server and the records of its resources will be permanently deleted from this Coolify instance. Nothing on the server changes.'] : ['This server will be permanently deleted from Coolify.']; + foreach ($detachedApplications as $application) { + $deleteActions[] = "Application {$application->name} will be removed from this server and will keep running on ".data_get($application, 'destination.server.name').'.'; + } $deleteWarningMessage = null; if ($server->isTransferredAway()) { $deleteWarningMessage = 'This removes the option to undo the transfer.
' diff --git a/tests/Feature/ServerDeleteAdditionalDestinationTest.php b/tests/Feature/ServerDeleteAdditionalDestinationTest.php new file mode 100644 index 0000000000..521d5a2a44 --- /dev/null +++ b/tests/Feature/ServerDeleteAdditionalDestinationTest.php @@ -0,0 +1,142 @@ + 'array']); + InstanceSettings::forceCreate(['id' => 0, 'is_api_enabled' => true]); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]); + $this->serverToDelete = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id, 'name' => 'worker-1', 'ip' => '203.0.113.51']); + $this->destinationToDelete = StandaloneDocker::where('server_id', $this->serverToDelete->id)->firstOrFail(); + $this->otherServer = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $privateKey->id, 'name' => 'worker-2', 'ip' => '203.0.113.52']); + $this->otherDestination = StandaloneDocker::where('server_id', $this->otherServer->id)->firstOrFail(); + + $project = Project::factory()->create(['team_id' => $this->team->id]); + $this->environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->makeApplication = fn (StandaloneDocker $destination, string $name): Application => Application::factory()->create([ + 'name' => $name, + 'environment_id' => $this->environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + ]); +}); + +afterEach(function () { + Server::flushIdentityMap(); +}); + +function deleteServer(object $test, string $path, bool $force): void +{ + if ($path === 'api') { + $token = $test->user->createToken('server-delete', ['write', 'deploy', 'delete'])->plainTextToken; + $test->withHeaders(['Authorization' => 'Bearer '.$token]) + ->deleteJson("/api/v1/servers/{$test->serverToDelete->uuid}".($force ? '?force=true' : '')) + ->assertOk(); + + return; + } + + $test->actingAs($test->user); + Livewire::test(Delete::class, ['server_uuid' => $test->serverToDelete->uuid]) + ->call('delete', 'password', $force ? ['force_delete_resources'] : []) + ->assertHasNoErrors() + ->assertNotDispatched('error'); +} + +function additionalDestinationsOf(Application $application, Server $server): int +{ + return DB::table('additional_destinations') + ->where('application_id', $application->id) + ->where('server_id', $server->id) + ->count(); +} + +test('deleting a server with force deletes its own applications and only detaches applications of other servers', function (string $path) { + $additional = ($this->makeApplication)($this->otherDestination, 'ref-admin'); + $additional->additional_networks()->attach($this->destinationToDelete->id, ['server_id' => $this->serverToDelete->id]); + $primary = ($this->makeApplication)($this->destinationToDelete, 'own-app'); + + StopApplicationOneServer::shouldRun()->once() + ->withArgs(fn (Application $application, Server $server) => $application->is($additional) && $server->is($this->serverToDelete)); + + deleteServer($this, $path, force: true); + + expect(Server::withTrashed()->find($this->serverToDelete->id)->trashed())->toBeTrue() + ->and(Application::find($additional->id))->not->toBeNull() + ->and(additionalDestinationsOf($additional, $this->serverToDelete))->toBe(0); + + Queue::assertPushed(DeleteResourceJob::class, 1); + Queue::assertPushed(DeleteResourceJob::class, fn (DeleteResourceJob $job) => $job->resource->is($primary)); +})->with(['api', 'ui']); + +test('a server with only additional-destination applications is deleted without force and detaches them', function (string $path) { + $additional = ($this->makeApplication)($this->otherDestination, 'ref-admin'); + $additional->additional_networks()->attach($this->destinationToDelete->id, ['server_id' => $this->serverToDelete->id]); + StopApplicationOneServer::shouldRun()->once(); + + deleteServer($this, $path, force: false); + + expect(Server::withTrashed()->find($this->serverToDelete->id)->trashed())->toBeTrue() + ->and(Application::find($additional->id))->not->toBeNull() + ->and(additionalDestinationsOf($additional, $this->serverToDelete))->toBe(0); + Queue::assertNotPushed(DeleteResourceJob::class); +})->with(['api', 'ui']); + +test('an application promoted to another server survives the deletion of its old server', function () { + $application = ($this->makeApplication)($this->destinationToDelete, 'ref-admin'); + $application->additional_networks()->attach($this->otherDestination->id, ['server_id' => $this->otherServer->id]); + StopApplicationOneServer::shouldRun()->once(); + + $this->actingAs($this->user); + Livewire::test(Destination::class, ['resource' => $application]) + ->call('promote', $this->otherDestination->id, $this->otherServer->id); + expect(additionalDestinationsOf($application, $this->serverToDelete))->toBe(1); + + deleteServer($this, 'ui', force: true); + + $application = Application::find($application->id); + expect($application)->not->toBeNull() + ->and($application->destination_id)->toBe($this->otherDestination->id) + ->and(additionalDestinationsOf($application, $this->serverToDelete))->toBe(0); + Queue::assertNotPushed(DeleteResourceJob::class); +}); + +test('the delete confirmation lists the resources to delete and the applications to detach', function () { + $additional = ($this->makeApplication)($this->otherDestination, 'ref-admin'); + $additional->additional_networks()->attach($this->destinationToDelete->id, ['server_id' => $this->serverToDelete->id]); + ($this->makeApplication)($this->destinationToDelete, 'own-app'); + + $this->actingAs($this->user); + $component = Livewire::test(Delete::class, ['server_uuid' => $this->serverToDelete->uuid]); + + expect($component->viewData('checkboxes')[0]['label'])->toBe('Delete all resources (1 total): own-app') + ->and($component->viewData('detachedApplications')->pluck('name')->all())->toBe(['ref-admin']); + $component->assertSee('ref-admin will keep running on worker-2'); +});