mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-29 10:04:36 +08:00
215 lines
9.6 KiB
PHP
215 lines
9.6 KiB
PHP
<?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, ?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<string> 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<string> $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']);
|