Files
coolify/tests/Feature/DevScriptTest.php
T
Andras BacsaiandClaude Opus 5.5 338f1f837f feat(dev): run one isolated dev instance per git branch
Merge scripts/dev-instances into scripts/dev. Each branch gets its own
Compose project, container, volumes, port block, libvirt network, and
KVM VMs, so stop and start reuse the same data and worktrees do not
collide.

- Name instances after the branch; keep slots and APP_KEYs in the main
  checkout's .dev-instances/ so they survive worktree removal
- Reuse existing QEMU VMs instead of recreating them; add dev:qemu --fresh
- Give each instance an isolated libvirt network (10.221.<slot>.0/24)
  and VM names coolify-dev-<branch>--<profile>
- Publish per-instance browser ports and publish them with tailscale
  serve when APP_URL is a tailnet host
- Add urls, exec, logs, container, destroy, and teardown commands; run
  teardown from jean.json before Jean deletes a worktree
- Fix Reverb/terminal browser ports and the testing-host alias in the
  instance compose file
- Keep fresh worktree instances working: pre-create laravel.log, log the
  host dev:qemu call to stderr, and stop Vite from watching vendor/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 11:55:49 +02:00

266 lines
12 KiB
PHP

<?php
use Symfony\Component\Process\Process;
beforeEach(function () {
$this->devRoot = sys_get_temp_dir().'/coolify-dev-script-'.bin2hex(random_bytes(4));
$main = $this->devRoot.'/main';
$this->devLog = $this->devRoot.'/commands.log';
$this->qemuStorage = $this->devRoot.'/qemu';
mkdir($main.'/scripts', 0777, true);
mkdir($this->devRoot.'/bin');
mkdir($this->qemuStorage);
copy(base_path('scripts/dev'), $main.'/scripts/dev');
chmod($main.'/scripts/dev', 0755);
copy(base_path('docker-compose.dev-multi.yml'), $main.'/docker-compose.dev-multi.yml');
file_put_contents($main.'/.gitignore', ".env\n.dev-instances/\n");
$git = Process::fromShellCommandline(implode(' && ', [
'git init -q -b main',
'git add -A',
'git -c user.name=test -c user.email=test@example.com commit -q -m init',
'git worktree add -q -b fix/Some_thing ../wt',
]), $main);
$git->run();
expect($git->isSuccessful())->toBeTrue($git->getErrorOutput());
$env = "APP_URL=https://devserver.example.ts.net:8000\nDEV_BIND_ADDRESS=127.0.0.1\n";
file_put_contents($main.'/.env', $env);
file_put_contents($this->devRoot.'/wt/.env', $env);
$fake = <<<'BASH'
#!/usr/bin/env bash
printf '%s %s\n' "$(basename "$0")" "$*" >> "$DEV_TEST_LOG"
case "$(basename "$0") $1" in
'docker inspect') echo "${DEV_TEST_HEALTH:-running healthy}" ;;
'docker ps') [[ -n "${DEV_TEST_RUNNING:-}" && "$*" == *"$DEV_TEST_RUNNING"* ]] && echo abc123 ;;
esac
if [[ "$(basename "$0")" == virsh && "$*" == *' list '* ]]; then
printf '%s\n' coolify-dev-fix-some_thing--ubuntu-root coolify-dev-fix-some_thing-2--ubuntu-root coolify-dev-ubuntu-root
fi
exit 0
BASH;
foreach (['docker', 'virsh', 'php', 'tailscale', 'iptables'] as $binary) {
file_put_contents($this->devRoot.'/bin/'.$binary, $fake);
chmod($this->devRoot.'/bin/'.$binary, 0755);
}
});
afterEach(function () {
Process::fromShellCommandline('rm -rf '.escapeshellarg($this->devRoot))->run();
});
function runDevScript(string $directory, array $arguments, array $env = []): Process
{
$process = new Process(['bash', 'scripts/dev', ...$arguments], test()->devRoot.'/'.$directory, [
'PATH' => test()->devRoot.'/bin:'.getenv('PATH'),
'DEV_TEST_LOG' => test()->devLog,
'DEVELOPMENT_QEMU_STORAGE_PATH' => test()->qemuStorage,
'COOLIFY_DEV_SERVER_BACKEND' => 'testing-host',
...$env,
]);
$process->run();
return $process;
}
function devScriptLog(): string
{
return file_exists(test()->devLog) ? file_get_contents(test()->devLog) : '';
}
function devInstanceEnv(string $name): string
{
return file_get_contents(test()->devRoot."/main/.dev-instances/{$name}.env");
}
it('shows usage and rejects invalid arguments', function () {
$help = runDevScript('main', ['--help']);
$extra = runDevScript('main', ['start', 'ubuntu-root', 'extra']);
$unknown = runDevScript('main', ['restart']);
$destroyWithoutName = runDevScript('main', ['destroy']);
expect($help->isSuccessful())->toBeTrue()
->and($help->getOutput())->toContain('start [qemu-profile]', 'stop [qemu-profile]', 'run [qemu-profile]', 'destroy <name>')
->and($extra->isSuccessful())->toBeFalse()
->and($unknown->isSuccessful())->toBeFalse()
->and($destroyWithoutName->isSuccessful())->toBeFalse();
});
it('starts the branch instance of the main checkout on the default ports', function () {
$process = runDevScript('main', ['start']);
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and(devInstanceEnv('main'))
->toContain('COMPOSE_PROJECT_NAME=coolify-dev-main')
->toContain('DEVELOPMENT_QEMU_INSTANCE=main')
->toContain('DEVELOPMENT_QEMU_SLOT=1')
->toContain('APP_URL=https://devserver.example.ts.net:8000')
->toContain('APP_PORT=8000')
->toContain('FORWARD_DB_PORT=5432')
->and(devScriptLog())
->toContain('docker stop coolify coolify-db coolify-redis')
->toContain('docker compose -p coolify-dev-main -f docker-compose.dev-multi.yml --env-file .env --env-file')
->toContain('--profile vite --profile mailpit --profile minio --profile testing-host up -d --build')
->toContain('coolify-dev-main')
->toContain('exec -T coolify php artisan db:seed --class=ServerSeeder --force')
->not->toContain('dev:qemu');
});
it('gives a worktree branch its own port block, tailscale ports, and stable data', function () {
runDevScript('main', ['start']);
file_put_contents($this->devLog, '');
$first = runDevScript('wt', ['start']);
$appKey = preg_replace('/.*^APP_KEY=(\S+)$.*/ms', '$1', devInstanceEnv('fix-some_thing'));
$second = runDevScript('wt', ['start']);
expect($first->isSuccessful())->toBeTrue($first->getErrorOutput())
->and($second->isSuccessful())->toBeTrue($second->getErrorOutput())
->and(devInstanceEnv('fix-some_thing'))
->toContain('COMPOSE_PROJECT_NAME=coolify-dev-fix-some_thing')
->toContain('DEVELOPMENT_QEMU_SLOT=2')
->toContain('APP_URL=https://devserver.example.ts.net:20020')
->toContain('FORWARD_PUSHER_PORT=20021')
->toContain('FORWARD_TERMINAL_PORT=20022')
->toContain('FORWARD_DB_PORT=20023')
->toContain('VITE_PORT=20025')
->toContain("APP_KEY={$appKey}")
->toContain('DEV_CHECKOUT='.realpath($this->devRoot.'/wt'))
->and(file_get_contents($this->devRoot.'/main/.dev-instances/slots'))->toBe("main 1\nfix-some_thing 2\n")
->and(devScriptLog())
->toContain('tailscale serve --bg --https=20020 http://127.0.0.1:20020')
->toContain('tailscale serve --bg --https=20021 http://127.0.0.1:20021')
->toContain('tailscale serve --bg --https=20022 http://127.0.0.1:20022')
->toContain('tailscale serve --bg --https=20025 http://127.0.0.1:20025')
->not->toContain('docker stop coolify ')
->and($this->devRoot.'/wt/.dev-instances')->not->toBeDirectory();
});
it('stops the other running instance of the same checkout', function () {
runDevScript('main', ['start'], ['COOLIFY_DEV_INSTANCE' => 'older']);
file_put_contents($this->devLog, '');
$process = runDevScript('main', ['start'], ['DEV_TEST_RUNNING' => 'coolify-dev-older']);
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and(devScriptLog())->toContain('docker compose -p coolify-dev-older stop');
});
it('fails when the coolify container exits', function () {
$process = runDevScript('main', ['start'], ['DEV_TEST_HEALTH' => 'exited unhealthy']);
expect($process->isSuccessful())->toBeFalse()
->and($process->getErrorOutput())->toContain('did not become healthy');
});
it('removes the containers when a run ends', function () {
$process = runDevScript('wt', ['run']);
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and(devScriptLog())->toContain('logs -f --tail=100')
->toContain(' stop')
->toContain('down --remove-orphans');
});
it('lists instances with their urls and checkouts', function () {
runDevScript('main', ['start']);
runDevScript('wt', ['start']);
$process = runDevScript('wt', ['urls']);
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and($process->getOutput())
->toMatch('/^main\s+https:\/\/devserver\.example\.ts\.net:8000\s+running healthy\s+5432/m')
->toMatch('/^fix-some_thing\s+https:\/\/devserver\.example\.ts\.net:20020\s+running healthy\s+20023/m');
});
it('destroys only the named instance data, vms, and slot', function () {
runDevScript('main', ['start']);
runDevScript('wt', ['start']);
foreach (['coolify-dev-fix-some_thing--ubuntu-root.qcow2', 'coolify-dev-fix-some_thing-2--ubuntu-root.qcow2', 'coolify-dev-ubuntu-root-prepared.qcow2'] as $file) {
touch($this->qemuStorage.'/'.$file);
}
file_put_contents($this->devLog, '');
$process = runDevScript('main', ['destroy', 'fix-some_thing']);
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and(devScriptLog())
->toContain('down --remove-orphans --volumes')
->toContain('virsh --connect qemu:///system undefine coolify-dev-fix-some_thing--ubuntu-root')
->not->toContain('undefine coolify-dev-fix-some_thing-2--ubuntu-root')
->not->toContain('undefine coolify-dev-ubuntu-root')
->toContain('virsh --connect qemu:///system net-undefine coolify-dev-2')
->toContain('tailscale serve --https=20020 off')
->and($this->qemuStorage.'/coolify-dev-fix-some_thing--ubuntu-root.qcow2')->not->toBeFile()
->and($this->qemuStorage.'/coolify-dev-fix-some_thing-2--ubuntu-root.qcow2')->toBeFile()
->and($this->qemuStorage.'/coolify-dev-ubuntu-root-prepared.qcow2')->toBeFile()
->and($this->devRoot.'/main/.dev-instances/fix-some_thing.env')->not->toBeFile()
->and(file_get_contents($this->devRoot.'/main/.dev-instances/slots'))->toBe("main 1\n");
});
it('starts the instance kvm vm with the instance database and names', function () {
if (! function_exists('posix_geteuid') || posix_geteuid() !== 0 || ! file_exists('/dev/kvm') || filetype('/dev/kvm') !== 'char' || ! is_readable('/dev/kvm') || ! is_writable('/dev/kvm')) {
$this->markTestSkipped('KVM and root access are required for this launcher branch.');
}
file_put_contents($this->devRoot.'/bin/php', <<<'BASH'
#!/usr/bin/env bash
printf 'php %s DB=%s:%s LOG=%s QEMU=%s/%s/%s/%s\n' "$*" "$DB_HOST" "$DB_PORT" "$LOG_CHANNEL" "$DEVELOPMENT_QEMU_INSTANCE" "$DEVELOPMENT_QEMU_SLOT" "$DEVELOPMENT_QEMU_DOCKER_NETWORK" "$DEVELOPMENT_QEMU_COOLIFY_CONTAINER" >> "$DEV_TEST_LOG"
BASH);
runDevScript('main', ['start'], ['COOLIFY_DEV_SERVER_BACKEND' => 'auto']);
$start = runDevScript('wt', ['start', 'debian-root'], ['COOLIFY_DEV_SERVER_BACKEND' => 'auto']);
$stop = runDevScript('wt', ['stop'], ['COOLIFY_DEV_SERVER_BACKEND' => 'auto']);
expect($start->isSuccessful())->toBeTrue($start->getErrorOutput())
->and($stop->isSuccessful())->toBeTrue($stop->getErrorOutput())
->and(devScriptLog())
->toContain('php artisan dev:qemu debian-root --as-localhost DB=127.0.0.1:20023 LOG=stderr QEMU=fix-some_thing/2/coolify-dev-fix-some_thing/coolify-dev-fix-some_thing')
->toContain('virsh --connect qemu:///system shutdown coolify-dev-fix-some_thing--ubuntu-root')
->not->toContain('shutdown coolify-dev-fix-some_thing-2--ubuntu-root')
->not->toContain('shutdown coolify-dev-ubuntu-root')
->not->toContain('--profile testing-host up');
});
it('keeps fresh worktree instances writable and stable during the first composer install', function () {
$initSetup = file_get_contents(base_path('docker/development/etc/s6-overlay/scripts/init-setup.sh'));
expect(strpos($initSetup, 'touch storage/logs/laravel.log'))->toBeLessThan(strpos($initSetup, 'chown -R www-data:www-data storage'))
->and(file_get_contents(base_path('vite.config.js')))->toContain('"**/vendor/**"');
});
it('tears down the instance of a deleted worktree', function () {
runDevScript('main', ['start']);
runDevScript('wt', ['start']);
file_put_contents($this->devLog, '');
$process = runDevScript('wt', ['teardown']);
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and(devScriptLog())->toContain('docker compose -p coolify-dev-fix-some_thing')
->toContain('down --remove-orphans --volumes')
->not->toContain('-p coolify-dev-main ')
->and($this->devRoot.'/main/.dev-instances/fix-some_thing.env')->not->toBeFile()
->and($this->devRoot.'/main/.dev-instances/main.env')->toBeFile();
});
it('lets jean delete a worktree that never started an instance', function () {
$process = runDevScript('wt', ['teardown']);
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and($process->getOutput())->toContain('nothing to destroy')
->and(devScriptLog())->not->toContain('docker compose');
});
it('never tears down the main checkout instance', function () {
runDevScript('main', ['start']);
$process = runDevScript('main', ['teardown']);
expect($process->isSuccessful())->toBeFalse()
->and($this->devRoot.'/main/.dev-instances/main.env')->toBeFile();
});