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, ?string $composeRaw = null): 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' => $composeRaw ?? "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, string $connectedNetworks = ''): array { File::ensureDirectoryExists($stubDirectory); $log = "{$stubDirectory}/calls.log"; File::put("{$stubDirectory}/docker", <<<'SH' #!/bin/sh echo "docker $*" >> "$STUB_LOG" case " $* " in *" container inspect "*) printf '%s\n' $STUB_CONNECTED_NETWORKS ;; *" 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 env \"\$@\"\n"); foreach (['docker', 'touch', 'sudo'] as $stub) { chmod("{$stubDirectory}/{$stub}", 0755); } $process = new SymfonyProcess(['bash', '-se'], env: ['PATH' => $stubDirectory.':'.getenv('PATH'), 'STUB_LOG' => $log, 'STUB_CONNECTED_NETWORKS' => $connectedNetworks], 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') || str_contains($line, ' start')), )); } 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(3); foreach ($lines as $line) { 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 '); } }); it('runs every service compose command with the compose file that Coolify wrote', function () { $service = serviceComposeStdinService('deploy'); $workdir = $service->workdir(); $lines = serviceComposeCommandLines(StartService::run($service, pullLatestImages: true)); expect($lines)->toHaveCount(3); foreach ($lines as $line) { expect($line)->toContain("-f {$workdir}/docker-compose.yml --project-name {$service->uuid} "); } }); /** * @param list $calls */ function serviceComposeCallIndex(array $calls, string ...$needles): int { foreach ($calls as $index => $call) { if (collect($needles)->every(fn (string $needle) => str_contains($call, $needle))) { return $index; } } throw new RuntimeException('No call contains: '.implode(', ', $needles)); } it('connects service containers to the predefined network before compose starts them', function (string $sshUser) { $compose = "services:\n db:\n image: busybox:latest\n healthcheck:\n test: ['CMD-SHELL', 'true']\n app:\n image: busybox:latest\n depends_on:\n db:\n condition: service_healthy\n"; $service = serviceComposeStdinService($sshUser, $compose); $calls = runServiceScriptLikeSsh(RemoteProcessCommand::read(StartService::run($service)), $this->stubDirectory); $create = serviceComposeCallIndex($calls, ' compose ', ' up -d ', ' --no-start'); $start = serviceComposeCallIndex($calls, ' compose ', " --project-name {$service->uuid} start"); foreach (['db', 'app'] as $name) { $connect = serviceComposeCallIndex($calls, "docker network connect --alias {$name}-{$service->uuid} coolify {$name}-{$service->uuid}"); expect($connect)->toBeGreaterThan($create)->toBeLessThan($start); } })->with(['root' => 'root', 'non-root' => 'deploy']); it('does not connect a running service container that is already on the predefined network', function (string $sshUser) { $service = serviceComposeStdinService($sshUser); $service->parse(); $calls = runServiceScriptLikeSsh(RemoteProcessCommand::read(DeployServiceApplication::run($service->applications()->firstOrFail())), $this->stubDirectory, connectedNetworks: "{$service->uuid} coolify"); expect(collect($calls)->filter(fn (string $call) => str_contains($call, 'network connect --alias'))->all())->toBe([]); })->with(['root' => 'root', 'non-root' => 'deploy']); it('does not connect containers that use a network mode', function () { $compose = "services:\n vpn:\n image: busybox:latest\n app:\n image: busybox:latest\n network_mode: service:vpn\n"; $service = serviceComposeStdinService('root', $compose); $calls = runServiceScriptLikeSsh(RemoteProcessCommand::read(StartService::run($service)), $this->stubDirectory); expect(collect($calls)->filter(fn (string $call) => str_contains($call, 'network connect --alias'))->values()->all()) ->toBe(["docker network connect --alias vpn-{$service->uuid} coolify vpn-{$service->uuid}"]); }); it('connects a single service container to the predefined network before it starts', function (string $sshUser) { $service = serviceComposeStdinService($sshUser); $service->parse(); $calls = runServiceScriptLikeSsh(RemoteProcessCommand::read(DeployServiceApplication::run($service->applications()->firstOrFail())), $this->stubDirectory); $create = serviceComposeCallIndex($calls, ' compose ', ' up -d --no-deps --no-start app'); $connect = serviceComposeCallIndex($calls, "docker network connect --alias app-{$service->uuid} coolify app-{$service->uuid}"); $start = serviceComposeCallIndex($calls, "docker start app-{$service->uuid}"); expect($connect)->toBeGreaterThan($create)->toBeLessThan($start); })->with(['root' => 'root', 'non-root' => 'deploy']);