mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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', []);
|
||||
|
||||
@@ -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 ');
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user