diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php index 7c973130c4..f4855b9ffa 100644 --- a/app/Actions/Server/ValidateServer.php +++ b/app/Actions/Server/ValidateServer.php @@ -114,7 +114,7 @@ class ValidateServer if ($this->docker_version) { return 'OK'; } else { - $this->error = 'Docker Engine is not installed. Please install Docker manually before continuing: documentation.'; + $this->error = $server->dockerEngineVersionError(); $server->update([ 'validation_logs' => $this->error, ]); diff --git a/app/Jobs/ValidateAndInstallServerJob.php b/app/Jobs/ValidateAndInstallServerJob.php index 987b53e7f6..7ebfbbc142 100644 --- a/app/Jobs/ValidateAndInstallServerJob.php +++ b/app/Jobs/ValidateAndInstallServerJob.php @@ -160,14 +160,14 @@ class ValidateAndInstallServerJob implements ShouldBeEncrypted, ShouldQueue // Validate Docker version $dockerVersion = $this->server->validateDockerEngineVersion(); if (! $dockerVersion) { - $requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.'); - $errorMessage = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not installed. Please install Docker manually before continuing: documentation.'; + $errorMessage = $this->server->dockerEngineVersionError(); $this->server->update([ 'validation_logs' => $errorMessage, 'is_validating' => false, ]); Log::error('ValidateAndInstallServer: Docker version not sufficient', [ 'server_id' => $this->server->id, + 'docker_version' => $this->server->dockerVersion(), ]); return; diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index 27a7c580e1..c7ce754732 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -320,8 +320,7 @@ class ValidateAndInstall extends Component instant_remote_process(ensureProxyNetworksExist($this->server)->toArray(), $this->server, false); StartProxy::dispatch($this->server); } else { - $requiredDockerVersion = str(config('constants.docker.minimum_required_version'))->before('.'); - $this->error = 'Minimum Docker Engine version '.$requiredDockerVersion.' is not installed. Please install Docker manually before continuing: documentation.'; + $this->error = $this->server->dockerEngineVersionError(); $this->server->update([ 'validation_logs' => $this->error, ]); diff --git a/app/Models/Server.php b/app/Models/Server.php index e8d3e7c12a..e1eac85d6a 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -2567,13 +2567,11 @@ $siteAddress { 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); + $dockerVersion = dockerEngineVersionFromJson($dockerVersionRaw); + $this->rememberDockerVersion($dockerVersion); $composeVersionRaw = instant_remote_process(['docker compose version --short'], $this, false); $this->rememberComposeVersion(is_string($composeVersionRaw) ? $composeVersionRaw : null); - $dockerVersion = checkMinimumDockerEngineVersion($dockerVersion); - if (is_null($dockerVersion)) { + if (is_null(checkMinimumDockerEngineVersion($dockerVersion))) { $this->settings->is_usable = false; $this->settings->save(); @@ -2587,6 +2585,25 @@ $siteAddress { return true; } + /** + * Explains why validateDockerEngineVersion() failed: the version could not be read, or it is too old. + */ + public function dockerEngineVersionError(): string + { + $requiredVersion = str(config('constants.docker.minimum_required_version'))->before('.'); + $documentation = 'documentation'; + $dockerVersion = $this->dockerVersion(); + + if (is_null($dockerVersion)) { + $command = $this->isNonRoot() ? 'sudo docker version --format json' : 'docker version --format json'; + $user = e($this->user); + + return "Coolify could not read the Docker Engine version. Run \"{$command}\" on the server as the {$user} user. It must exit without an error and print only JSON. Text from shell startup files (for example ~/.bashrc) breaks the output."; + } + + return "Docker Engine {$dockerVersion} is installed, but Coolify needs version {$requiredVersion} or newer. Please upgrade Docker: {$documentation}."; + } + public function validateCoolifyNetwork($isSwarm = false, $isBuildServer = false) { if ($isBuildServer) { diff --git a/tests/Feature/ServerDockerVersionValidationTest.php b/tests/Feature/ServerDockerVersionValidationTest.php new file mode 100644 index 0000000000..f41992431c --- /dev/null +++ b/tests/Feature/ServerDockerVersionValidationTest.php @@ -0,0 +1,87 @@ + 0]); + Event::fake([ServerReachabilityChanged::class]); + + $user = User::factory()->create(); + $this->team = Team::factory()->create(); + $user->teams()->attach($this->team, ['role' => 'owner']); + $this->actingAs($user); + session(['currentTeam' => $this->team]); + + $this->server = Server::factory()->create([ + 'team_id' => $this->team->id, + 'user' => 'root', + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id, + ]); +}); + +function fakeDockerVersionOutput(string $output, int $exitCode = 0): void +{ + Process::fake([ + '*compose*' => Process::result(output: 'v2.40.3', exitCode: 0), + '*' => Process::result(output: $output, exitCode: $exitCode), + ]); +} + +function validateDockerVersionError(Server $server): ?string +{ + $component = Livewire::test(ValidateAndInstall::class, ['server' => $server])->instance(); + $component->uptime = '1 day'; + $component->validateDockerVersion(); + + return $component->error; +} + +it('accepts Docker Engine 29', function () { + fakeDockerVersionOutput('{"Client":{"Version":"29.6.2"},"Server":{"Version":"29.6.2"}}'); + + expect($this->server->validateDockerEngineVersion())->toBeTrue() + ->and($this->server->fresh()->dockerVersion())->toBe('29.6.2'); +}); + +it('reports an unreadable docker version instead of a too old one', function (string $output, int $exitCode) { + fakeDockerVersionOutput($output, $exitCode); + + $error = validateDockerVersionError($this->server); + + expect($error)->toContain('could not read the Docker Engine version') + ->toContain('Run "docker version --format json" on the server as the root user') + ->not->toContain('Minimum Docker Engine version') + ->and($this->server->fresh()->dockerVersion())->toBeNull() + ->and($this->server->fresh()->validation_logs)->toBe($error); +})->with([ + 'text before the json' => ['Welcome to the server'."\n".'{"Server":{"Version":"29.6.2"}}', 0], + 'command failed' => ['', 1], +]); + +it('reports the detected version when docker is too old', function () { + fakeDockerVersionOutput('{"Server":{"Version":"23.0.6"}}'); + + expect(validateDockerVersionError($this->server)) + ->toContain('Docker Engine 23.0.6 is installed') + ->toContain('version 24 or newer'); +}); + +it('shows the sudo command for non-root servers', function () { + $this->server->update(['user' => 'deploy']); + fakeDockerVersionOutput('not json'); + + expect(validateDockerVersionError($this->server->fresh())) + ->toContain('Run "sudo docker version --format json" on the server as the deploy user'); +});