From 1017a5b3078c2f5469cbb05b59a33861cfc0ee6b Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:00:06 +0100 Subject: [PATCH] fix(deploy): use SSH user registry credentials for host image pulls (#12216) --- app/Actions/Database/StartClickhouse.php | 2 +- app/Actions/Database/StartDragonfly.php | 2 +- app/Actions/Database/StartKeydb.php | 2 +- app/Actions/Database/StartMariadb.php | 2 +- app/Actions/Database/StartMongodb.php | 2 +- app/Actions/Database/StartMysql.php | 2 +- app/Actions/Database/StartPostgresql.php | 2 +- app/Actions/Database/StartRedis.php | 2 +- app/Actions/Database/StartSqlite.php | 2 +- app/Actions/Proxy/StartProxy.php | 2 +- .../Service/DeployServiceApplication.php | 4 +- app/Actions/Service/StartService.php | 2 +- app/Jobs/ApplicationDeploymentJob.php | 10 +- .../GithubRunner/GithubRunnerContainer.php | 2 +- bootstrap/helpers/docker.php | 14 ++ .../DeploymentSensitiveCommandFailureTest.php | 16 ++ .../NonRootDataDirectoryCommandsTest.php | 2 + .../Proxy/NonRootProxyCommandsTest.php | 4 +- tests/Feature/ServiceComposeStdinTest.php | 4 +- tests/Unit/GithubRunnerContainerTest.php | 5 +- tests/Unit/HostDockerPullConfigTest.php | 177 ++++++++++++++++++ 21 files changed, 235 insertions(+), 25 deletions(-) create mode 100644 tests/Unit/HostDockerPullConfigTest.php diff --git a/app/Actions/Database/StartClickhouse.php b/app/Actions/Database/StartClickhouse.php index 86545e010f..0760269b3a 100644 --- a/app/Actions/Database/StartClickhouse.php +++ b/app/Actions/Database/StartClickhouse.php @@ -121,7 +121,7 @@ class StartClickhouse $readme = generate_readme_file($this->database->name, now()); $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull"; $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"; diff --git a/app/Actions/Database/StartDragonfly.php b/app/Actions/Database/StartDragonfly.php index 2602de63d1..f2f074e510 100644 --- a/app/Actions/Database/StartDragonfly.php +++ b/app/Actions/Database/StartDragonfly.php @@ -186,7 +186,7 @@ class StartDragonfly $readme = generate_readme_file($this->database->name, now()); $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull"; if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } diff --git a/app/Actions/Database/StartKeydb.php b/app/Actions/Database/StartKeydb.php index dddb86e2c2..51e8b859d4 100644 --- a/app/Actions/Database/StartKeydb.php +++ b/app/Actions/Database/StartKeydb.php @@ -201,7 +201,7 @@ class StartKeydb $readme = generate_readme_file($this->database->name, now()); $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull"; if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } diff --git a/app/Actions/Database/StartMariadb.php b/app/Actions/Database/StartMariadb.php index 234583a538..1ba35c8866 100644 --- a/app/Actions/Database/StartMariadb.php +++ b/app/Actions/Database/StartMariadb.php @@ -202,7 +202,7 @@ class StartMariadb $readme = generate_readme_file($this->database->name, now()); $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull"; if ($this->database->enable_ssl) { $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt < /dev/null"; } diff --git a/app/Actions/Database/StartMongodb.php b/app/Actions/Database/StartMongodb.php index 0064b43e38..6008166852 100644 --- a/app/Actions/Database/StartMongodb.php +++ b/app/Actions/Database/StartMongodb.php @@ -250,7 +250,7 @@ class StartMongodb $readme = generate_readme_file($this->database->name, now()); $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull"; if ($this->database->enable_ssl) { $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mongodb:mongodb /etc/mongo/certs/server.pem < /dev/null"; } diff --git a/app/Actions/Database/StartMysql.php b/app/Actions/Database/StartMysql.php index e8c24d5179..8f3825ffc0 100644 --- a/app/Actions/Database/StartMysql.php +++ b/app/Actions/Database/StartMysql.php @@ -205,7 +205,7 @@ class StartMysql $readme = generate_readme_file($this->database->name, now()); $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull"; if ($this->database->enable_ssl) { $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name mysql:mysql /etc/mysql/certs/server.key /etc/mysql/certs/server.crt < /dev/null"; } diff --git a/app/Actions/Database/StartPostgresql.php b/app/Actions/Database/StartPostgresql.php index fa1224d9cc..f307d543af 100644 --- a/app/Actions/Database/StartPostgresql.php +++ b/app/Actions/Database/StartPostgresql.php @@ -214,7 +214,7 @@ class StartPostgresql $readme = generate_readme_file($this->database->name, now()); $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull"; if ($this->database->enable_ssl) { $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml run --rm --no-deps --user root --entrypoint chown $container_name postgres:postgres /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt < /dev/null"; } diff --git a/app/Actions/Database/StartRedis.php b/app/Actions/Database/StartRedis.php index b0ac2e7001..115de5e3ea 100644 --- a/app/Actions/Database/StartRedis.php +++ b/app/Actions/Database/StartRedis.php @@ -198,7 +198,7 @@ class StartRedis $readme = generate_readme_file($this->database->name, now()); $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull"; if ($this->database->enable_ssl) { $this->commands[] = "chown -R 999:999 $this->configuration_dir/ssl/server.key $this->configuration_dir/ssl/server.crt"; } diff --git a/app/Actions/Database/StartSqlite.php b/app/Actions/Database/StartSqlite.php index b63598f998..60dd19c28b 100644 --- a/app/Actions/Database/StartSqlite.php +++ b/app/Actions/Database/StartSqlite.php @@ -99,7 +99,7 @@ class StartSqlite $readme = generate_readme_file($this->database->name, now()); $this->commands[] = "echo '{$readme}' | tee $this->configuration_dir/README.md > /dev/null"; $this->commands[] = 'echo '.escapeshellarg("Pulling {$database->image} image."); - $this->commands[] = "docker compose -f $this->configuration_dir/docker-compose.yml pull"; + $this->commands[] = serverDockerConfigPrefix($this->database->destination->server)."docker compose -f $this->configuration_dir/docker-compose.yml pull"; $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"; diff --git a/app/Actions/Proxy/StartProxy.php b/app/Actions/Proxy/StartProxy.php index 5eaf8d7655..641e47469f 100644 --- a/app/Actions/Proxy/StartProxy.php +++ b/app/Actions/Proxy/StartProxy.php @@ -87,7 +87,7 @@ class StartProxy "echo '$caddyfile' | base64 -d | tee $proxy_path/dynamic/Caddyfile > /dev/null", "echo 'Creating required Docker Compose file.'", "echo 'Pulling docker image.'", - "docker compose -f $compose_file pull", + serverDockerConfigPrefix($server)."docker compose -f $compose_file pull", 'if docker ps -a --format "{{.Names}}" | grep -q "^coolify-proxy$"; then', " echo 'Stopping and removing existing coolify-proxy.'", ' docker stop coolify-proxy 2>/dev/null || true', diff --git a/app/Actions/Service/DeployServiceApplication.php b/app/Actions/Service/DeployServiceApplication.php index 2a475cd462..cc49010db6 100644 --- a/app/Actions/Service/DeployServiceApplication.php +++ b/app/Actions/Service/DeployServiceApplication.php @@ -39,7 +39,7 @@ class DeployServiceApplication if ($pullLatestImages) { $commands->push('echo Pulling image for service.'); - $commands->push("docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} pull {$safeComposeServiceName} < /dev/null"); + $commands->push(serverDockerConfigPrefix($service->server)."docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} pull {$safeComposeServiceName} < /dev/null"); } if ($service->networks()->count() > 0) { @@ -48,7 +48,7 @@ class DeployServiceApplication } $connectToPredefinedNetwork = (bool) data_get($service, 'connect_to_docker_network'); - $upCommand = "docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} up -d --no-deps"; + $upCommand = serverDockerConfigPrefix($service->server)."docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} up -d --no-deps"; if ($forceRebuild) { $upCommand .= ' --build'; } diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index 6d938c9ac4..c7119bfefe 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -47,7 +47,7 @@ class StartService // Give Compose no stdin so it uses the default answer (keep the volume). Never pass --yes: // it recreates the volume and deletes its data. // Pass -f so Compose uses only the file that Coolify wrote. - $compose = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid}"; + $compose = serverDockerConfigPrefix($service->server)."docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid}"; if ($pullLatestImages) { $commands[] = "echo 'Pulling images.'"; $commands[] = "{$compose} pull < /dev/null"; diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index c5e465a0da..ed18ca9a09 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -709,7 +709,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->application_deployment_queue->addLogEntry("Pulling image ({$this->production_image_name}) that the main server pushed. Build step skipped."); $this->execute_remote_command( [ - "docker pull {$image}", + serverDockerConfigPrefix($this->server)."docker pull {$image}", 'hidden' => true, ], [ @@ -1032,7 +1032,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue $this->write_deployment_configurations(); $this->server = $this->mainServer; $workdir = $this->application->workdir(); - $command = "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}"; + $command = serverDockerConfigPrefix($this->server)."{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}"; } else { $workdir = $this->workdir; $command = executeInDocker($this->deployment_uuid, "{$this->coolify_variables} docker compose --env-file {$workdir}/.env --project-name {$this->application->uuid} --project-directory {$workdir} -f {$workdir}{$this->docker_compose_location} pull --ignore-buildable {$services}"); @@ -1705,7 +1705,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue if (str($this->saved_outputs->get('local_image_found'))->isEmpty() && $this->application->docker_registry_image_name) { $this->application_deployment_queue->addLogEntry("Image ({$this->production_image_name}) not found on the server. Pulling it from the Docker registry."); $this->execute_remote_command([ - 'docker pull '.escapeshellarg($this->production_image_name).' 2>/dev/null', + serverDockerConfigPrefix($this->server).'docker pull '.escapeshellarg($this->production_image_name).' 2>/dev/null', 'ignore_errors' => true, 'hidden' => true, ]); @@ -3124,7 +3124,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue : "-v {$this->serverUserHomeDir}/.docker/buildx:/root/.docker/buildx"; if ($this->use_build_server) { if ($this->dockerConfigFileExists === 'NOK') { - throw new DeploymentException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.'); + throw new DeploymentException("Docker config file (~/.docker/config.json) of the SSH user ({$this->server->user}) not found on the build server ({$this->server->name}). Log in to your registry under Server → Docker Registries on the build server. On a non-root server, a login made with \"sudo docker login\" is saved for root and is not used for builds."); } $runCommand = "docker run -d --name {$this->deployment_uuid} {$env_flags} --rm -v {$this->serverUserHomeDir}/.docker/config.json:/root/.docker/config.json:ro {$buildxMetadataVolume} -v /var/run/docker.sock:/var/run/docker.sock {$helperImage}"; } else { @@ -5256,7 +5256,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } else { if ($this->use_build_server) { $this->execute_remote_command( - ["{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up --pull always --build -d", 'hidden' => true], + [serverDockerConfigPrefix($this->server)."{$this->coolify_variables} docker compose --project-name {$this->application->uuid} --project-directory {$this->configuration_dir} -f {$this->configuration_dir}{$this->docker_compose_location} up --pull always --build -d", 'hidden' => true], ); } else { $this->execute_remote_command( diff --git a/app/Services/GithubRunner/GithubRunnerContainer.php b/app/Services/GithubRunner/GithubRunnerContainer.php index 09839249a5..46b0d4f050 100644 --- a/app/Services/GithubRunner/GithubRunnerContainer.php +++ b/app/Services/GithubRunner/GithubRunnerContainer.php @@ -223,7 +223,7 @@ class GithubRunnerContainer { $image = escapeshellarg($image); - return "docker pull {$image} || docker image inspect {$image} > /dev/null"; + return serverDockerConfigPrefix($this->config->server)."docker pull {$image} || docker image inspect {$image} > /dev/null"; } private function pullIfMissing(string $image): string diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 4676b1e754..f179df1d96 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -526,6 +526,20 @@ function escapeShellValue(string $value): string return "'".str_replace("'", "'\\''", $value)."'"; } +/** + * Prefix for a Docker command that pulls on the server itself, outside the helper container. It makes the CLI use + * the SSH user's Docker config, where the registry logins are and which the helper container mounts. On a non-root + * server the command runs with sudo, which resets HOME to /root, so without it a private image pull is anonymous. + * The SSH user's shell expands $HOME before sudo runs. Without a config of the SSH user, DOCKER_CONFIG is empty and + * the CLI keeps /root/.docker, so a login made with `sudo docker login` still works. DOCKER_CONTEXT=default keeps the + * pull on the host daemon if that config selects another context, because the next commands use the host daemon. + */ +function serverDockerConfigPrefix(Server $server): string +{ + // No && or pipe: with them, the sudo parser can wrap the line in `sudo bash -c`, where $HOME is /root. + return $server->isNonRoot() ? 'DOCKER_CONTEXT=default DOCKER_CONFIG="$(find "$HOME/.docker/config.json" -maxdepth 0 -exec dirname {} \\; 2>/dev/null)" ' : ''; +} + function executeInDocker(string $containerId, string $command) { $escapedCommand = str_replace("'", "'\\''", $command); diff --git a/tests/Feature/DeploymentSensitiveCommandFailureTest.php b/tests/Feature/DeploymentSensitiveCommandFailureTest.php index fe7346a5cd..e8924c758e 100644 --- a/tests/Feature/DeploymentSensitiveCommandFailureTest.php +++ b/tests/Feature/DeploymentSensitiveCommandFailureTest.php @@ -562,6 +562,22 @@ it('resolves a literal vault reference when the application has a secret manager expect($runtime->first(fn (string $line): bool => str_starts_with($line, 'LITERAL_REF=')))->toBe('LITERAL_REF='.escapeComposeEnvFileValue('resolved-remote-value')); }); +it('tells how to log in when the build server has no Docker config for the SSH user', function () { + [$job, $reflection] = makeSensitiveCommandFailureJob(); + $server = readSensitiveFailureJobProperty($job, $reflection, 'server'); + $server->forceFill(['user' => 'coolify', 'name' => 'build-1'])->save(); + setSensitiveFailureJobProperties($job, $reflection, ['server' => $server->fresh(), 'use_build_server' => true]); + Process::fake(fn (PendingProcess $process) => Process::result(output: str_contains($process->command, 'echo $HOME') ? '/home/coolify' : 'NOK')); + + $exception = captureDeploymentException(fn () => invokeSensitiveFailureJobMethod($job, $reflection, 'prepare_builder_image')); + + expect($exception->getMessage()) + ->toContain('of the SSH user (coolify) not found on the build server (build-1)') + ->toContain('Server → Docker Registries') + ->toContain('"sudo docker login" is saved for root'); + Process::assertNotRan(fn (PendingProcess $process) => str_contains($process->command, 'docker run -d')); +}); + it('mounts the seccomp profile from custom docker options into the helper container', function () { [$job, $reflection, $queue, $application] = makeSensitiveCommandFailureJob(); $application->update(['custom_docker_run_options' => '--security-opt seccomp=/etc/docker/seccomp-profile.json']); diff --git a/tests/Feature/NonRootDataDirectoryCommandsTest.php b/tests/Feature/NonRootDataDirectoryCommandsTest.php index a155b2605d..fb99fac25d 100644 --- a/tests/Feature/NonRootDataDirectoryCommandsTest.php +++ b/tests/Feature/NonRootDataDirectoryCommandsTest.php @@ -75,6 +75,8 @@ it('starts a database through the queued executor on a non-root server', functio expect(implode("\n", $lines)) ->toContain("| sudo tee {$configurationDirectory}/README.md > /dev/null") ->toContain("sudo find {$configurationDirectory}/docker-entrypoint-initdb.d -mindepth 1 -maxdepth 1 -exec rm -rf {} +") + // The pull uses the SSH user's Docker config, where the registry logins are; sudo would read /root/.docker. + ->toContain("sudo DOCKER_CONTEXT=default DOCKER_CONFIG=\"\$(sudo find \"\$HOME/.docker/config.json\" -maxdepth 0 -exec dirname {} \\; 2>/dev/null)\" docker compose -f {$configurationDirectory}/docker-compose.yml pull") ->toContain("sudo docker compose -f {$configurationDirectory}/docker-compose.yml up -d"); }); diff --git a/tests/Feature/Proxy/NonRootProxyCommandsTest.php b/tests/Feature/Proxy/NonRootProxyCommandsTest.php index 3718b54d56..71c6d906e4 100644 --- a/tests/Feature/Proxy/NonRootProxyCommandsTest.php +++ b/tests/Feature/Proxy/NonRootProxyCommandsTest.php @@ -79,7 +79,7 @@ it('starts the proxy without entering proxy directories as the SSH user', functi $lines = nonRootProxyScriptLines(); expectNoUnprivilegedProxyFileAccess($lines); expect($lines) - ->toContain("sudo docker compose -f {$proxyPath}/docker-compose.yml pull") + ->toContain("sudo DOCKER_CONTEXT=default DOCKER_CONFIG=\"\$(sudo find \"\$HOME/.docker/config.json\" -maxdepth 0 -exec dirname {} \\; 2>/dev/null)\" docker compose -f {$proxyPath}/docker-compose.yml pull") ->toContain("sudo docker compose -f {$proxyPath}/docker-compose.yml up -d --wait --remove-orphans") // The backfill reads a stored configuration in an `else` branch, which must run through sudo too. ->and(implode("\n", $lines))->toMatch("/^sudo\\s+head -c \\d+ '\\S+\\/docker-compose\\.yml'$/m"); @@ -123,7 +123,7 @@ it('builds restart commands without entering proxy directories as the SSH user', expectNoUnprivilegedProxyFileAccess($lines); expect($lines) - ->toContain("sudo docker compose -f {$proxyPath}/docker-compose.yml pull") + ->toContain("sudo DOCKER_CONTEXT=default DOCKER_CONFIG=\"\$(sudo find \"\$HOME/.docker/config.json\" -maxdepth 0 -exec dirname {} \\; 2>/dev/null)\" docker compose -f {$proxyPath}/docker-compose.yml pull") ->toContain("sudo docker compose -f {$proxyPath}/docker-compose.yml up -d --wait --remove-orphans"); })->with([ 'traefik' => [ProxyTypes::TRAEFIK->value, '/data/coolify/proxy'], diff --git a/tests/Feature/ServiceComposeStdinTest.php b/tests/Feature/ServiceComposeStdinTest.php index 15ee254050..d61723c37c 100644 --- a/tests/Feature/ServiceComposeStdinTest.php +++ b/tests/Feature/ServiceComposeStdinTest.php @@ -84,7 +84,7 @@ esac exit 0 SH); File::put("{$stubDirectory}/touch", "#!/bin/sh\nexit 0\n"); - File::put("{$stubDirectory}/sudo", "#!/bin/sh\nexec \"\$@\"\n"); + File::put("{$stubDirectory}/sudo", "#!/bin/sh\nexec env \"\$@\"\n"); foreach (['docker', 'touch', 'sudo'] as $stub) { chmod("{$stubDirectory}/{$stub}", 0755); } @@ -135,7 +135,7 @@ it('gives service compose up and pull no stdin and never answers the recreate pr expect($lines)->toHaveCount(3); foreach ($lines as $line) { - expect($line)->toStartWith('sudo docker compose ') + expect($line)->toStartWith('sudo DOCKER_CONTEXT=default DOCKER_CONFIG="$(sudo find "$HOME/.docker/config.json" -maxdepth 0 -exec dirname {} \\; 2>/dev/null)" docker compose ') ->toEndWith(' < /dev/null') ->not->toContain(' --yes') ->not->toContain(' -y '); diff --git a/tests/Unit/GithubRunnerContainerTest.php b/tests/Unit/GithubRunnerContainerTest.php index c843007359..856e0e772a 100644 --- a/tests/Unit/GithubRunnerContainerTest.php +++ b/tests/Unit/GithubRunnerContainerTest.php @@ -16,7 +16,7 @@ beforeEach(function () { 'cpu_limit' => '2', 'memory_limit' => '4g', 'runner_image' => null, - ]); + ])->setRelation('server', (new Server)->forceFill(['user' => 'root'])); $this->nonRootServer = Mockery::mock(Server::class)->makePartial(); $this->nonRootServer->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu'); }); @@ -100,6 +100,7 @@ it('quotes a custom runner image', function () { }); it('keeps every command valid for servers with a non-root user', function () { + $this->config->setRelation('server', $this->nonRootServer); $container = new GithubRunnerContainer($this->execution, $this->config); $commands = collect([ ...$container->prepareCommands(), @@ -112,7 +113,7 @@ it('keeps every command valid for servers with a non-root user', function () { $parsed = parseCommandsByLineForSudo($commands, $this->nonRootServer); expect($parsed) - ->toContain('sudo docker pull '."'".config('constants.github_runner.image')."'".' || sudo docker image inspect '."'".config('constants.github_runner.image')."'".' > /dev/null') + ->toContain('sudo DOCKER_CONTEXT=default DOCKER_CONFIG="$(sudo find "$HOME/.docker/config.json" -maxdepth 0 -exec dirname {} \\; 2>/dev/null)" docker pull '."'".config('constants.github_runner.image')."'".' || sudo docker image inspect '."'".config('constants.github_runner.image')."'".' > /dev/null') ->toContain("sudo sh -c 'umask 077 && rm -f /data/coolify/github-runners/abc123.env && echo ".base64_encode("ACTIONS_RUNNER_INPUT_JITCONFIG=SECRET\n")." | base64 -d > /data/coolify/github-runners/abc123.env'") ->toContain('echo '.base64_encode(GithubRunnerContainer::pullRequestHookScript()).' | sudo base64 -d | sudo tee /data/coolify/github-runners/abc123-job-started.sh > /dev/null') ->toContain('sudo chmod 644 /data/coolify/github-runners/abc123-job-started.sh') diff --git a/tests/Unit/HostDockerPullConfigTest.php b/tests/Unit/HostDockerPullConfigTest.php new file mode 100644 index 0000000000..2e91404fb7 --- /dev/null +++ b/tests/Unit/HostDockerPullConfigTest.php @@ -0,0 +1,177 @@ +recordedCommands[] = $command['command'] ?? $command[0]; + } + } +} + +function hostDockerPullServer(string $user): Server +{ + $server = new Server; + $server->id = 1; + $server->user = $user; + + return $server; +} + +function hostDockerPullJob(Server $server, string $buildPack = 'dockerfile'): array +{ + $job = new HostDockerPullRecordingJob; + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + + $application = Mockery::mock(Application::class)->makePartial(); + $application->uuid = 'application-uuid'; + $application->name = 'application'; + $application->build_pack = $buildPack; + $application->docker_registry_image_name = 'ghcr.io/org/app'; + $application->setRelation('settings', new ApplicationSetting(['is_raw_compose_deployment_enabled' => false])); + $application->setRelation('fileStorages', new Collection); + $application->shouldReceive('workdir')->andReturn('/data/coolify/applications/application-uuid'); + + $queue = Mockery::mock(ApplicationDeploymentQueue::class)->makePartial(); + $queue->shouldReceive('getAttribute')->with('updated_at')->andReturn('2026-10-19 00:00:00'); + $queue->shouldReceive('addLogEntry')->andReturnNull(); + + $destination = Mockery::mock(StandaloneDocker::class)->makePartial(); + $destination->shouldReceive('proxyConnectCommand')->andReturn('docker network connect coolify coolify-proxy'); + + foreach ([ + 'application' => $application, + 'application_deployment_queue' => $queue, + 'destination' => $destination, + 'server' => $server, + 'build_server' => hostDockerPullServer('root'), + 'mainServer' => $server, + 'use_build_server' => true, + 'is_this_additional_server' => true, + 'preserveRepository' => true, + 'workdir' => '/artifacts/deployment-uuid', + 'configuration_dir' => '/data/coolify/applications/application-uuid', + 'pull_request_id' => 0, + 'deployment_uuid' => 'deployment-uuid', + 'docker_compose_location' => '/docker-compose.yaml', + 'coolify_variables' => "COOLIFY_URL='https://example.com' COOLIFY_RESOURCE_UUID='application-uuid' ", + 'docker_compose_custom_build_command' => null, + 'generated_docker_compose' => "services:\n db:\n image: 'ghcr.io/org/db:1'\n", + 'production_image_name' => 'ghcr.io/org/app:commit', + 'saved_outputs' => new Collection(['local_image_found' => 'sha256:image']), + ] as $property => $value) { + $reflection->getProperty($property)->setValue($job, $value); + } + + return [$job, $reflection]; +} + +/** + * Runs a command as ExecuteRemoteCommand sends it to the server, with a sudo that resets HOME like env_reset, + * and returns the Docker config directory that each docker call reads, followed by the Docker context that it + * selects. The SSH user's home is shown as /home/administrator. + */ +function hostDockerConfigDirectoryOf(string $command, Server $server, bool $sshUserHasDockerConfig = true, bool $withContext = false): string +{ + $directory = sys_get_temp_dir().'/host-docker-pull-'.uniqid(); + mkdir("{$directory}/bin", recursive: true); + mkdir("{$directory}/home/.docker", recursive: true); + file_put_contents("{$directory}/bin/docker", "#!/bin/sh\nprintf '%s\\n' \"\${DOCKER_CONFIG:-\$HOME/.docker}\"\n".($withContext ? "printf '%s\\n' \"\${DOCKER_CONTEXT:-config.json}\"\n" : '')); + chmod("{$directory}/bin/docker", 0755); + if ($sshUserHasDockerConfig) { + file_put_contents("{$directory}/home/.docker/config.json", '{"auths":{},"currentContext":"rootless"}'); + } + + if ($server->isNonRoot()) { + $command = parseLineForSudo($command, $server); + } + $script = "sudo() { env -i HOME=/root PATH=\"\$PATH\" \"\$@\"; }\n{$command}"; + $process = new Process(['sh', '-c', $script], env: ['HOME' => "{$directory}/home", 'PATH' => "{$directory}/bin:".getenv('PATH')]); + $process->run(); + + (new Filesystem)->deleteDirectory($directory); + + expect($process->getErrorOutput())->toBe(''); + + return str_replace("{$directory}/home", '/home/administrator', trim($process->getOutput())); +} + +afterEach(function () { + Mockery::close(); +}); + +$pulls = [ + 'build server container start' => ['start_by_compose_file', ' up --pull always --build -d'], + 'compose image pull before a build server deployment' => ['pull_docker_compose_images', ' pull --ignore-buildable '], + 'additional server image pull' => ['pull_image_for_additional_server', 'docker pull '], +]; + +it('pulls with the SSH user Docker config, where the registry login is, on a non-root server', function (string $method, string $pull) { + $server = hostDockerPullServer('administrator'); + [$job, $reflection] = hostDockerPullJob($server); + + $reflection->getMethod($method)->invoke($job); + + $command = collect($job->recordedCommands)->first(fn (string $command) => str_contains($command, $pull)); + expect($command)->not->toBeNull() + ->and(hostDockerConfigDirectoryOf($command, $server))->toBe('/home/administrator/.docker'); +})->with($pulls); + +it('pulls on the host daemon when the SSH user Docker config selects another context', function (string $method, string $pull) { + $server = hostDockerPullServer('administrator'); + [$job, $reflection] = hostDockerPullJob($server); + + $reflection->getMethod($method)->invoke($job); + + $command = collect($job->recordedCommands)->first(fn (string $command) => str_contains($command, $pull)); + expect($command)->not->toBeNull() + ->and(hostDockerConfigDirectoryOf($command, $server, withContext: true))->toBe("/home/administrator/.docker\ndefault"); +})->with($pulls); + +it('keeps the root Docker config on a non-root server when the SSH user has no Docker config', function (string $method, string $pull) { + $server = hostDockerPullServer('administrator'); + [$job, $reflection] = hostDockerPullJob($server); + + $reflection->getMethod($method)->invoke($job); + + $command = collect($job->recordedCommands)->first(fn (string $command) => str_contains($command, $pull)); + expect($command)->not->toBeNull() + ->and(hostDockerConfigDirectoryOf($command, $server, sshUserHasDockerConfig: false))->toBe('/root/.docker'); +})->with($pulls); + +it('keeps the pull commands unchanged on a root server', function (string $method, string $pull) { + $server = hostDockerPullServer('root'); + [$job, $reflection] = hostDockerPullJob($server); + + $reflection->getMethod($method)->invoke($job); + + $command = collect($job->recordedCommands)->first(fn (string $command) => str_contains($command, $pull)); + expect($command)->not->toContain('DOCKER_CONFIG'); +})->with($pulls); + +it('checks the registry for a missing image with the SSH user Docker config on a non-root server', function () { + $server = hostDockerPullServer('administrator'); + [$job, $reflection] = hostDockerPullJob($server); + $reflection->getProperty('saved_outputs')->setValue($job, new Collection); + + $reflection->getMethod('check_image_locally_or_remotely')->invoke($job); + + $command = collect($job->recordedCommands)->first(fn (string $command) => str_contains($command, 'docker pull ')); + expect($command)->not->toBeNull() + ->and(hostDockerConfigDirectoryOf($command, $server))->toBe('/home/administrator/.docker'); +});