From 99d4071f2cb7a61a1bb605e468c96dbd0c6c338c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:20:19 +0200 Subject: [PATCH] fix(service): read compose up and pull input from /dev/null The service start script goes to the server over stdin. When the configuration of an existing volume changed, docker compose asked to recreate the volume and read the next script line as the answer. That line, for example the proxy network connect, did not run. - Add < /dev/null to the service compose up and pull commands - Do not use --yes, because it recreates the volume and deletes data --- .../Service/DeployServiceApplication.php | 5 +- app/Actions/Service/StartService.php | 8 +- tests/Feature/ServiceComposeStdinTest.php | 140 ++++++++++++++++++ 3 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/ServiceComposeStdinTest.php diff --git a/app/Actions/Service/DeployServiceApplication.php b/app/Actions/Service/DeployServiceApplication.php index b8d1d832fd..4db4834d4c 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}"); + $commands->push("docker compose --project-directory {$safeWorkdir} -f {$safeComposeFile} --project-name {$safeProjectName} pull {$safeComposeServiceName} < /dev/null"); } if ($service->networks()->count() > 0) { @@ -51,7 +51,8 @@ class DeployServiceApplication if ($forceRebuild) { $upCommand .= ' --build'; } - $upCommand .= " {$safeComposeServiceName}"; + // No stdin for Compose prompts: they would read the next script lines (see StartService). + $upCommand .= " {$safeComposeServiceName} < /dev/null"; $commands->push('echo Starting service container.'); $commands->push($upCommand); diff --git a/app/Actions/Service/StartService.php b/app/Actions/Service/StartService.php index ca26ccbd23..aacf2bc0cc 100644 --- a/app/Actions/Service/StartService.php +++ b/app/Actions/Service/StartService.php @@ -42,16 +42,20 @@ class StartService $commands[] = "touch {$workdir}/.env"; $commands = array_merge($commands, EnsureContentFilesOnServer::echoCommands($this->contentFileStorages($service), $service->server)); $commands = array_merge($commands, self::composeVolumeWarningCommands($service)); + // The script reaches the server on stdin. Compose prompts (for example "Volume ... exists but + // doesn't match configuration. Recreate?") would read the next script lines as the answer. + // Give Compose no stdin so it uses the default answer (keep the volume). Never pass --yes: + // it recreates the volume and deletes its data. if ($pullLatestImages) { $commands[] = "echo 'Pulling images.'"; - $commands[] = "docker compose --project-directory {$workdir} pull"; + $commands[] = "docker compose --project-directory {$workdir} pull < /dev/null"; } if ($service->networks()->count() > 0) { $commands[] = "echo 'Creating Docker network.'"; $commands[] = "docker network inspect $service->uuid >/dev/null 2>&1 || docker network create --attachable $service->uuid"; } $commands[] = 'echo Starting service.'; - $commands[] = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --remove-orphans --force-recreate --build"; + $commands[] = "docker compose --project-directory {$workdir} -f {$workdir}/docker-compose.yml --project-name {$service->uuid} up -d --remove-orphans --force-recreate --build < /dev/null"; $commands[] = "docker network connect $service->uuid coolify-proxy >/dev/null 2>&1 || true"; if (data_get($service, 'connect_to_docker_network')) { $compose = data_get($service, 'docker_compose', []); diff --git a/tests/Feature/ServiceComposeStdinTest.php b/tests/Feature/ServiceComposeStdinTest.php new file mode 100644 index 0000000000..f5d48e9a15 --- /dev/null +++ b/tests/Feature/ServiceComposeStdinTest.php @@ -0,0 +1,140 @@ + 0]); + config([ + 'app.maintenance.store' => 'array', + 'constants.ssh.mux_enabled' => false, + ]); + $this->stubDirectory = sys_get_temp_dir().'/service-compose-stdin-'.bin2hex(random_bytes(6)); +}); + +afterEach(function () { + File::deleteDirectory($this->stubDirectory); +}); + +function serviceComposeStdinService(string $sshUser): Service +{ + $team = Team::factory()->create(); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'user' => $sshUser, + 'private_key_id' => PrivateKey::factory()->create(['team_id' => $team->id])->id, + ]); + $destination = StandaloneDocker::query()->where('server_id', $server->id)->firstOrFail(); + $project = Project::factory()->create(['team_id' => $team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + return Service::factory()->create([ + 'environment_id' => $environment->id, + 'server_id' => $server->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'connect_to_docker_network' => true, + 'docker_compose_raw' => "services:\n app:\n image: busybox:latest\n volumes:\n - data:/data\nvolumes:\n data:\n labels:\n example: changed\n", + ]); +} + +/** + * Runs the generated script the way SSH does (`bash -se` with the script on stdin). + * Process is faked for the SSH calls of the action, so this uses Symfony Process directly. + * The `docker compose up`/`pull` stub reads one line from stdin, like the Compose prompt + * "Volume ... exists but doesn't match configuration in compose file. Recreate?". + * + * @return list the commands that ran, and the lines the prompt consumed + */ +function runServiceScriptLikeSsh(string $script, string $stubDirectory): array +{ + File::ensureDirectoryExists($stubDirectory); + $log = "{$stubDirectory}/calls.log"; + File::put("{$stubDirectory}/docker", <<<'SH' +#!/bin/sh +echo "docker $*" >> "$STUB_LOG" +case " $* " in + *" compose "*" up "*|*" compose "*" pull"*) + if IFS= read -r answer; then echo "prompt read: $answer" >> "$STUB_LOG"; fi + ;; +esac +exit 0 +SH); + File::put("{$stubDirectory}/touch", "#!/bin/sh\nexit 0\n"); + File::put("{$stubDirectory}/sudo", "#!/bin/sh\nexec \"\$@\"\n"); + foreach (['docker', 'touch', 'sudo'] as $stub) { + chmod("{$stubDirectory}/{$stub}", 0755); + } + + $process = new SymfonyProcess(['bash', '-se'], env: ['PATH' => $stubDirectory.':'.getenv('PATH'), 'STUB_LOG' => $log], input: $script."\n", timeout: 30); + $process->run(); + + expect($process->isSuccessful())->toBeTrue($process->getErrorOutput()); + + return File::exists($log) ? explode("\n", trim(File::get($log))) : []; +} + +function serviceComposeCommandLines(Activity $activity): array +{ + return array_values(array_filter( + explode("\n", RemoteProcessCommand::read($activity)), + fn (string $line) => str_contains($line, 'docker compose ') && (str_contains($line, ' up -d') || str_contains($line, ' pull')), + )); +} + +it('runs the proxy network connect after a service compose up that reads stdin', function (string $sshUser) { + $service = serviceComposeStdinService($sshUser); + + $activity = StartService::run($service, pullLatestImages: true); + + $calls = runServiceScriptLikeSsh(RemoteProcessCommand::read($activity), $this->stubDirectory); + expect($calls) + ->toContain("docker network connect {$service->uuid} coolify-proxy") + ->and(collect($calls)->filter(fn (string $call) => str_starts_with($call, 'prompt read:'))->all())->toBe([]); +})->with(['root' => 'root', 'non-root' => 'deploy']); + +it('runs the proxy network connect after a single service application compose up that reads stdin', function (string $sshUser) { + $service = serviceComposeStdinService($sshUser); + $service->parse(); + + $activity = DeployServiceApplication::run($service->applications()->firstOrFail(), pullLatestImages: true); + + $calls = runServiceScriptLikeSsh(RemoteProcessCommand::read($activity), $this->stubDirectory); + expect($calls) + ->toContain("docker network connect {$service->uuid} coolify-proxy") + ->and(collect($calls)->filter(fn (string $call) => str_starts_with($call, 'prompt read:'))->all())->toBe([]); +})->with(['root' => 'root', 'non-root' => 'deploy']); + +it('gives service compose up and pull no stdin and never answers the recreate prompt', function () { + $service = serviceComposeStdinService('deploy'); + + $lines = serviceComposeCommandLines(StartService::run($service, pullLatestImages: true)); + + expect($lines)->toHaveCount(2); + foreach ($lines as $line) { + expect($line)->toStartWith('sudo docker compose ') + ->toEndWith(' < /dev/null') + ->not->toContain(' --yes') + ->not->toContain(' -y '); + } +});