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
2026-08-13 11:17:44 +02:00
parent 6481ffffcf
commit e79230cf37
32 changed files with 697 additions and 64 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ class StopApplication
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
"docker stop --time=$timeout $containerName",
dockerStopCommand($timeout, $containerName, $server),
"docker rm -f $containerName",
], server: $server, throwError: false);
}
@@ -28,7 +28,7 @@ class StopApplicationOneServer
if ($containerName) {
instant_remote_process(
[
"docker stop --time=$timeout $containerName",
dockerStopCommand($timeout, $containerName, $server),
"docker rm -f $containerName",
],
$server
+1 -1
View File
@@ -104,7 +104,7 @@ class StartClickhouse
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
+1 -1
View File
@@ -191,7 +191,7 @@ class StartDragonfly
if ($this->database->enable_ssl) {
$this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt";
}
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
+1 -1
View File
@@ -209,7 +209,7 @@ class StartKeydb
if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) {
$this->commands[] = "chown 999:999 $this->configuration_dir/keydb.conf";
}
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
+1 -1
View File
@@ -208,7 +208,7 @@ class StartMariadb
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
+1 -1
View File
@@ -257,7 +257,7 @@ class StartMongodb
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
+1 -1
View File
@@ -209,7 +209,7 @@ class StartMysql
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
+1 -1
View File
@@ -219,7 +219,7 @@ class StartPostgresql
$this->commands[] = "echo '{$readme}' > $this->configuration_dir/README.md";
$this->commands[] = "echo 'Pulling {$database->image} image.'";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull";
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
if ($this->database->enable_ssl) {
+1 -1
View File
@@ -204,7 +204,7 @@ class StartRedis
if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) {
$this->commands[] = "chown 999:999 $this->configuration_dir/redis.conf";
}
$this->commands[] = "docker stop -t 10 $container_name 2>/dev/null || true";
$this->commands[] = dockerStopCommand(10, $container_name, $this->database->destination->server).' 2>/dev/null || true';
$this->commands[] = "docker rm -f $container_name 2>/dev/null || true";
$this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml up -d";
$this->commands[] = "echo 'Database started.'";
+1 -1
View File
@@ -56,7 +56,7 @@ class StopDatabase
{
$server = $database->destination->server;
instant_remote_process(command: [
"docker stop -t $timeout $containerName",
dockerStopCommand($timeout, $containerName, $server),
"docker rm -f $containerName",
], server: $server, throwError: false);
}
+1 -1
View File
@@ -24,7 +24,7 @@ class StopProxy
}
instant_remote_process(command: [
"docker stop -t=$timeout $containerName 2>/dev/null || true",
dockerStopCommand($timeout, $containerName, $server).' 2>/dev/null || true',
"docker rm -f $containerName 2>/dev/null || true",
'# Wait for container to be fully removed',
'for i in {1..10}; do',
+1 -1
View File
@@ -67,7 +67,7 @@ class StopService
$timeout = count($containersToStop) > 5 ? 10 : 30;
$commands = [];
$containerList = implode(' ', $containersToStop);
$commands[] = "docker stop -t $timeout $containerList";
$commands[] = dockerStopCommand($timeout, $containerList, $server);
$commands[] = "docker rm -f $containerList";
instant_remote_process(
command: $commands,
+3 -2
View File
@@ -431,6 +431,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
["docker version --format '{{.Server.Version}}'"],
$serverToCheck
);
$serverToCheck->rememberDockerVersion($dockerVersion);
$versionParts = explode('.', $dockerVersion);
$majorVersion = (int) $versionParts[0];
@@ -3972,11 +3973,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
if ($skipRemove) {
$this->execute_remote_command(
["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true]
[dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true]
);
} else {
$this->execute_remote_command(
["docker stop --time=$timeout $containerName", 'hidden' => true, 'ignore_errors' => true],
[dockerStopCommand($timeout, $containerName, $this->server), 'hidden' => true, 'ignore_errors' => true],
["docker rm -f $containerName", 'hidden' => true, 'ignore_errors' => true]
);
}
+1 -1
View File
@@ -216,7 +216,7 @@ class DeleteResourceJob implements ShouldBeEncrypted, ShouldQueue
$containerList = implode(' ', array_map('escapeshellarg', $containerNames));
$commands = [
"docker stop -t $timeout $containerList",
dockerStopCommand($timeout, $containerList, $server),
"docker rm -f $containerList",
];
instant_remote_process(
+1 -1
View File
@@ -98,7 +98,7 @@ class RestartProxyJob implements ShouldBeEncrypted, ShouldQueue
// === STOP PHASE ===
$commands = $commands->merge([
"echo 'Stopping proxy...'",
"docker stop -t=$stopTimeout $containerName 2>/dev/null || true",
dockerStopCommand($stopTimeout, $containerName, $this->server).' 2>/dev/null || true',
"docker rm -f $containerName 2>/dev/null || true",
'# Wait for container to be fully removed',
'for i in {1..15}; do',
+14
View File
@@ -194,6 +194,20 @@ class ServerConnectionCheckJob implements ShouldBeEncrypted, ShouldQueue
$output = trim($output);
if (! empty($output)) {
$dockerInfo = json_decode($output, true);
$dockerVersion = dockerEngineVersionFromJson($output);
if ($dockerVersion !== null) {
$this->server->rememberDockerVersion($dockerVersion);
}
$composeOutput = instant_remote_process_with_timeout(
['docker compose version --short'],
$this->server,
false
);
$composeVersion = parseDockerEngineVersion($composeOutput);
if ($composeVersion !== null) {
$this->server->rememberComposeVersion($composeVersion);
}
return isset($dockerInfo['Server']['Version']);
}
@@ -354,7 +354,7 @@ class Previews extends Component
foreach ($containersToStop as $containerName) {
instant_remote_process(command: [
"docker stop --time=$timeout $containerName",
dockerStopCommand($timeout, $containerName, $server),
"docker rm -f $containerName",
], server: $server, throwError: false);
}
+10 -2
View File
@@ -371,6 +371,8 @@ class Show extends Component
$this->server->settings->is_usable = $this->isUsable = true;
$this->server->settings->save();
ServerReachabilityChanged::dispatch($this->server);
$this->server->gatherServerMetadata();
$this->server->refresh();
} else {
$this->dispatch('error', 'Server is not reachable.', 'Please validate your configuration and connection.<br><br>Check this <a target="_blank" class="underline" href="https://coolify.io/docs/knowledge-base/server/openssh">documentation</a> for further help. <br><br>Error: '.$error);
@@ -671,12 +673,18 @@ class Show extends Component
{
try {
$this->authorize('update', $this->server);
if (! $this->server->isFunctional()) {
$this->dispatch('error', 'Validate the server connection before fetching details.');
return;
}
$result = $this->server->gatherServerMetadata();
if ($result) {
$this->server->refresh();
$this->server->refresh()->load('settings');
$this->dispatch('success', 'Server details refreshed.');
} else {
$this->dispatch('error', 'Could not fetch server details. Is the server reachable?');
$this->dispatch('error', 'Could not collect server details. Check the application logs for the remote command output.');
}
} catch (\Throwable $e) {
handleError($e, $this);
+48 -2
View File
@@ -961,7 +961,7 @@ $schema://$host {
public function stopUnmanaged($id)
{
return instant_remote_process(['docker stop -t 0 '.escapeshellarg($id)], $this);
return instant_remote_process([dockerStopCommand(0, escapeshellarg($id), $this)], $this);
}
public function restartUnmanaged($id)
@@ -1351,7 +1351,7 @@ $schema://$host {
try {
$output = instant_remote_process([
'echo "---PRETTY_NAME---" && grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d \'"\' && echo "---ARCH---" && uname -m && echo "---KERNEL---" && uname -r && echo "---CPUS---" && nproc && echo "---MEMORY---" && free -b | awk \'/Mem:/{print $2}\' && echo "---UPTIME_SINCE---" && uptime -s',
'echo "---PRETTY_NAME---" && grep PRETTY_NAME /etc/os-release | cut -d= -f2 | tr -d \'"\' && echo "---ARCH---" && uname -m && echo "---KERNEL---" && uname -r && echo "---CPUS---" && nproc && echo "---MEMORY---" && free -b | awk \'/Mem:/{print $2}\' && echo "---UPTIME_SINCE---" && uptime -s && echo "---DOCKER---" && (docker version --format \'{{.Server.Version}}\' 2>/dev/null || true) && echo "---COMPOSE---" && (docker compose version --short 2>/dev/null || true)',
], $this, false);
if (! $output) {
@@ -1381,6 +1381,23 @@ $schema://$host {
$this->update(['server_metadata' => $metadata]);
try {
$detectedDockerVersion = parseDockerEngineVersion($sections['DOCKER'] ?? null);
if ($detectedDockerVersion !== null) {
$this->rememberDockerVersion($detectedDockerVersion);
}
$detectedComposeVersion = parseDockerEngineVersion($sections['COMPOSE'] ?? null);
if ($detectedComposeVersion !== null) {
$this->rememberComposeVersion($detectedComposeVersion);
}
} catch (\Throwable $e) {
Log::debug('Failed to store server runtime versions', [
'server_id' => $this->id,
'error' => $e->getMessage(),
]);
}
return $metadata;
} catch (\Throwable $e) {
Log::debug('Failed to gather server metadata', [
@@ -1604,11 +1621,40 @@ $schema://$host {
return true;
}
public function dockerVersion(): ?string
{
return $this->settings?->docker_version;
}
public function rememberDockerVersion(?string $version): void
{
$this->settings->update([
'docker_version' => parseDockerEngineVersion($version),
'docker_version_checked_at' => now(),
]);
}
public function composeVersion(): ?string
{
return $this->settings?->compose_version;
}
public function rememberComposeVersion(?string $version): void
{
$this->settings->update([
'compose_version' => parseDockerEngineVersion($version),
'compose_version_checked_at' => now(),
]);
}
public function validateDockerEngineVersion()
{
$dockerVersionRaw = instant_remote_process(['docker version --format json'], $this, false);
$dockerVersionJson = json_decode($dockerVersionRaw, true);
$dockerVersion = data_get($dockerVersionJson, 'Server.Version', '0.0.0');
$this->rememberDockerVersion(is_string($dockerVersion) ? $dockerVersion : null);
$composeVersionRaw = instant_remote_process(['docker compose version --short'], $this, false);
$this->rememberComposeVersion(is_string($composeVersionRaw) ? $composeVersionRaw : null);
$dockerVersion = checkMinimumDockerEngineVersion($dockerVersion);
if (is_null($dockerVersion)) {
$this->settings->is_usable = false;
+10
View File
@@ -51,6 +51,10 @@ use OpenApi\Attributes as OA;
'delete_unused_volumes' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused volumes should be deleted.'],
'delete_unused_networks' => ['type' => 'boolean', 'description' => 'The flag to indicate if the unused networks should be deleted.'],
'connection_timeout' => ['type' => 'integer', 'description' => 'SSH connection timeout in seconds.'],
'docker_version' => ['type' => 'string', 'nullable' => true, 'description' => 'Detected Docker Engine version on the server.'],
'docker_version_checked_at' => ['type' => 'string', 'nullable' => true, 'description' => 'When Docker Engine version was last detected.'],
'compose_version' => ['type' => 'string', 'nullable' => true, 'description' => 'Detected Docker Compose plugin version on the server.'],
'compose_version_checked_at' => ['type' => 'string', 'nullable' => true, 'description' => 'When Docker Compose version was last detected.'],
]
)]
class ServerSetting extends Model
@@ -100,6 +104,10 @@ class ServerSetting extends Model
'deployment_queue_limit',
'disable_application_image_retention',
'connection_timeout',
'docker_version',
'docker_version_checked_at',
'compose_version',
'compose_version_checked_at',
];
protected $casts = [
@@ -113,6 +121,8 @@ class ServerSetting extends Model
'is_terminal_enabled' => 'boolean',
'disable_application_image_retention' => 'boolean',
'connection_timeout' => 'integer',
'docker_version_checked_at' => 'datetime',
'compose_version_checked_at' => 'datetime',
];
/**
+55
View File
@@ -198,6 +198,61 @@ function checkMinimumDockerEngineVersion($dockerVersion)
return $dockerVersion;
}
function parseDockerEngineVersion(?string $rawVersion): ?string
{
if ($rawVersion === null || trim($rawVersion) === '') {
return null;
}
if (preg_match('/\d+\.\d+(?:\.\d+)?/', $rawVersion, $matches) !== 1) {
return null;
}
$parts = explode('.', $matches[0]);
return sprintf('%d.%d.%d', (int) $parts[0], (int) ($parts[1] ?? 0), (int) ($parts[2] ?? 0));
}
function dockerEngineVersionFromJson(?string $raw): ?string
{
if ($raw === null || trim($raw) === '') {
return null;
}
$decoded = json_decode($raw, true);
if (! is_array($decoded)) {
return null;
}
$version = $decoded['Server']['Version'] ?? null;
return is_string($version) ? parseDockerEngineVersion($version) : null;
}
function dockerStopTimeoutOption(?string $dockerVersion): string
{
$normalized = parseDockerEngineVersion($dockerVersion);
if ($normalized !== null && version_compare($normalized, '28.0.0', '>=')) {
return '--timeout';
}
return '--time';
}
function dockerStopCommand(int $timeout, string $containers, Server|string|null $dockerVersion = null): string
{
$version = $dockerVersion instanceof Server
? $dockerVersion->dockerVersion()
: $dockerVersion;
$option = dockerStopTimeoutOption($version);
$flag = $option === '--timeout'
? "--timeout={$timeout}"
: "--time={$timeout}";
return "docker stop {$flag} {$containers}";
}
function escapeShellValue(string $value): string
{
return "'".str_replace("'", "'\\''", $value)."'";
+1
View File
@@ -63,6 +63,7 @@ return [
'docker' => [
'minimum_required_version' => '24.0',
'stop_timeout_flag_since' => '28.0.0',
],
'ssh' => [
@@ -0,0 +1,30 @@
<?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('server_settings', function (Blueprint $table) {
$table->string('docker_version')->nullable();
$table->timestamp('docker_version_checked_at')->nullable();
$table->string('compose_version')->nullable();
$table->timestamp('compose_version_checked_at')->nullable();
});
}
public function down(): void
{
Schema::table('server_settings', function (Blueprint $table) {
$table->dropColumn([
'docker_version',
'docker_version_checked_at',
'compose_version',
'compose_version_checked_at',
]);
});
}
};
@@ -3,6 +3,12 @@
<x-application.settings-section id="server-overview-section" title="Server overview"
helper="Operating system and hardware details for the server running this Coolify instance.">
<x-slot:actions>
@if ($server->server_metadata)
<x-forms.button type="button" class="size-8! px-0!"
wire:click="refreshServerMetadata" title="Refresh server details">
<x-reicon name="refresh" class="size-3.5" />
</x-forms.button>
@endif
<x-status-badge :status="$server->isFunctional() ? 'Ready' : 'Validation required'"
:type="$server->isFunctional() ? 'success' : 'warning'" />
</x-slot:actions>
@@ -25,27 +31,14 @@
</div>
@if ($server->server_metadata)
@php($meta = $server->server_metadata)
<dl
class="mt-4 grid gap-x-6 gap-y-5 border-t border-neutral-200 pt-4 sm:grid-cols-2 lg:grid-cols-3 dark:border-white/[0.08]">
@foreach ([
'Operating system' => $meta['os'] ?? 'N/A',
'Architecture' => $meta['arch'] ?? 'N/A',
'Kernel' => $meta['kernel'] ?? 'N/A',
'CPU cores' => $meta['cpus'] ?? 'N/A',
'Memory' => isset($meta['memory_bytes']) ? round($meta['memory_bytes'] / 1073741824, 1) . ' GB' : 'N/A',
'Up since' => $meta['uptime_since'] ?? 'N/A',
] as $detailLabel => $detailValue)
<div>
<dt class="text-xs font-medium text-neutral-500 dark:text-fg-dim">
{{ $detailLabel }}
</dt>
<dd class="mt-1 text-sm font-medium text-neutral-950 dark:text-fg">
{{ $detailValue }}
</dd>
</div>
@endforeach
</dl>
@include('livewire.server.partials.server-details', ['server' => $server])
@else
<div class="mt-4 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<x-forms.button type="button" wire:click="refreshServerMetadata">
<x-reicon name="refresh" class="size-3.5" />
Fetch server details
</x-forms.button>
</div>
@endif
</x-application.settings-section>
@@ -0,0 +1,23 @@
@php($meta = $server->server_metadata)
<dl
class="mt-4 grid gap-x-6 gap-y-5 border-t border-neutral-200 pt-4 sm:grid-cols-2 lg:grid-cols-3 dark:border-white/[0.08]">
@foreach ([
'Operating system' => $meta['os'] ?? 'N/A',
'Architecture' => $meta['arch'] ?? 'N/A',
'Kernel' => $meta['kernel'] ?? 'N/A',
'CPU cores' => $meta['cpus'] ?? 'N/A',
'Memory' => isset($meta['memory_bytes']) ? round($meta['memory_bytes'] / 1073741824, 1) . ' GB' : 'N/A',
'Docker version' => $server->dockerVersion() ?? 'N/A',
'Compose version' => $server->composeVersion() ?? 'N/A',
'Up since' => $meta['uptime_since'] ?? 'N/A',
] as $detailLabel => $detailValue)
<div>
<dt class="text-xs font-medium text-neutral-500 dark:text-fg-dim">
{{ $detailLabel }}
</dt>
<dd class="mt-1 text-sm font-medium text-neutral-950 dark:text-fg">
{{ $detailValue }}
</dd>
</div>
@endforeach
</dl>
+7 -21
View File
@@ -89,6 +89,12 @@
</x-forms.button>
@endif
@endif
@if ($server->server_metadata)
<x-forms.button type="button" class="size-8! px-0!"
wire:click="refreshServerMetadata" title="Refresh server details">
<x-reicon name="refresh" class="size-3.5" />
</x-forms.button>
@endif
@if ($server->isTransferredAway())
<x-status-badge label="Transferred away" type="warning" />
@else
@@ -119,27 +125,7 @@
</div>
@if ($server->server_metadata)
@php($meta = $server->server_metadata)
<dl
class="mt-4 grid gap-x-6 gap-y-5 border-t border-neutral-200 pt-4 sm:grid-cols-2 lg:grid-cols-3 dark:border-white/[0.08]">
@foreach ([
'Operating system' => $meta['os'] ?? 'N/A',
'Architecture' => $meta['arch'] ?? 'N/A',
'Kernel' => $meta['kernel'] ?? 'N/A',
'CPU cores' => $meta['cpus'] ?? 'N/A',
'Memory' => isset($meta['memory_bytes']) ? round($meta['memory_bytes'] / 1073741824, 1) . ' GB' : 'N/A',
'Up since' => $meta['uptime_since'] ?? 'N/A',
] as $detailLabel => $detailValue)
<div>
<dt class="text-xs font-medium text-neutral-500 dark:text-fg-dim">
{{ $detailLabel }}
</dt>
<dd class="mt-1 text-sm font-medium text-neutral-950 dark:text-fg">
{{ $detailValue }}
</dd>
</div>
@endforeach
</dl>
@include('livewire.server.partials.server-details', ['server' => $server])
@else
<div class="mt-4 border-t border-neutral-200 pt-4 dark:border-white/[0.08]">
<x-forms.button type="button" wire:click="refreshServerMetadata">
+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;
}