diff --git a/CHANGELOG.md b/CHANGELOG.md index 48e40a7ceb..74de1fec5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- Show "Not in Compose file" on the applications and databases of a service that you removed from its Docker Compose file, and do not count them in the service status. Before, each removed part showed as "Exited" and the service stayed degraded. Coolify keeps the settings of a removed part: if you add the service back under the same name, Coolify uses the part again. To remove the part, delete it in its settings. A part that a Compose error created, for example a `ports:` block at the wrong indentation, is also marked. Existing parts are marked at the next deployment or save of the service ([#9591](https://github.com/coollabsio/coolify/issues/9591)) - Fix deployments that failed with "The container name ... is already in use" when **Use build secrets** is on. To set the real `SOURCE_COMMIT`, Coolify starts the helper container a second time with the same name. Before, Coolify stopped the old helper and started the new one immediately, but Docker removes a stopped `--rm` container in the background, so the name was sometimes still in use. Coolify now removes the old helper and waits until Docker frees the name ([#9363](https://github.com/coollabsio/coolify/issues/9363)) - Fix Docker builds that failed with "BuildKit is enabled but the buildx component is missing or broken" when the application has a build-time variable `HOME` or `DOCKER_CONFIG`. Coolify loads the build-time variables before it runs `docker build` in the helper container, so the variable moved the Docker CLI away from its plugins in `/root/.docker/cli-plugins`. The Docker CLI in the helper now always uses `/root/.docker`. A `HOME` build argument keeps its value ([#9726](https://github.com/coollabsio/coolify/issues/9726)) - Keep the trailing newline of multiline environment variables, for example an SSH private key that a Dockerfile mounts as a build secret. Before, Coolify removed the edge whitespace of every value, so OpenSSH rejected the key with "error in libcrypto". A value with more than one line is now saved exactly as you enter it, in the UI and in the API. Single-line values still lose their edge whitespace. Save a variable again to add the newline that Coolify removed before ([#9438](https://github.com/coollabsio/coolify/issues/9438)) diff --git a/app/Models/Service.php b/app/Models/Service.php index 24ba035349..2b696ff1e8 100644 --- a/app/Models/Service.php +++ b/app/Models/Service.php @@ -330,6 +330,11 @@ class Service extends BaseModel $resources = $applications->concat($databases); foreach ($resources as $resource) { + // A part that is not in the compose file has no container, so it does not count. + if ($resource->is_removed_from_compose ?? false) { + continue; + } + $isExcluded = $resource->exclude_from_status || str($resource->status)->contains(':excluded'); // Filter based on excludedOnly flag diff --git a/app/Models/ServiceApplication.php b/app/Models/ServiceApplication.php index e28f09f6a2..57ecada9c8 100644 --- a/app/Models/ServiceApplication.php +++ b/app/Models/ServiceApplication.php @@ -68,6 +68,7 @@ class ServiceApplication extends BaseModel 'noindex_domains' => 'array', 'is_force_https_enabled' => 'boolean', 'is_permanent_redirect_enabled' => 'boolean', + 'is_removed_from_compose' => 'boolean', ]; } diff --git a/app/Models/ServiceDatabase.php b/app/Models/ServiceDatabase.php index 33f43561ec..b81749d319 100644 --- a/app/Models/ServiceDatabase.php +++ b/app/Models/ServiceDatabase.php @@ -34,6 +34,7 @@ class ServiceDatabase extends BaseModel protected $casts = [ 'exclude_from_status' => 'boolean', + 'is_removed_from_compose' => 'boolean', 'is_public' => 'boolean', 'is_log_drain_enabled' => 'boolean', 'is_include_timestamps' => 'boolean', diff --git a/app/Services/ServerTransfer/ServerTransferCoverage.php b/app/Services/ServerTransfer/ServerTransferCoverage.php index 47efbb767f..6558fc2edf 100644 --- a/app/Services/ServerTransfer/ServerTransferCoverage.php +++ b/app/Services/ServerTransfer/ServerTransferCoverage.php @@ -169,6 +169,8 @@ final class ServerTransferCoverage private const RUNTIME = 'Runtime state: the target sets it again when it manages the server.'; + private const FROM_COMPOSE = 'The target sets it again when it parses the Docker Compose file of the service.'; + /** * Columns of transferred models that are not in the bundle under their own name, with the reason. * Columns in ALWAYS_NOT_EXPORTED are left out of every model. @@ -266,8 +268,8 @@ final class ServerTransferCoverage Service::class => [ 'environment_id' => self::LINKED, 'server_id' => self::LINKED, 'destination_type' => self::LINKED, 'destination_id' => self::LINKED, ], - ServiceApplication::class => ['service_id' => self::LINKED], - ServiceDatabase::class => ['service_id' => self::LINKED], + ServiceApplication::class => ['service_id' => self::LINKED, 'is_removed_from_compose' => self::FROM_COMPOSE], + ServiceDatabase::class => ['service_id' => self::LINKED, 'is_removed_from_compose' => self::FROM_COMPOSE], StandalonePostgresql::class => [ 'destination_type' => self::LINKED, 'destination_id' => self::LINKED, 'environment_id' => self::LINKED, 'config_hash' => 'Calculated again when the target starts the database.', diff --git a/bootstrap/helpers/parsers.php b/bootstrap/helpers/parsers.php index 14b9638432..8aef09e78d 100644 --- a/bootstrap/helpers/parsers.php +++ b/bootstrap/helpers/parsers.php @@ -2386,6 +2386,28 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int return $topLevel; } +/** + * Marks the applications and databases of a service whose names are no longer in its compose file. + * They keep their settings, so a part that comes back under the same name is used again. + * The service status ignores marked parts, and the user can delete them. + * + * @param array|Collection $services + */ +function markServicePartsRemovedFromCompose(Service $resource, array|Collection $services): void +{ + $serviceNames = collect($services)->keys()->map(fn ($name): string => (string) $name)->all(); + if ($serviceNames === []) { + return; + } + + foreach ([$resource->applications(), $resource->databases()] as $parts) { + (clone $parts)->whereIn('name', $serviceNames)->where('is_removed_from_compose', true) + ->update(['is_removed_from_compose' => false]); + (clone $parts)->whereNotIn('name', $serviceNames)->where('is_removed_from_compose', false) + ->update(['is_removed_from_compose' => true]); + } +} + function serviceParser(Service $resource): Collection { $resource->resetComposeVolumeWarnings(); @@ -2510,6 +2532,7 @@ function serviceParser(Service $resource): Collection $savedService->save(); } } + markServicePartsRemovedFromCompose($resource, $services); foreach ($services as $serviceName => $service) { $predefinedPort = null; $magicEnvironments = collect([]); diff --git a/database/migrations/2026_11_03_192547_add_is_removed_from_compose_to_service_parts.php b/database/migrations/2026_11_03_192547_add_is_removed_from_compose_to_service_parts.php new file mode 100644 index 0000000000..0e57f94d0e --- /dev/null +++ b/database/migrations/2026_11_03_192547_add_is_removed_from_compose_to_service_parts.php @@ -0,0 +1,28 @@ +boolean('is_removed_from_compose')->default(false); + }); + Schema::table('service_databases', function (Blueprint $table) { + $table->boolean('is_removed_from_compose')->default(false); + }); + } + + public function down(): void + { + Schema::table('service_applications', function (Blueprint $table) { + $table->dropColumn('is_removed_from_compose'); + }); + Schema::table('service_databases', function (Blueprint $table) { + $table->dropColumn('is_removed_from_compose'); + }); + } +}; diff --git a/resources/views/livewire/project/service/resource-card.blade.php b/resources/views/livewire/project/service/resource-card.blade.php index 549b0c54c5..37cef570c5 100644 --- a/resources/views/livewire/project/service/resource-card.blade.php +++ b/resources/views/livewire/project/service/resource-card.blade.php @@ -37,11 +37,22 @@ {{ $resource->image }} + @if ($resource->is_removed_from_compose) + + + Not in Compose file + + @else + @endif - @if ($resource->configuration_required) + @if ($resource->is_removed_from_compose) +

+ This resource is not in the Docker Compose file. Add it back to use it again, or delete it in its settings. +

+ @elseif ($resource->configuration_required)
@@ -104,8 +115,16 @@
{{ $resource->image }}
-
- +
is_removed_from_compose) title="Not in the Docker Compose file. Add it back to use it again, or delete it in its settings." @endif> + @if ($resource->is_removed_from_compose) + + + Not in Compose file + + @else + + @endif
@php diff --git a/tests/Feature/ServiceRemovedComposeServicesTest.php b/tests/Feature/ServiceRemovedComposeServicesTest.php new file mode 100644 index 0000000000..9331912770 --- /dev/null +++ b/tests/Feature/ServiceRemovedComposeServicesTest.php @@ -0,0 +1,108 @@ +create(); + $server = Server::factory()->create(['team_id' => $team->id]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $environment = Environment::factory()->create(['project_id' => Project::factory()->create(['team_id' => $team->id])->id]); + + return Service::factory()->create([ + 'server_id' => $server->id, + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'docker_compose_raw' => $compose, + ]); +} + +function reparseWithCompose(Service $service, string $compose): Service +{ + $service->update(['docker_compose_raw' => $compose]); + $service = $service->fresh(); + $service->parse(); + + return $service->fresh(); +} + +test('parts removed from the compose file are marked and keep their settings', function () { + $service = makeServiceWithCompose(COMPOSE_WITH_EXTRA_PARTS); + $service->parse(); + $service->applications()->where('name', 'test-orphan')->update(['fqdn' => 'https://orphan.example.com']); + + $service = reparseWithCompose($service, COMPOSE_WITHOUT_EXTRA_PARTS); + + expect($service->applications()->where('is_removed_from_compose', true)->pluck('name')->all())->toBe(['test-orphan']) + ->and($service->databases()->where('is_removed_from_compose', true)->pluck('name')->all())->toBe(['old-db']) + ->and($service->applications()->where('is_removed_from_compose', false)->pluck('name')->all())->toBe(['web']) + ->and($service->databases()->where('is_removed_from_compose', false)->pluck('name')->all())->toBe(['db']) + ->and($service->applications()->where('name', 'test-orphan')->value('fqdn'))->toBe('https://orphan.example.com'); +}); + +test('a part that comes back to the compose file is used again with its settings', function () { + $service = makeServiceWithCompose(COMPOSE_WITH_EXTRA_PARTS); + $service->parse(); + $orphan = $service->applications()->where('name', 'test-orphan')->firstOrFail(); + $orphan->update(['fqdn' => 'https://orphan.example.com']); + + $service = reparseWithCompose($service, COMPOSE_WITHOUT_EXTRA_PARTS); + $service = reparseWithCompose($service, COMPOSE_WITH_EXTRA_PARTS); + + $restored = $service->applications()->where('name', 'test-orphan')->sole(); + expect($restored->id)->toBe($orphan->id) + ->and($restored->is_removed_from_compose)->toBeFalse() + ->and($restored->fqdn)->toBe('https://orphan.example.com'); +}); + +test('parts are not marked when the compose file cannot be parsed', function () { + $service = makeServiceWithCompose(COMPOSE_WITH_EXTRA_PARTS); + $service->parse(); + + $service = reparseWithCompose($service, "services:\n web:\n image: [nginx\n"); + + expect($service->applications()->where('is_removed_from_compose', true)->count())->toBe(0) + ->and($service->databases()->where('is_removed_from_compose', true)->count())->toBe(0); +}); + +test('parts removed from the compose file do not change the service status', function () { + $service = makeServiceWithCompose(COMPOSE_WITH_EXTRA_PARTS); + $service->parse(); + $service->applications()->update(['status' => 'running:healthy']); + $service->databases()->update(['status' => 'running:healthy']); + + $service = reparseWithCompose($service, COMPOSE_WITHOUT_EXTRA_PARTS); + $service->applications()->where('name', 'test-orphan')->update(['status' => 'exited']); + $service->databases()->where('name', 'old-db')->update(['status' => 'exited']); + + expect($service->fresh()->status)->toBe('running:healthy'); +});