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
This commit is contained in:
Andras Bacsai
2026-10-05 21:20:19 +02:00
parent c2d82b542d
commit 99d4071f2c
3 changed files with 149 additions and 4 deletions
@@ -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);
+6 -2
View File
@@ -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', []);
+140
View File
@@ -0,0 +1,140 @@
<?php
use App\Actions\Service\DeployServiceApplication;
use App\Actions\Service\StartService;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\StandaloneDocker;
use App\Models\Team;
use App\Support\RemoteProcessCommand;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Spatie\Activitylog\Models\Activity;
use Symfony\Component\Process\Process as SymfonyProcess;
uses(RefreshDatabase::class);
beforeEach(function () {
Server::flushIdentityMap();
Bus::fake();
Process::fake();
InstanceSettings::forceCreate(['id' => 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<string> 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 ');
}
});