fix(deploy): use SSH user registry credentials for host image pulls (#12216)

This commit is contained in:
Andras Bacsai
2026-10-26 13:00:06 +01:00
committed by GitHub
parent da3e98eff3
commit 1017a5b307
21 changed files with 235 additions and 25 deletions
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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";
}
+1 -1
View File
@@ -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";
}
+1 -1
View File
@@ -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";
}
+1 -1
View File
@@ -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";
}
+1 -1
View File
@@ -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";
}
+1 -1
View File
@@ -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";
}
+1 -1
View File
@@ -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";
}
+1 -1
View File
@@ -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";
+1 -1
View File
@@ -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',
@@ -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';
}
+1 -1
View File
@@ -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";
+5 -5
View File
@@ -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(
@@ -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
+14
View File
@@ -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);
@@ -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']);
@@ -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");
});
@@ -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'],
+2 -2
View File
@@ -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 ');
+3 -2
View File
@@ -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')
+177
View File
@@ -0,0 +1,177 @@
<?php
use App\Jobs\ApplicationDeploymentJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
use App\Models\ApplicationSetting;
use App\Models\Server;
use App\Models\StandaloneDocker;
use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Collection;
use Symfony\Component\Process\Process;
class HostDockerPullRecordingJob extends ApplicationDeploymentJob
{
public array $recordedCommands = [];
public function __construct() {}
public function execute_remote_command(...$commands): void
{
foreach ($commands as $command) {
$this->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');
});