fix(docker): replace deprecated stop flags with version-aware ones

Store each server's Docker version and build stop commands via
dockerStopCommand() so newer Docker uses --timeout instead of
deprecated --time/-t. Also show Docker version in server details.
This commit is contained in:
Andras Bacsai committed 2026-08-13 11:17:44 +02:00
1 parent 6481ffffcf
commit e79230cf37
32 files changed
+697 -64

No files matched your search

+112
View File
@@ -0,0 +1,112 @@
<?php
use App\Jobs\ServerConnectionCheckJob;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
function createServerForDockerVersionStorageTest(): Server
{
$team = Team::factory()->create();
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
return Server::factory()->create([
'team_id' => $team->id,
'private_key_id' => $privateKey->id,
'ip' => '203.0.113.10',
]);
}
beforeEach(function () {
Storage::fake('ssh-keys');
Carbon::setTestNow('2026-08-13 12:00:00');
});
afterEach(function () {
Carbon::setTestNow();
});
it('has docker and compose version columns on server settings', function () {
expect(Schema::hasColumns('server_settings', [
'docker_version',
'docker_version_checked_at',
'compose_version',
'compose_version_checked_at',
]))->toBeTrue();
});
it('stores a parsed docker version on the server settings', function () {
$server = createServerForDockerVersionStorageTest();
$server->rememberDockerVersion('29.4.3-ce');
$settings = $server->settings->fresh();
expect($settings->docker_version)->toBe('29.4.3')
->and($settings->docker_version_checked_at?->equalTo(Carbon::parse('2026-08-13 12:00:00')))->toBeTrue()
->and($server->dockerVersion())->toBe('29.4.3');
});
it('updates the stored docker version when the engine changes', function () {
$server = createServerForDockerVersionStorageTest();
$server->rememberDockerVersion('27.5.1');
Carbon::setTestNow('2026-08-13 13:00:00');
$server->rememberDockerVersion('29.4.3');
$settings = $server->settings->fresh();
expect($settings->docker_version)->toBe('29.4.3')
->and($settings->docker_version_checked_at?->equalTo(Carbon::parse('2026-08-13 13:00:00')))->toBeTrue();
});
it('builds stop commands from the stored server docker version', function (?string $storedVersion, string $expectedCommand) {
$server = createServerForDockerVersionStorageTest();
if ($storedVersion !== null) {
$server->rememberDockerVersion($storedVersion);
}
expect(dockerStopCommand(30, 'app-1', $server->fresh()))->toBe($expectedCommand);
})->with([
'unset' => [null, 'docker stop --time=30 app-1'],
'docker 28+' => ['29.4.3', 'docker stop --timeout=30 app-1'],
'docker 27' => ['27.5.1', 'docker stop --time=30 app-1'],
]);
it('stores a parsed compose version on the server settings', function () {
$server = createServerForDockerVersionStorageTest();
$server->rememberComposeVersion('v2.32.4');
$settings = $server->settings->fresh();
expect($settings->compose_version)->toBe('2.32.4')
->and($settings->compose_version_checked_at?->equalTo(Carbon::parse('2026-08-13 12:00:00')))->toBeTrue()
->and($server->composeVersion())->toBe('2.32.4');
});
it('records docker and compose versions during connection checks', function () {
$server = createServerForDockerVersionStorageTest();
Process::fake([
'*compose*' => Process::result(output: 'v2.32.4', exitCode: 0),
'*' => Process::result(
output: '{"Server":{"Version":"29.4.3"}}',
exitCode: 0,
),
]);
(new ServerConnectionCheckJob($server, disableMux: false))->handle();
$settings = $server->settings->fresh();
expect($settings->docker_version)->toBe('29.4.3')
->and($settings->compose_version)->toBe('2.32.4');
});
@@ -0,0 +1,184 @@
<?php
use App\Livewire\Server\Show;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create(['id' => 0]));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->user->teams()->attach($this->team, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->team]);
$this->privateKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
]);
$this->server = Server::factory()->create([
'id' => 0,
'uuid' => 'localhost',
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
'name' => 'localhost',
'ip' => 'host.docker.internal',
'user' => 'root',
'port' => 22,
]);
});
function localhostMetadataProcessOutput(): string
{
return implode("\n", [
'---PRETTY_NAME---',
'Ubuntu 22.04.3 LTS',
'---ARCH---',
'x86_64',
'---KERNEL---',
'5.15.0-91-generic',
'---CPUS---',
'4',
'---MEMORY---',
'8589934592',
'---UPTIME_SINCE---',
'2024-01-15 10:30:00',
'---DOCKER---',
'29.4.3-ce',
'---COMPOSE---',
'v2.32.4',
]);
}
it('shows a fetch server details action on localhost when metadata is missing', function () {
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSee('Fetch server details')
->assertDontSee('Ubuntu 22.04.3 LTS');
});
it('renders stored localhost server details instead of the fetch action', function () {
$this->server->update([
'server_metadata' => [
'os' => 'Ubuntu 22.04.3 LTS',
'arch' => 'x86_64',
'kernel' => '5.15.0-91-generic',
'cpus' => 4,
'memory_bytes' => 8589934592,
'uptime_since' => '2024-01-15 10:30:00',
'collected_at' => now()->toIso8601String(),
],
]);
$this->server->rememberDockerVersion('29.4.3-ce');
$this->server->rememberComposeVersion('v2.32.4');
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSee('Operating system')
->assertSee('Ubuntu 22.04.3 LTS')
->assertSee('x86_64')
->assertSee('Docker version')
->assertSee('29.4.3')
->assertSee('Compose version')
->assertSee('2.32.4')
->assertSee('Refresh server details')
->assertDontSee('Fetch server details');
});
it('fetches localhost server details from the overview action', function () {
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
Process::fake([
'*' => Process::result(output: localhostMetadataProcessOutput(), exitCode: 0),
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('refreshServerMetadata')
->assertSee('Operating system')
->assertSee('Ubuntu 22.04.3 LTS')
->assertSee('Docker version')
->assertSee('29.4.3')
->assertSee('Compose version')
->assertSee('2.32.4');
$server = $this->server->fresh();
expect($server->server_metadata['os'])->toBe('Ubuntu 22.04.3 LTS')
->and($server->dockerVersion())->toBe('29.4.3')
->and($server->composeVersion())->toBe('2.32.4');
});
it('refetches localhost compose version from the overview refresh action', function () {
$this->server->update([
'server_metadata' => [
'os' => 'Ubuntu 22.04.3 LTS',
'arch' => 'x86_64',
'kernel' => '5.15.0-91-generic',
'cpus' => 4,
'memory_bytes' => 8589934592,
'uptime_since' => '2024-01-15 10:30:00',
'collected_at' => now()->toIso8601String(),
],
]);
$this->server->rememberDockerVersion('29.4.3');
$this->server->settings()->update([
'is_reachable' => true,
'is_usable' => true,
]);
Process::fake([
'*' => Process::result(output: localhostMetadataProcessOutput(), exitCode: 0),
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSee('Compose version')
->assertSee('N/A')
->call('refreshServerMetadata')
->assertSee('2.32.4');
expect($this->server->fresh()->composeVersion())->toBe('2.32.4');
});
it('collects localhost server details after a successful connection check', function () {
$this->server->settings()->update([
'is_reachable' => false,
'is_usable' => false,
'server_timezone' => 'UTC',
'connection_timeout' => 10,
]);
Process::fake([
'*' => Process::sequence([
Process::result(output: 'bin', exitCode: 0),
Process::result(output: localhostMetadataProcessOutput(), exitCode: 0),
]),
]);
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->call('checkLocalhostConnection');
$server = $this->server->fresh();
expect($server->server_metadata['os'])->toBe('Ubuntu 22.04.3 LTS')
->and($server->dockerVersion())->toBe('29.4.3')
->and($server->composeVersion())->toBe('2.32.4')
->and($server->settings->is_reachable)->toBeTrue()
->and($server->settings->is_usable)->toBeTrue();
});
+62
View File
@@ -1,10 +1,13 @@
<?php
use App\Livewire\Server\Show;
use App\Livewire\Server\ValidateAndInstall;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Livewire\Livewire;
uses(RefreshDatabase::class);
@@ -16,8 +19,13 @@ beforeEach(function () {
$this->actingAs($user);
session(['currentTeam' => $this->team]);
$this->privateKey = PrivateKey::factory()->create([
'team_id' => $this->team->id,
]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => $this->privateKey->id,
]);
});
@@ -84,6 +92,60 @@ it('returns null from gatherServerMetadata when server is not functional', funct
expect($this->server->gatherServerMetadata())->toBeNull();
});
it('stores a parsed docker version when gathering server metadata', function () {
$this->server->settings->update([
'is_reachable' => true,
'is_usable' => true,
]);
Process::fake([
'*' => Process::result(output: implode("\n", [
'---PRETTY_NAME---',
'Debian GNU/Linux 12 (bookworm)',
'---ARCH---',
'aarch64',
'---KERNEL---',
'6.1.0-17-arm64',
'---CPUS---',
'8',
'---MEMORY---',
'17179869184',
'---UPTIME_SINCE---',
'2024-03-01 08:00:00',
'---DOCKER---',
'29.4.3-ce',
'---COMPOSE---',
'v2.32.4',
]), exitCode: 0),
]);
expect($this->server->gatherServerMetadata()['os'])->toBe('Debian GNU/Linux 12 (bookworm)')
->and($this->server->fresh()->dockerVersion())->toBe('29.4.3')
->and($this->server->fresh()->composeVersion())->toBe('2.32.4');
});
it('shows the stored docker version in remote server details', function () {
$this->server->update([
'server_metadata' => [
'os' => 'Debian GNU/Linux 12 (bookworm)',
'arch' => 'aarch64',
'kernel' => '6.1.0-17-arm64',
'cpus' => 8,
'memory_bytes' => 17179869184,
'uptime_since' => '2024-03-01 08:00:00',
'collected_at' => now()->toIso8601String(),
],
]);
$this->server->rememberDockerVersion('27.5.1');
$this->server->rememberComposeVersion('2.29.7');
Livewire::test(Show::class, ['server_uuid' => $this->server->uuid])
->assertSee('Docker version')
->assertSee('27.5.1')
->assertSee('Compose version')
->assertSee('2.29.7');
});
it('can overwrite server_metadata with new values', function () {
$this->server->update(['server_metadata' => ['os' => 'Ubuntu 20.04', 'cpus' => 2]]);
$this->server->refresh();
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* Docker 28.0 deprecated `--time` on `docker stop` / `docker restart` in favor of `--timeout`.
* `--timeout` is rejected by Docker < 28, so Coolify picks the flag from the stored engine version.
* Unknown versions keep the historical `--time` flag.
*
* @see https://github.com/coollabsio/coolify/issues/11244
*/
it('parses docker engine versions', function (?string $raw, ?string $expected) {
expect(parseDockerEngineVersion($raw))->toBe($expected);
})->with([
'semver' => ['29.4.3', '29.4.3'],
'two-part' => ['28.0', '28.0.0'],
'suffix' => ['28.0.1-ce', '28.0.1'],
'build metadata' => ['29.4.3+azure', '29.4.3'],
'null' => [null, null],
'empty' => ['', null],
'garbage' => ['not-a-version', null],
]);
it('extracts the server version from docker version json', function () {
expect(dockerEngineVersionFromJson('{"Server":{"Version":"29.4.3"}}'))->toBe('29.4.3')
->and(dockerEngineVersionFromJson('{"Client":{"Version":"29.4.3"}}'))->toBeNull()
->and(dockerEngineVersionFromJson('not-json'))->toBeNull();
});
it('uses --timeout on docker 28 and newer', function (string $version) {
expect(dockerStopCommand(30, 'app-1', $version))->toBe('docker stop --timeout=30 app-1');
})->with(['28.0.0', '28.0.1', '28.0', '29.4.3', '29.4.3-ce']);
it('uses --time on docker older than 28', function (string $version) {
expect(dockerStopCommand(30, 'app-1', $version))->toBe('docker stop --time=30 app-1');
})->with(['24.0.0', '26.1.4', '27.5.1']);
it('uses the historical --time flag when the docker version is unknown', function (?string $version) {
expect(dockerStopCommand(30, 'app-1', $version))->toBe('docker stop --time=30 app-1');
})->with([
'null' => [null],
'empty' => [''],
'garbage' => ['unknown'],
]);
@@ -0,0 +1,66 @@
<?php
/**
* Docker deprecated `--time` on `docker stop` / `docker restart` in v28.0.
* Call sites must go through dockerStopCommand() so the flag is chosen from
* the stored engine version (`--timeout` on 28+, `--time` before that or if unknown).
*
* @see https://github.com/coollabsio/coolify/issues/11244
* @see https://github.com/coollabsio/coolify/issues/10791
*/
it('does not hardcode docker --time outside the version-aware helper', function () {
$projectRoot = dockerStopDeprecatedFlagProjectRoot();
$helperFile = $projectRoot.DIRECTORY_SEPARATOR.'bootstrap'.DIRECTORY_SEPARATOR.'helpers'.DIRECTORY_SEPARATOR.'docker.php';
$offenders = collect([
...dockerStopDeprecatedFlagPhpFiles($projectRoot.DIRECTORY_SEPARATOR.'app'),
...dockerStopDeprecatedFlagPhpFiles($projectRoot.DIRECTORY_SEPARATOR.'bootstrap'.DIRECTORY_SEPARATOR.'helpers'),
])
->reject(fn (string $file): bool => $file === $helperFile)
->mapWithKeys(function (string $file) use ($projectRoot) {
preg_match_all('/docker\s+(?:stop|restart)\s+--time\b/', file_get_contents($file), $matches);
return [str_replace($projectRoot.DIRECTORY_SEPARATOR, '', $file) => $matches[0]];
})
->filter(fn (array $matches): bool => $matches !== [])
->map(fn (array $matches, string $file): string => $file.': '.implode(', ', array_unique($matches)));
expect($offenders->values()->implode("\n"))->toBe('');
});
it('builds application container stop commands from the stored docker version', function (string $relativePath) {
$contents = file_get_contents(dockerStopDeprecatedFlagProjectRoot().DIRECTORY_SEPARATOR.$relativePath);
expect($contents)
->toContain('dockerStopCommand(')
->not->toContain('docker stop --time=')
->not->toContain('docker stop -t $timeout');
})->with([
'deployment graceful shutdown' => ['app/Jobs/ApplicationDeploymentJob.php'],
'stop application' => ['app/Actions/Application/StopApplication.php'],
'stop application on one server' => ['app/Actions/Application/StopApplicationOneServer.php'],
'stop preview containers' => ['app/Livewire/Project/Application/Previews.php'],
]);
function dockerStopDeprecatedFlagProjectRoot(): string
{
return dirname(__DIR__, 2);
}
/**
* @return list<string>
*/
function dockerStopDeprecatedFlagPhpFiles(string $directory): array
{
$files = [];
foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory)) as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$files[] = $file->getPathname();
}
}
sort($files);
return $files;
}