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 '); + } +});