fix(service): mark parts that are no longer in the Compose file

Closes #9591

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-11-03 20:34:37 +01:00
co-authored by Claude Opus 5.5
parent d6405d5545
commit e29dcf70d9
9 changed files with 193 additions and 5 deletions
+1
View File
@@ -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))
+5
View File
@@ -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
+1
View File
@@ -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',
];
}
+1
View File
@@ -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',
@@ -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.',
+23
View File
@@ -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<string|int, mixed>|Collection<string|int, mixed> $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([]);
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('service_applications', function (Blueprint $table) {
$table->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');
});
}
};
@@ -37,11 +37,22 @@
{{ $resource->image }}
</div>
</div>
@if ($resource->is_removed_from_compose)
<span class="inline-flex h-6 max-w-full items-center gap-1.5 whitespace-nowrap rounded-full border border-red-200 bg-red-50 px-2 text-xs font-medium leading-none text-red-700 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300">
<span class="size-1.5 shrink-0 rounded-full bg-red-500"></span>
<span class="truncate">Not in Compose file</span>
</span>
@else
<x-status-badge :status="$statusLabel" :type="$statusType" />
@endif
<x-application.restart-limit-warning :application="$resource" />
</div>
@if ($resource->configuration_required)
@if ($resource->is_removed_from_compose)
<p class="mt-2 text-xs leading-5 text-neutral-500 dark:text-fg-dim">
This resource is not in the Docker Compose file. Add it back to use it again, or delete it in its settings.
</p>
@elseif ($resource->configuration_required)
<div class="mt-2">
<x-status-badge status="Configuration required" type="warning" />
</div>
@@ -104,8 +115,16 @@
<div class="truncate font-mono text-xs text-neutral-500 dark:text-fg-faint">
{{ $resource->image }}
</div>
<div class="flex flex-wrap items-center gap-1">
<x-status-badge :status="$statusLabel" :type="$statusType" />
<div class="flex flex-wrap items-center gap-1"
@if ($resource->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)
<span class="inline-flex h-6 max-w-full items-center gap-1.5 whitespace-nowrap rounded-full border border-red-200 bg-red-50 px-2 text-xs font-medium leading-none text-red-700 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-300">
<span class="size-1.5 shrink-0 rounded-full bg-red-500"></span>
<span class="truncate">Not in Compose file</span>
</span>
@else
<x-status-badge :status="$statusLabel" :type="$statusType" />
@endif
<x-application.restart-limit-warning :application="$resource" />
</div>
@php
@@ -0,0 +1,108 @@
<?php
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
const COMPOSE_WITH_EXTRA_PARTS = <<<'YAML'
services:
web:
image: nginx:alpine
test-orphan:
image: alpine:latest
db:
image: postgres:16
old-db:
image: postgres:16
YAML;
const COMPOSE_WITHOUT_EXTRA_PARTS = <<<'YAML'
services:
web:
image: nginx:alpine
db:
image: postgres:16
YAML;
function makeServiceWithCompose(string $compose): Service
{
$team = Team::factory()->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');
});