mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(deploy): use SSH user registry credentials for host image pulls (#12216)
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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 ');
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user