From 338f1f837fa529547dd9cb46c2b92d274795119e Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:55:49 +0200 Subject: [PATCH] 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..0/24) and VM names coolify-dev--- - 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 --- AGENTS.md | 23 +- .../ConfigureDevelopmentQemuHost.php | 47 +- .../Development/ManageDevelopmentQemuVm.php | 13 +- .../Development/StartDevelopmentQemuVm.php | 79 +++- .../ManageDevelopmentQemuVmCommand.php | 6 +- config/development-qemu.php | 258 ++++++----- docker-compose.dev-kvm.yml | 4 - docker-compose.dev-multi.yml | 70 +-- .../etc/s6-overlay/scripts/init-setup.sh | 2 + jean.json | 2 +- scripts/dev | 429 +++++++++++++++--- scripts/dev-helper | 2 +- scripts/dev-instances | 318 ------------- scripts/seed-transfer-demo.php | 2 +- tests/Feature/DevKvmScriptTest.php | 100 ---- tests/Feature/DevScriptTest.php | 265 +++++++++++ tests/Feature/DevelopmentQemuVmTest.php | 288 +++++++++++- tests/Feature/JeanRunScriptTest.php | 112 +---- .../ReverbAndTerminalPackagingTest.php | 7 +- vite.config.js | 2 +- 20 files changed, 1227 insertions(+), 802 deletions(-) delete mode 100644 docker-compose.dev-kvm.yml delete mode 100755 scripts/dev-instances delete mode 100644 tests/Feature/DevKvmScriptTest.php create mode 100644 tests/Feature/DevScriptTest.php diff --git a/AGENTS.md b/AGENTS.md index dbd3457a5f..a927356737 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,20 +15,21 @@ For UI/UX design specifications, principles, and visual standards, consult the l Docker Compose-based dev setup with services: coolify (app, which also runs Reverb WebSockets and the terminal server), postgres, redis, vite, testing-host, mailpit, minio. ```bash -# Start dev environment (uses docker-compose.dev.yml) -docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d -docker compose -f docker-compose.yml -f docker-compose.dev.yml down # stop services +# One dev instance per git branch (containers, volumes, KVM VMs named after the branch) +./scripts/dev start [qemu-profile] # KVM VM as localhost when /dev/kvm + root/sudo, else testing-host +./scripts/dev stop # stop containers and VMs; data is kept for the next start +./scripts/dev run # start + follow logs, stop on exit (Jean run script) +./scripts/dev urls # all instances, URLs, ports, and checkouts +./scripts/dev exec php artisan migrate # run a command in this branch's Coolify container +./scripts/dev destroy # delete containers, volumes, VMs, and the port slot +./scripts/dev teardown # destroy this worktree's instance (Jean teardown before worktree deletion) +# Compose: docker-compose.dev-multi.yml Env + slots:
/.dev-instances/ (gitignored) -# Two local Coolify instances (isolated stacks; server transfer / multi-control-plane) -./scripts/dev-instances up # a:8000 + b:8001 (uses npm run build for CSS/JS) -./scripts/dev-instances up a --with vite # HMR only when starting a single instance -./scripts/dev-instances urls -./scripts/dev-instances down -# Compose: docker-compose.dev-multi.yml Env: .dev-instances/{a,b}.env (gitignored) -# Note: dual Vite HMR is unsupported (shared public/hot); multi-instance always uses public/build. +# Legacy fixed-name stack (not used by scripts/dev) +docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d ``` -The app runs at `localhost:8000` by default. Instance **b** is on `8001` (db `5433`, redis `6380`, …); see `./scripts/dev-instances`. +The main checkout serves its branch at `localhost:8000` (Reverb `6001`, terminal `6002`, db `5432`, redis `6379`, vite `5173`). Worktrees get a port block at `20000 + slot*10` (app `+0`, Reverb `+1`, terminal `+2`, db `+3`, redis `+4`, vite `+5`). Each instance has its own libvirt network `coolify-dev-` (`10.221..0/24`) and VMs `coolify-dev---`; VMs are reused, use `php artisan dev:qemu --fresh` to rebuild one. If `APP_URL` in `.env` is a `*.ts.net` host, the browser ports are published with `tailscale serve`. Set `COOLIFY_DEV_INSTANCE=` to run another instance from the same checkout. ## Testing the Self-Hosted Upgrade Process diff --git a/app/Actions/Development/ConfigureDevelopmentQemuHost.php b/app/Actions/Development/ConfigureDevelopmentQemuHost.php index 7d6111d5c7..22d816162a 100644 --- a/app/Actions/Development/ConfigureDevelopmentQemuHost.php +++ b/app/Actions/Development/ConfigureDevelopmentQemuHost.php @@ -14,6 +14,7 @@ class ConfigureDevelopmentQemuHost public function handle(): void { $this->ensureDevelopmentEnvironment(); + $this->ensureValidInstance(); $this->installDependencies(); $this->runOrFail('systemctl enable --now libvirtd'); $this->configureLibvirtNetwork(); @@ -45,7 +46,7 @@ class ConfigureDevelopmentQemuHost $networkInfo = Process::run('virsh net-info '.escapeshellarg($network)); if ($networkInfo->failed()) { - $networkXml = config('development-qemu.storage_path').'/libvirt-network.xml'; + $networkXml = config('development-qemu.storage_path')."/libvirt-network-{$network}.xml"; File::ensureDirectoryExists(dirname($networkXml), 0777, true); File::put($networkXml, $this->libvirtNetworkXml($network)); $this->runOrFail('virsh net-define '.escapeshellarg($networkXml)); @@ -82,7 +83,12 @@ class ConfigureDevelopmentQemuHost throw new RuntimeException('Unable to determine the Coolify Docker network subnet.'); } - $rule = sprintf('-s %s -d %s -o virbr0 -j ACCEPT', escapeshellarg($subnet), escapeshellarg(config('development-qemu.subnet'))); + $rule = sprintf( + '-s %s -d %s -o %s -j ACCEPT', + escapeshellarg($subnet), + escapeshellarg(config('development-qemu.subnet')), + escapeshellarg(config('development-qemu.bridge')), + ); Process::run("iptables -D LIBVIRT_FWI {$rule}"); $this->runOrFail("iptables -I LIBVIRT_FWI 1 {$rule}"); @@ -90,14 +96,22 @@ class ConfigureDevelopmentQemuHost private function libvirtNetworkXml(string $network): string { + $bridge = config('development-qemu.bridge'); + $gateway = config('development-qemu.gateway'); + $prefix = (int) config('development-qemu.prefix'); + $networkAddress = ip2long(explode('/', config('development-qemu.subnet'))[0]); + $netmask = long2ip((0xFFFFFFFF << (32 - $prefix)) & 0xFFFFFFFF); + $rangeStart = long2ip($networkAddress + 2); + $rangeEnd = long2ip($networkAddress + (2 ** (32 - $prefix)) - 2); + return << {$network} - - + + - + @@ -119,4 +133,27 @@ XML; throw new RuntimeException('QEMU host configuration may only run in development environments.'); } } + + /** + * Instance names become part of libvirt domain names ("coolify-dev-{instance}--{profile}"), + * so they must not contain repeated, leading, or trailing dashes that would blur that separator. + */ + private function ensureValidInstance(): void + { + $instance = config('development-qemu.instance'); + + if ($instance === null) { + return; + } + + if (! preg_match('/^[a-z0-9_]+(?:-[a-z0-9_]+)*$/', $instance)) { + throw new RuntimeException("Invalid DEVELOPMENT_QEMU_INSTANCE: {$instance}. Use [a-z0-9_-] without leading, trailing, or repeated dashes."); + } + + $slot = config('development-qemu.slot'); + + if (! is_int($slot) || $slot < 1 || $slot > 254) { + throw new RuntimeException('DEVELOPMENT_QEMU_SLOT must be an integer between 1 and 254 when DEVELOPMENT_QEMU_INSTANCE is set.'); + } + } } diff --git a/app/Actions/Development/ManageDevelopmentQemuVm.php b/app/Actions/Development/ManageDevelopmentQemuVm.php index 2101906299..9b1a1c791f 100644 --- a/app/Actions/Development/ManageDevelopmentQemuVm.php +++ b/app/Actions/Development/ManageDevelopmentQemuVm.php @@ -10,20 +10,25 @@ class ManageDevelopmentQemuVm { use AsAction; - /** @param string|array $profileNames */ - public function handle(string|array $profileNames, bool $asLocalhost = false): void + /** + * Start (or create) the selected VMs and seed their servers. Existing VMs are reused unless $fresh is set. + * + * @param string|array $profileNames + */ + public function handle(string|array $profileNames, bool $asLocalhost = false, bool $fresh = false): void { $profileNames = is_array($profileNames) ? array_values(array_unique($profileNames)) : [$profileNames]; foreach ($profileNames as $index => $profileName) { - StartDevelopmentQemuVm::run($profileName, $index === 0); + StartDevelopmentQemuVm::run($profileName, $fresh); try { SeedDevelopmentQemuServer::run($profileName, $index === 0, $asLocalhost); } catch (QueryException $exception) { $keepOthers = $index === 0 ? '' : ' --keep-others'; $localhostOption = $asLocalhost ? ' --as-localhost' : ''; - $result = Process::run('docker exec coolify php artisan dev:qemu:seed '.escapeshellarg($profileName).$keepOthers.$localhostOption); + $container = escapeshellarg(config('development-qemu.coolify_container')); + $result = Process::run("docker exec {$container} php artisan dev:qemu:seed ".escapeshellarg($profileName).$keepOthers.$localhostOption); if ($result->failed()) { throw $exception; diff --git a/app/Actions/Development/StartDevelopmentQemuVm.php b/app/Actions/Development/StartDevelopmentQemuVm.php index 67d770e513..52570dc42e 100644 --- a/app/Actions/Development/StartDevelopmentQemuVm.php +++ b/app/Actions/Development/StartDevelopmentQemuVm.php @@ -12,11 +12,14 @@ class StartDevelopmentQemuVm { use AsAction; - public function handle(string $profileName, bool $resetManagedVms = true): void + /** + * Start the VM of a profile, creating it only when its libvirt domain does not exist yet. + * With $fresh, only this profile's VM is destroyed, undefined, and deleted before it is created again. + */ + public function handle(string $profileName, bool $fresh = false): void { $this->ensureDevelopmentEnvironment(); - $profiles = config('development-qemu.profiles'); - $profile = $profiles[$profileName] ?? null; + $profile = config("development-qemu.profiles.{$profileName}"); if (! is_array($profile)) { throw new InvalidArgumentException("Unknown development QEMU profile: {$profileName}"); @@ -25,15 +28,24 @@ class StartDevelopmentQemuVm ConfigureDevelopmentQemuHost::run(); $this->configureDhcpReservation($profile); - if ($resetManagedVms) { - foreach ($profiles as $managedProfile) { - Process::run('virsh destroy '.escapeshellarg($managedProfile['domain'])); - Process::run('virsh undefine '.escapeshellarg($managedProfile['domain'])); - $this->deleteVmData($managedProfile['domain']); - } + if ($fresh) { + $this->destroyVm($profile['domain']); } - $preparedImage = $this->preparedImage($profile['domain']); + if (! $fresh && $this->domainExists($profile['domain'])) { + $this->startExistingVm($profile['domain']); + } else { + $this->createPreparedVm($profile); + } + + ConfigureDevelopmentQemuHost::run(); + $this->waitForSsh($profile['ip']); + } + + /** @param array{domain: string, template: string, ip: string, user: string, mac: string, image: string, image_url: string, os_variant: string, provisioner: string} $profile */ + private function createPreparedVm(array $profile): void + { + $preparedImage = $this->preparedImage($profile['template']); if (! File::exists($preparedImage)) { $this->createVm($profile, false); @@ -46,19 +58,39 @@ class StartDevelopmentQemuVm } $this->createVm($profile, true); - - ConfigureDevelopmentQemuHost::run(); - $this->waitForSsh($profile['ip']); } - /** @param array{domain: string, ip: string, user: string, mac: string, image: string, image_url: string, os_variant: string, provisioner: string} $profile */ + private function domainExists(string $domain): bool + { + return Process::run('virsh dominfo '.escapeshellarg($domain))->successful(); + } + + private function startExistingVm(string $domain): void + { + $state = trim(Process::run('virsh domstate '.escapeshellarg($domain))->output()); + + if ($state === 'running') { + return; + } + + $this->runOrFail(($state === 'paused' ? 'virsh resume ' : 'virsh start ').escapeshellarg($domain)); + } + + private function destroyVm(string $domain): void + { + Process::run('virsh destroy '.escapeshellarg($domain)); + Process::run('virsh undefine '.escapeshellarg($domain)); + $this->deleteVmData($domain); + } + + /** @param array{domain: string, template: string, ip: string, user: string, mac: string, image: string, image_url: string, os_variant: string, provisioner: string} $profile */ private function createVm(array $profile, bool $prepared): void { $directory = config('development-qemu.storage_path'); File::ensureDirectoryExists($directory); File::chmod($directory, 0777); $this->moveLegacyFiles($directory); - $baseImage = $prepared ? $this->preparedImage($profile['domain']) : "{$directory}/{$profile['image']}"; + $baseImage = $prepared ? $this->preparedImage($profile['template']) : "{$directory}/{$profile['image']}"; $disk = $this->vmDisk($profile['domain']); $userData = "{$directory}/{$profile['domain']}-user-data.yaml"; $metaData = "{$directory}/{$profile['domain']}-meta-data.yaml"; @@ -92,7 +124,7 @@ class StartDevelopmentQemuVm if (! $prepared) { File::put($userData, $this->userData($profile)); - File::put($metaData, "instance-id: {$profile['domain']}\nlocal-hostname: {$profile['domain']}\n"); + File::put($metaData, "instance-id: {$profile['domain']}\nlocal-hostname: {$profile['template']}\n"); File::put($networkConfig, $this->networkConfig($profile)); $this->runOrFail(sprintf( 'xorriso -as mkisofs -V cidata -graft-points -o %s %s %s %s', @@ -104,9 +136,11 @@ class StartDevelopmentQemuVm } $seedDisk = $prepared ? '' : ' --disk path='.escapeshellarg($seedImage).',format=raw,bus=virtio,readonly=on'; + // Instance networks are isolated, so VMs of different instances may share the profile MAC. + $macCheck = config('development-qemu.instance') === null ? '' : ' --check mac_in_use=off'; $this->runOrFail(sprintf( - 'virt-install --connect qemu:///system --name %s --memory %d --vcpus %d --import --os-variant %s --disk path=%s,format=qcow2,bus=virtio%s --network network=%s,model=virtio,mac=%s --noautoconsole', + 'virt-install --connect qemu:///system --name %s --memory %d --vcpus %d --import --os-variant %s --disk path=%s,format=qcow2,bus=virtio%s --network network=%s,model=virtio,mac=%s --noautoconsole'.$macCheck, escapeshellarg($profile['domain']), config('development-qemu.memory'), config('development-qemu.vcpus'), @@ -123,9 +157,12 @@ class StartDevelopmentQemuVm return config('development-qemu.storage_path')."/{$domain}.qcow2"; } - private function preparedImage(string $domain): string + /** + * Prepared images are keyed by the instance-independent template name so every dev instance reuses them. + */ + private function preparedImage(string $template): string { - return config('development-qemu.storage_path')."/{$domain}-prepared.qcow2"; + return config('development-qemu.storage_path')."/{$template}-prepared.qcow2"; } private function waitForPreparation(string $domain): void @@ -227,7 +264,7 @@ ethernets: YAML; } - /** @param array{domain: string, ip: string, mac: string} $profile */ + /** @param array{template: string, ip: string, mac: string} $profile */ private function configureDhcpReservation(array $profile): void { $network = escapeshellarg(config('development-qemu.libvirt_network')); @@ -241,7 +278,7 @@ YAML; return; } - $host = sprintf("", $profile['mac'], $profile['domain'], $profile['ip']); + $host = sprintf("", $profile['mac'], $profile['template'], $profile['ip']); $this->runOrFail("virsh net-update {$network} add-last ip-dhcp-host ".escapeshellarg($host).' --live --config'); } diff --git a/app/Console/Commands/ManageDevelopmentQemuVmCommand.php b/app/Console/Commands/ManageDevelopmentQemuVmCommand.php index 91c2f855e9..1dc235d0d0 100644 --- a/app/Console/Commands/ManageDevelopmentQemuVmCommand.php +++ b/app/Console/Commands/ManageDevelopmentQemuVmCommand.php @@ -9,9 +9,9 @@ use function Laravel\Prompts\multiselect; class ManageDevelopmentQemuVmCommand extends Command { - protected $signature = 'dev:qemu {profiles?* : Profile keys from config/development-qemu.php} {--as-localhost : Use the VM for the localhost server (id 0)}'; + protected $signature = 'dev:qemu {profiles?* : Profile keys from config/development-qemu.php} {--as-localhost : Use the VM for the localhost server (id 0)} {--fresh : Destroy and recreate the selected VMs instead of reusing them}'; - protected $description = 'Recreate selected development QEMU VMs and seed their Coolify servers'; + protected $description = 'Start (or create) selected development QEMU VMs and seed their Coolify servers'; public function handle(): int { @@ -34,7 +34,7 @@ class ManageDevelopmentQemuVmCommand extends Command return self::FAILURE; } - ManageDevelopmentQemuVm::run($profileNames, (bool) $this->option('as-localhost')); + ManageDevelopmentQemuVm::run($profileNames, (bool) $this->option('as-localhost'), (bool) $this->option('fresh')); foreach ($profileNames as $profileName) { $profile = $profiles[$profileName]; diff --git a/config/development-qemu.php b/config/development-qemu.php index ae77c8341e..ed93362258 100644 --- a/config/development-qemu.php +++ b/config/development-qemu.php @@ -1,124 +1,156 @@ [ + 'label' => 'Ubuntu 24.04 (root)', + 'domain' => 'coolify-dev-ubuntu-root', + 'uuid' => 'development-qemu-ubuntu-root', + 'name' => 'QEMU Ubuntu (root)', + 'ip' => '192.168.122.10', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:01', + 'image' => 'ubuntu-noble-amd64.qcow2', + 'image_url' => 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img', + 'os_variant' => 'ubuntu24.04', + 'provisioner' => 'apt', + ], + 'ubuntu-non-root' => [ + 'label' => 'Ubuntu 24.04 (non-root)', + 'domain' => 'coolify-dev-ubuntu-non-root', + 'uuid' => 'development-qemu-ubuntu-non-root', + 'name' => 'QEMU Ubuntu (non-root)', + 'ip' => '192.168.122.11', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:02', + 'image' => 'ubuntu-noble-amd64.qcow2', + 'image_url' => 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img', + 'os_variant' => 'ubuntu24.04', + 'provisioner' => 'apt', + ], + 'debian-root' => [ + 'label' => 'Debian 12 (root)', + 'domain' => 'coolify-dev-debian-root', + 'uuid' => 'development-qemu-debian-root', + 'name' => 'QEMU Debian (root)', + 'ip' => '192.168.122.20', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:03', + 'image' => 'debian-12-amd64.qcow2', + 'image_url' => 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2', + 'os_variant' => 'debian12', + 'provisioner' => 'apt', + ], + 'debian-non-root' => [ + 'label' => 'Debian 12 (non-root)', + 'domain' => 'coolify-dev-debian-non-root', + 'uuid' => 'development-qemu-debian-non-root', + 'name' => 'QEMU Debian (non-root)', + 'ip' => '192.168.122.21', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:04', + 'image' => 'debian-12-amd64.qcow2', + 'image_url' => 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2', + 'os_variant' => 'debian12', + 'provisioner' => 'apt', + ], + 'centos-root' => [ + 'label' => 'CentOS Stream 9 (root)', + 'domain' => 'coolify-dev-centos-root', + 'uuid' => 'development-qemu-centos-root', + 'name' => 'QEMU CentOS Stream (root)', + 'ip' => '192.168.122.30', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:05', + 'image' => 'centos-stream-9-amd64.qcow2', + 'image_url' => 'https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2', + 'os_variant' => 'centos-stream9', + 'provisioner' => 'rpm', + ], + 'centos-non-root' => [ + 'label' => 'CentOS Stream 9 (non-root)', + 'domain' => 'coolify-dev-centos-non-root', + 'uuid' => 'development-qemu-centos-non-root', + 'name' => 'QEMU CentOS Stream (non-root)', + 'ip' => '192.168.122.31', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:06', + 'image' => 'centos-stream-9-amd64.qcow2', + 'image_url' => 'https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2', + 'os_variant' => 'centos-stream9', + 'provisioner' => 'rpm', + ], + 'alpine-root' => [ + 'label' => 'Alpine Linux 3.24 (root)', + 'domain' => 'coolify-dev-alpine-root', + 'uuid' => 'development-qemu-alpine-root', + 'name' => 'QEMU Alpine (root)', + 'ip' => '192.168.122.40', + 'user' => 'root', + 'mac' => '52:54:00:ca:00:07', + 'image' => 'alpine-3.24-amd64.qcow2', + 'image_url' => 'https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/cloud/generic_alpine-3.24.1-x86_64-bios-cloudinit-r0.qcow2', + 'os_variant' => 'generic', + 'provisioner' => 'apk', + 'interface' => 'eth0', + ], + 'alpine-non-root' => [ + 'label' => 'Alpine Linux 3.24 (non-root)', + 'domain' => 'coolify-dev-alpine-non-root', + 'uuid' => 'development-qemu-alpine-non-root', + 'name' => 'QEMU Alpine (non-root)', + 'ip' => '192.168.122.41', + 'user' => 'coolify', + 'mac' => '52:54:00:ca:00:08', + 'image' => 'alpine-3.24-amd64.qcow2', + 'image_url' => 'https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/cloud/generic_alpine-3.24.1-x86_64-bios-cloudinit-r0.qcow2', + 'os_variant' => 'generic', + 'provisioner' => 'apk', + 'interface' => 'eth0', + ], +]; + +foreach ($developmentQemuProfiles as $developmentQemuProfileKey => $developmentQemuProfile) { + $developmentQemuProfile['template'] = "coolify-dev-{$developmentQemuProfileKey}"; + + if ($developmentQemuInstance !== null) { + $developmentQemuProfile['domain'] = "coolify-dev-{$developmentQemuInstance}--{$developmentQemuProfileKey}"; + $developmentQemuProfile['ip'] = "10.221.{$developmentQemuSlot}.".substr(strrchr($developmentQemuProfile['ip'], '.'), 1); + } + + $developmentQemuProfiles[$developmentQemuProfileKey] = $developmentQemuProfile; +} + return [ 'public_key' => 'ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFuGmoeGq/pojrsyP1pszcNVuZx9iFkCELtxrh31QJ68', 'storage_path' => env('DEVELOPMENT_QEMU_STORAGE_PATH', '/var/lib/libvirt/images/coolify-development'), - 'gateway' => '192.168.122.1', - 'subnet' => '192.168.122.0/24', + 'instance' => $developmentQemuInstance, + 'slot' => $developmentQemuSlot, + 'gateway' => $developmentQemuInstance === null ? '192.168.122.1' : "10.221.{$developmentQemuSlot}.1", + 'subnet' => $developmentQemuInstance === null ? '192.168.122.0/24' : "10.221.{$developmentQemuSlot}.0/24", 'prefix' => 24, 'dns' => '1.1.1.1', 'memory' => 2048, 'vcpus' => 2, 'disk_size' => '20G', - 'libvirt_network' => 'default', - 'docker_network' => 'coolify', - 'coolify_container' => 'coolify', - 'profiles' => [ - 'ubuntu-root' => [ - 'label' => 'Ubuntu 24.04 (root)', - 'domain' => 'coolify-dev-ubuntu-root', - 'uuid' => 'development-qemu-ubuntu-root', - 'name' => 'QEMU Ubuntu (root)', - 'ip' => '192.168.122.10', - 'user' => 'root', - 'mac' => '52:54:00:ca:00:01', - 'image' => 'ubuntu-noble-amd64.qcow2', - 'image_url' => 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img', - 'os_variant' => 'ubuntu24.04', - 'provisioner' => 'apt', - ], - 'ubuntu-non-root' => [ - 'label' => 'Ubuntu 24.04 (non-root)', - 'domain' => 'coolify-dev-ubuntu-non-root', - 'uuid' => 'development-qemu-ubuntu-non-root', - 'name' => 'QEMU Ubuntu (non-root)', - 'ip' => '192.168.122.11', - 'user' => 'coolify', - 'mac' => '52:54:00:ca:00:02', - 'image' => 'ubuntu-noble-amd64.qcow2', - 'image_url' => 'https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img', - 'os_variant' => 'ubuntu24.04', - 'provisioner' => 'apt', - ], - 'debian-root' => [ - 'label' => 'Debian 12 (root)', - 'domain' => 'coolify-dev-debian-root', - 'uuid' => 'development-qemu-debian-root', - 'name' => 'QEMU Debian (root)', - 'ip' => '192.168.122.20', - 'user' => 'root', - 'mac' => '52:54:00:ca:00:03', - 'image' => 'debian-12-amd64.qcow2', - 'image_url' => 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2', - 'os_variant' => 'debian12', - 'provisioner' => 'apt', - ], - 'debian-non-root' => [ - 'label' => 'Debian 12 (non-root)', - 'domain' => 'coolify-dev-debian-non-root', - 'uuid' => 'development-qemu-debian-non-root', - 'name' => 'QEMU Debian (non-root)', - 'ip' => '192.168.122.21', - 'user' => 'coolify', - 'mac' => '52:54:00:ca:00:04', - 'image' => 'debian-12-amd64.qcow2', - 'image_url' => 'https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2', - 'os_variant' => 'debian12', - 'provisioner' => 'apt', - ], - 'centos-root' => [ - 'label' => 'CentOS Stream 9 (root)', - 'domain' => 'coolify-dev-centos-root', - 'uuid' => 'development-qemu-centos-root', - 'name' => 'QEMU CentOS Stream (root)', - 'ip' => '192.168.122.30', - 'user' => 'root', - 'mac' => '52:54:00:ca:00:05', - 'image' => 'centos-stream-9-amd64.qcow2', - 'image_url' => 'https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2', - 'os_variant' => 'centos-stream9', - 'provisioner' => 'rpm', - ], - 'centos-non-root' => [ - 'label' => 'CentOS Stream 9 (non-root)', - 'domain' => 'coolify-dev-centos-non-root', - 'uuid' => 'development-qemu-centos-non-root', - 'name' => 'QEMU CentOS Stream (non-root)', - 'ip' => '192.168.122.31', - 'user' => 'coolify', - 'mac' => '52:54:00:ca:00:06', - 'image' => 'centos-stream-9-amd64.qcow2', - 'image_url' => 'https://cloud.centos.org/centos/9-stream/x86_64/images/CentOS-Stream-GenericCloud-9-latest.x86_64.qcow2', - 'os_variant' => 'centos-stream9', - 'provisioner' => 'rpm', - ], - 'alpine-root' => [ - 'label' => 'Alpine Linux 3.24 (root)', - 'domain' => 'coolify-dev-alpine-root', - 'uuid' => 'development-qemu-alpine-root', - 'name' => 'QEMU Alpine (root)', - 'ip' => '192.168.122.40', - 'user' => 'root', - 'mac' => '52:54:00:ca:00:07', - 'image' => 'alpine-3.24-amd64.qcow2', - 'image_url' => 'https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/cloud/generic_alpine-3.24.1-x86_64-bios-cloudinit-r0.qcow2', - 'os_variant' => 'generic', - 'provisioner' => 'apk', - 'interface' => 'eth0', - ], - 'alpine-non-root' => [ - 'label' => 'Alpine Linux 3.24 (non-root)', - 'domain' => 'coolify-dev-alpine-non-root', - 'uuid' => 'development-qemu-alpine-non-root', - 'name' => 'QEMU Alpine (non-root)', - 'ip' => '192.168.122.41', - 'user' => 'coolify', - 'mac' => '52:54:00:ca:00:08', - 'image' => 'alpine-3.24-amd64.qcow2', - 'image_url' => 'https://dl-cdn.alpinelinux.org/alpine/latest-stable/releases/cloud/generic_alpine-3.24.1-x86_64-bios-cloudinit-r0.qcow2', - 'os_variant' => 'generic', - 'provisioner' => 'apk', - 'interface' => 'eth0', - ], - ], + 'libvirt_network' => $developmentQemuInstance === null ? 'default' : "coolify-dev-{$developmentQemuSlot}", + 'bridge' => $developmentQemuInstance === null ? 'virbr0' : "cdevbr{$developmentQemuSlot}", + 'docker_network' => env('DEVELOPMENT_QEMU_DOCKER_NETWORK') ?: 'coolify', + 'coolify_container' => env('DEVELOPMENT_QEMU_COOLIFY_CONTAINER') ?: 'coolify', + 'profiles' => $developmentQemuProfiles, ]; diff --git a/docker-compose.dev-kvm.yml b/docker-compose.dev-kvm.yml deleted file mode 100644 index 32cf1c0db1..0000000000 --- a/docker-compose.dev-kvm.yml +++ /dev/null @@ -1,4 +0,0 @@ -# Opt-in KVM development mode. The default Compose setup still starts testing-host. -services: - testing-host: - profiles: ["testing-host"] diff --git a/docker-compose.dev-multi.yml b/docker-compose.dev-multi.yml index c66a29948d..57cc18498b 100644 --- a/docker-compose.dev-multi.yml +++ b/docker-compose.dev-multi.yml @@ -1,21 +1,18 @@ -# Multi-instance Coolify (isolated project/network/volumes per instance). +# Development stack used by scripts/dev: one isolated Compose project per dev +# instance (git branch), with its own container names, network, volumes, and ports. # -# ./scripts/dev-instances up # a + b -# ./scripts/dev-instances urls -# ./scripts/dev-instances down +# ./scripts/dev start # this branch's instance (KVM or testing-host) +# ./scripts/dev urls # all instances and their ports +# ./scripts/dev stop # -# Manual: -# docker compose -p coolify-a -f docker-compose.dev-multi.yml --env-file .dev-instances/a.env up -d -# -# Optional profiles: vite, mailpit, minio, testing-host -# ./scripts/dev-instances up a --with vite mailpit -# -# Ports (a / b): app 8000/8001, db 5432/5433, redis 6379/6380, Reverb 6001/6011 +# Env: /.env, then
/.dev-instances/.env (generated). +# Optional profiles: vite, mailpit, minio, testing-host (scripts/dev enables them). services: coolify: image: coolify:dev pull_policy: never + container_name: "${COMPOSE_PROJECT_NAME:-coolify-dev}" build: context: . dockerfile: ./docker/development/Dockerfile @@ -23,9 +20,9 @@ services: - USER_ID=${USERID:-1000} - GROUP_ID=${GROUPID:-1000} ports: - - "${APP_PORT:-8000}:8080" - - "${FORWARD_PUSHER_PORT:-6001}:6001" - - "${FORWARD_TERMINAL_PORT:-6002}:6002" + - "${DEV_BIND_ADDRESS:-0.0.0.0}:${APP_PORT:-8000}:8080" + - "${DEV_BIND_ADDRESS:-0.0.0.0}:${FORWARD_PUSHER_PORT:-6001}:6001" + - "${DEV_BIND_ADDRESS:-0.0.0.0}:${FORWARD_TERMINAL_PORT:-6002}:6002" extra_hosts: - "host.docker.internal:host-gateway" environment: @@ -44,11 +41,13 @@ services: REDIS_HOST: redis REDIS_PORT: 6379 REDIS_PASSWORD: "${REDIS_PASSWORD:-null}" - COOLIFY_CONTAINER_ROLE: all + COOLIFY_CONTAINER_ROLE: "${COOLIFY_CONTAINER_ROLE:-all}" PUSHER_HOST: "${PUSHER_HOST:-}" - PUSHER_PORT: 6001 + # Browser-facing ports must match the host-published ports of this instance. + PUSHER_PORT: "${FORWARD_PUSHER_PORT:-6001}" + TERMINAL_PORT: "${FORWARD_TERMINAL_PORT:-6002}" PUSHER_BACKEND_PORT: 6001 - PUSHER_SCHEME: http + PUSHER_SCHEME: "${PUSHER_SCHEME:-http}" PUSHER_APP_ID: "${PUSHER_APP_ID:-coolify}" PUSHER_APP_KEY: "${PUSHER_APP_KEY:-coolify}" PUSHER_APP_SECRET: "${PUSHER_APP_SECRET:-coolify}" @@ -56,6 +55,11 @@ services: QUEUE_CONNECTION: redis CACHE_STORE: redis SESSION_DRIVER: redis + # Same values as the host-side `php artisan dev:qemu` call (config/development-qemu.php). + DEVELOPMENT_QEMU_INSTANCE: "${DEVELOPMENT_QEMU_INSTANCE:-}" + DEVELOPMENT_QEMU_SLOT: "${DEVELOPMENT_QEMU_SLOT:-}" + DEVELOPMENT_QEMU_DOCKER_NETWORK: "${COMPOSE_PROJECT_NAME:-coolify-dev}" + DEVELOPMENT_QEMU_COOLIFY_CONTAINER: "${COMPOSE_PROJECT_NAME:-coolify-dev}" healthcheck: test: curl -sf http://127.0.0.1:8080/api/health || exit 1 interval: 5s @@ -78,7 +82,7 @@ services: image: postgres:15-alpine pull_policy: always ports: - - "${FORWARD_DB_PORT:-5432}:5432" + - "${DEV_BIND_ADDRESS:-0.0.0.0}:${FORWARD_DB_PORT:-5432}:5432" environment: POSTGRES_USER: "${DB_USERNAME:-coolify}" POSTGRES_PASSWORD: "${DB_PASSWORD:-password}" @@ -98,7 +102,7 @@ services: image: redis:7-alpine pull_policy: always ports: - - "${FORWARD_REDIS_PORT:-6379}:6379" + - "${DEV_BIND_ADDRESS:-0.0.0.0}:${FORWARD_REDIS_PORT:-6379}:6379" healthcheck: test: redis-cli ping interval: 5s @@ -115,13 +119,15 @@ services: pull_policy: always working_dir: /var/www/html environment: - VITE_HOST: localhost + # Set VITE_HOST in .env to a browser-reachable IP/hostname for LAN/Tailscale access + VITE_HOST: "${VITE_HOST:-localhost}" VITE_PORT: "${VITE_PORT:-5173}" + VITE_PROTOCOL: "${VITE_PROTOCOL:-http}" ports: - - "${VITE_PORT:-5173}:${VITE_PORT:-5173}" + - "${DEV_BIND_ADDRESS:-0.0.0.0}:${VITE_PORT:-5173}:${VITE_PORT:-5173}" volumes: - .:/var/www/html/:cached - command: sh -c "npm install && npm run dev -- --port ${VITE_PORT:-5173} --host" + command: sh -c "npm install && npm run dev" networks: - coolify @@ -136,20 +142,25 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock - coolify_data:/data/coolify + # Same path as on the Docker host, so bind mounts created through the host daemon resolve. + - coolify_data:/var/lib/docker/volumes/${COMPOSE_PROJECT_NAME:-coolify-dev}_coolify_data/_data - backups_data:/data/coolify/backups - postgres_data:/data/coolify/_volumes/database - redis_data:/data/coolify/_volumes/redis - minio_data:/data/coolify/_volumes/minio networks: - - coolify + coolify: + # Seeders use this hostname for the localhost server. + aliases: + - coolify-testing-host mailpit: profiles: ["mailpit"] image: axllent/mailpit:latest pull_policy: always ports: - - "${FORWARD_MAILPIT_PORT:-1025}:1025" - - "${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025" + - "${DEV_BIND_ADDRESS:-0.0.0.0}:${FORWARD_MAILPIT_PORT:-1025}:1025" + - "${DEV_BIND_ADDRESS:-0.0.0.0}:${FORWARD_MAILPIT_DASHBOARD_PORT:-8025}:8025" networks: - coolify @@ -158,11 +169,11 @@ services: image: coollabsio/maxio:latest pull_policy: always ports: - - "${FORWARD_MINIO_PORT:-9000}:9000" - - "${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001" + - "${DEV_BIND_ADDRESS:-0.0.0.0}:${FORWARD_MINIO_PORT:-9000}:9000" + - "${DEV_BIND_ADDRESS:-0.0.0.0}:${FORWARD_MINIO_PORT_CONSOLE:-9001}:9001" environment: - MINIO_ACCESS_KEY: minioadmin - MINIO_SECRET_KEY: minioadmin + MINIO_ACCESS_KEY: "${MINIO_ACCESS_KEY:-minioadmin}" + MINIO_SECRET_KEY: "${MINIO_SECRET_KEY:-minioadmin}" volumes: - minio_data:/data networks: @@ -192,4 +203,5 @@ volumes: networks: coolify: + name: "${COMPOSE_PROJECT_NAME:-coolify-dev}" driver: bridge diff --git a/docker/development/etc/s6-overlay/scripts/init-setup.sh b/docker/development/etc/s6-overlay/scripts/init-setup.sh index e800ed6cde..78390205eb 100755 --- a/docker/development/etc/s6-overlay/scripts/init-setup.sh +++ b/docker/development/etc/s6-overlay/scripts/init-setup.sh @@ -13,6 +13,8 @@ prepare_bind_mount() { storage/framework/views \ storage/logs \ bootstrap/cache + # Create the shared log before root processes (migrate, scheduler) can create it root-owned. + touch storage/logs/laravel.log if [ "$(id -u)" = "0" ]; then # Top-level only: allows creating vendor/ when the mount is root-owned diff --git a/jean.json b/jean.json index ac29e92ebc..5b2e5fc009 100644 --- a/jean.json +++ b/jean.json @@ -1,7 +1,7 @@ { "scripts": { "setup": "cp $JEAN_ROOT_PATH/.env .", - "teardown": null, + "teardown": "./scripts/dev teardown", "run": "./scripts/dev run" }, "ports": [ diff --git a/scripts/dev b/scripts/dev index fa4d7cd8d6..1f47900b54 100755 --- a/scripts/dev +++ b/scripts/dev @@ -1,55 +1,200 @@ #!/usr/bin/env bash -# Manage the development stack with KVM when available, or testing-host otherwise. -# Jean uses `run` to keep logs attached and clean up when the run ends. +# Manage the development instance of the current git branch. +# +# Usage: +# ./scripts/dev start [qemu-profile] # start this branch's instance (KVM VM or testing-host) +# ./scripts/dev stop [qemu-profile] # stop containers and VMs; data is kept +# ./scripts/dev run [qemu-profile] # start, follow logs, stop on exit (Jean) +# ./scripts/dev urls # all instances, ports, and state +# ./scripts/dev ps | logs | exec | container +# ./scripts/dev destroy # delete containers, volumes, VMs, and the port slot +# ./scripts/dev teardown # destroy this worktree's instance if it exists (Jean) +# +# Each branch is one instance: Compose project, container, volumes, and KVM VMs are +# named after it, so stop + start keeps the same data. Detached HEAD uses the +# checkout directory name. Set COOLIFY_DEV_INSTANCE= to override. +# +# Ports: the main checkout uses 8000 (Reverb 6001, terminal 6002, db 5432, redis 6379, +# vite 5173). Worktrees use 20000 + slot*10: app +0, Reverb +1, terminal +2, db +3, +# redis +4, vite +5, mailpit +6/+7, minio +8/+9. +# +# Env: COOLIFY_DEV_SERVER_BACKEND=auto|testing-host, COOLIFY_DEV_KVM_PROFILE= +# set -euo pipefail cd "$(dirname "$0")/.." +ROOT="$(pwd -P)" + +COMPOSE_FILE="docker-compose.dev-multi.yml" +GIT_COMMON_DIR="$(git rev-parse --path-format=absolute --git-common-dir)" +# Shared by all worktrees, so slots, APP_KEYs, and data survive worktree removal. +ENV_DIR="$(dirname "$GIT_COMMON_DIR")/.dev-instances" +SLOTS_FILE="$ENV_DIR/slots" +QEMU_STORAGE="${DEVELOPMENT_QEMU_STORAGE_PATH:-/var/lib/libvirt/images/coolify-development}" +LEGACY_CONTAINERS=(coolify coolify-db coolify-redis coolify-vite coolify-mail coolify-minio coolify-minio-init coolify-testing-host) usage() { - echo 'Usage: ./scripts/dev start [qemu-profile]' - echo ' ./scripts/dev stop [qemu-profile]' - echo ' ./scripts/dev run [qemu-profile]' + sed -n '2,22p' "$0" | sed 's/^# \?//' } -if [[ "${1:-}" == "--help" ]]; then - usage - exit 0 -fi +normalize_name() { + local name + name="$(tr '[:upper:]' '[:lower:]' <<<"$1" | sed -E 's/[^a-z0-9_]+/-/g; s/^-+//; s/-+$//')" + if [[ ! "$name" =~ ^[a-z0-9_]+(-[a-z0-9_]+)*$ ]]; then + echo "Invalid instance name '$1'" >&2 + exit 1 + fi + echo "$name" +} -if [[ $# -gt 2 || ( "${1:-start}" != "start" && "${1:-start}" != "stop" && "${1:-start}" != "run" ) ]]; then - usage >&2 - exit 2 -fi +is_linked_worktree() { + [[ "$(git rev-parse --path-format=absolute --git-dir)" != "$GIT_COMMON_DIR" ]] +} -command="${1:-start}" -profile="${2:-${COOLIFY_DEV_KVM_PROFILE:-ubuntu-root}}" -backend="${COOLIFY_DEV_SERVER_BACKEND:-auto}" -if [[ "$backend" != "auto" && "$backend" != "testing-host" ]]; then - echo 'COOLIFY_DEV_SERVER_BACKEND must be auto or testing-host.' >&2 - exit 2 -fi +current_instance() { + if [[ -n "${COOLIFY_DEV_INSTANCE:-}" ]]; then + normalize_name "$COOLIFY_DEV_INSTANCE" + return + fi + normalize_name "$(git symbolic-ref --quiet --short HEAD || basename "$ROOT")" +} -privileged=() -if [[ $EUID -ne 0 ]]; then - privileged=(sudo -n -E) -fi +project_name() { echo "coolify-dev-$1"; } +env_file() { echo "$ENV_DIR/$1.env"; } +env_value() { grep -E "^$2=" "$(env_file "$1")" | cut -d= -f2-; } -if [[ "$backend" == "auto" && -c /dev/kvm && -r /dev/kvm && -w /dev/kvm ]] && - { [[ $EUID -eq 0 ]] || { command -v sudo >/dev/null && sudo -n -E true; }; }; then - backend=kvm -else - backend=testing-host -fi +# Lowest free slot (1..254), stable per instance name. It selects the port block +# and the libvirt network (10.221..0/24) of the instance. +instance_slot() { + local name="$1" slot + mkdir -p "$ENV_DIR" + touch "$SLOTS_FILE" + exec 9>"$SLOTS_FILE.lock" + if command -v flock >/dev/null; then + flock 9 + fi + slot="$(awk -v n="$name" '$1 == n { print $2 }' "$SLOTS_FILE")" + if [[ -z "$slot" ]]; then + slot=1 + while awk -v s="$slot" '$2 == s { found = 1 } END { exit !found }' "$SLOTS_FILE"; do + slot=$((slot + 1)) + done + if ((slot > 254)); then + echo 'No free dev instance slot. Destroy unused instances first.' >&2 + exit 1 + fi + echo "$name $slot" >>"$SLOTS_FILE" + fi + exec 9>&- + echo "$slot" +} -compose=(docker compose -f docker-compose.yml -f docker-compose.dev.yml) -if [[ "$backend" == "kvm" ]]; then - compose+=(-f docker-compose.dev-kvm.yml) -fi +# Keep the scheme and host of APP_URL in .env (for example a Tailscale name) and use the instance port. +app_url() { + local port="$1" url="" + if [[ -f .env ]]; then + url="$(grep -E '^APP_URL=' .env | tail -n1 | cut -d= -f2- | tr -d "\"'" || true)" + fi + if [[ "$url" =~ ^(https?)://([^/:]+) ]]; then + echo "${BASH_REMATCH[1]}://${BASH_REMATCH[2]}:${port}" + else + echo "http://localhost:${port}" + fi +} + +ensure_env() { + local name="$1" slot env_file existing_key base + local app reverb terminal db redis vite mail mail_ui minio minio_ui + slot="$(instance_slot "$name")" + env_file="$(env_file "$name")" + + if ! is_linked_worktree && [[ -z "${COOLIFY_DEV_INSTANCE:-}" ]]; then + app=8000 reverb=6001 terminal=6002 db=5432 redis=6379 vite=5173 + mail=1025 mail_ui=8025 minio=9000 minio_ui=9001 + else + base=$((20000 + slot * 10)) + app=$base reverb=$((base + 1)) terminal=$((base + 2)) db=$((base + 3)) redis=$((base + 4)) + vite=$((base + 5)) mail=$((base + 6)) mail_ui=$((base + 7)) minio=$((base + 8)) minio_ui=$((base + 9)) + fi + + # Never rotate APP_KEY once set: encrypted DB columns (private keys, secrets) + # become unreadable ("The MAC is invalid") if APP_KEY changes while volumes persist. + existing_key="" + if [[ -f "$env_file" ]]; then + existing_key="$(grep -E '^APP_KEY=base64:' "$env_file" | tail -n1 | cut -d= -f2- || true)" + fi + if [[ -z "$existing_key" && -f "$ENV_DIR/$name.appkey" ]]; then + existing_key="$(cat "$ENV_DIR/$name.appkey")" + fi + if [[ -z "$existing_key" ]]; then + existing_key="base64:$(openssl rand -base64 32)" + fi + printf '%s\n' "$existing_key" >"$ENV_DIR/$name.appkey" + + cat >"$env_file" < : .env of the checkout first, generated instance env wins. +compose() { + local name="$1" + shift + local -a env_files=() + if [[ -f .env ]]; then + env_files+=(--env-file .env) + fi + docker compose -p "$(project_name "$name")" -f "$COMPOSE_FILE" \ + "${env_files[@]}" --env-file "$(env_file "$name")" "${profiles[@]}" "$@" +} + +select_backend() { + backend="${COOLIFY_DEV_SERVER_BACKEND:-auto}" + if [[ "$backend" != "auto" && "$backend" != "testing-host" ]]; then + echo 'COOLIFY_DEV_SERVER_BACKEND must be auto or testing-host.' >&2 + exit 2 + fi + privileged=() + if [[ $EUID -ne 0 ]]; then + privileged=(sudo -n -E) + fi + if [[ "$backend" == "auto" && -c /dev/kvm && -r /dev/kvm && -w /dev/kvm ]] && + { [[ $EUID -eq 0 ]] || { command -v sudo >/dev/null && sudo -n -E true; }; }; then + backend=kvm + else + backend=testing-host + fi + profiles=(--profile vite --profile mailpit --profile minio) + if [[ "$backend" == "testing-host" ]]; then + profiles+=(--profile testing-host) + fi +} wait_for_coolify() { - local attempt status + local container attempt status + container="$(project_name "$instance")" for ((attempt = 0; attempt < 150; attempt++)); do - status="$(docker inspect --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' coolify 2>/dev/null || true)" + status="$(docker inspect --format '{{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}}' "$container" 2>/dev/null || true)" if [[ "$status" == 'running healthy' ]]; then return 0 fi @@ -63,44 +208,187 @@ wait_for_coolify() { return 1 } +# Only one instance of a checkout runs at a time: they share its ports (main checkout) +# and its public/hot Vite file. The legacy fixed-name stack also holds the main ports. +stop_other_checkout_instances() { + local file other + for file in "$ENV_DIR"/*.env; do + [[ -f "$file" ]] || continue + other="$(basename "$file" .env)" + if [[ "$other" != "$instance" ]] && grep -qxF "DEV_CHECKOUT=$ROOT" "$file" && + [[ -n "$(docker ps -q --filter "label=com.docker.compose.project=$(project_name "$other")")" ]]; then + echo "Stopping instance ${other} (same checkout)" + docker compose -p "$(project_name "$other")" stop + fi + done + if ! is_linked_worktree; then + docker stop "${LEGACY_CONTAINERS[@]}" >/dev/null 2>&1 || true + fi +} + +# Publish the browser-facing ports over Tailscale HTTPS when APP_URL uses a tailnet name. +tailscale_serve() { + local action="$1" port + [[ "$(env_value "$instance" APP_URL)" == https://*.ts.net:* ]] || return 0 + command -v tailscale >/dev/null || return 0 + for port in APP_PORT FORWARD_PUSHER_PORT FORWARD_TERMINAL_PORT VITE_PORT; do + port="$(env_value "$instance" "$port")" + if [[ "$action" == on ]]; then + tailscale serve --bg --https="$port" "http://127.0.0.1:$port" >/dev/null || true + else + tailscale serve --https="$port" off >/dev/null 2>&1 || true + fi + done +} + +vm_domains() { + command -v virsh >/dev/null || return 0 + "${privileged[@]}" virsh --connect qemu:///system list "$@" --name 2>/dev/null | + grep -E "^coolify-dev-${instance}--" || true +} + start_backend() { + stop_other_checkout_instances + echo "==> Instance ${instance} → $(env_value "$instance" APP_URL) (backend: ${backend})" + compose "$instance" up -d --build + wait_for_coolify + tailscale_serve on if [[ "$backend" == "kvm" ]]; then + local host="${DEV_BIND_ADDRESS:-127.0.0.1}" + [[ "$host" == 0.0.0.0 ]] && host=127.0.0.1 echo "Using KVM profile: $profile" - "${compose[@]}" --profile testing-host stop testing-host - "${compose[@]}" up -d - wait_for_coolify - "${privileged[@]}" php artisan dev:qemu "$profile" --as-localhost + # The host command must reach this instance's database, not the one in .env, and must + # not create root-owned files in storage/logs. + LOG_CHANNEL=stderr DB_HOST="$host" DB_PORT="$(env_value "$instance" FORWARD_DB_PORT)" \ + REDIS_HOST="$host" REDIS_PORT="$(env_value "$instance" FORWARD_REDIS_PORT)" \ + DEVELOPMENT_QEMU_INSTANCE="$instance" \ + DEVELOPMENT_QEMU_SLOT="$(env_value "$instance" DEVELOPMENT_QEMU_SLOT)" \ + DEVELOPMENT_QEMU_DOCKER_NETWORK="$(project_name "$instance")" \ + DEVELOPMENT_QEMU_COOLIFY_CONTAINER="$(project_name "$instance")" \ + "${privileged[@]}" php artisan dev:qemu "$profile" --as-localhost else - echo 'KVM is unavailable or disabled; using coolify-testing-host.' - "${compose[@]}" up -d - wait_for_coolify - "${compose[@]}" exec -T coolify php artisan db:seed --class=ServerSeeder --force + compose "$instance" exec -T coolify php artisan db:seed --class=ServerSeeder --force fi } stop_backend() { - if [[ "$backend" == "kvm" ]] && command -v virsh >/dev/null; then - local domain domains state - domain="coolify-dev-${profile}" - domains="$("${privileged[@]}" virsh --connect qemu:///system list --all --name)" - if grep -Fxq "$domain" <<< "$domains"; then - state="$("${privileged[@]}" virsh --connect qemu:///system domstate "$domain")" - if [[ "$state" != 'shut off' ]]; then + local domain + if [[ "$backend" == "kvm" ]]; then + for domain in $(vm_domains --state-running); do + if [[ -z "$profile_arg" || "$domain" == "coolify-dev-${instance}--${profile_arg}" ]]; then "${privileged[@]}" virsh --connect qemu:///system shutdown "$domain" fi - fi + done fi - "${compose[@]}" stop + compose "$instance" stop } -cleanup() { - status=$? - trap - EXIT INT TERM - stop_backend || true - "${compose[@]}" down || true - exit "$status" +cmd_urls() { + local name file state + printf '%-40s %-44s %-7s %-7s %-6s %s\n' NAME URL STATE DB REDIS CHECKOUT + for file in "$ENV_DIR"/*.env; do + [[ -f "$file" ]] || continue + name="$(basename "$file" .env)" + state="$(docker inspect --format '{{.State.Status}}' "$(project_name "$name")" 2>/dev/null || echo stopped)" + printf '%-40s %-44s %-7s %-7s %-6s %s\n' "$name" "$(env_value "$name" APP_URL)" "$state" \ + "$(env_value "$name" FORWARD_DB_PORT)" "$(env_value "$name" FORWARD_REDIS_PORT)" \ + "$(env_value "$name" DEV_CHECKOUT)" + done } +cmd_destroy() { + local name="$1" slot domain subnet + instance="$name" + [[ -f "$(env_file "$name")" ]] || { + echo "Unknown instance '$name'. See ./scripts/dev urls" >&2 + exit 1 + } + slot="$(env_value "$name" DEVELOPMENT_QEMU_SLOT)" + echo "==> Destroying ${name} (containers, volumes, VMs, port slot)" + tailscale_serve off + subnet="$(docker network inspect "$(project_name "$name")" --format '{{(index .IPAM.Config 0).Subnet}}' 2>/dev/null || true)" + compose "$name" --profile '*' down --remove-orphans --volumes + if command -v virsh >/dev/null; then + for domain in $(vm_domains --all); do + "${privileged[@]}" virsh --connect qemu:///system destroy "$domain" >/dev/null 2>&1 || true + "${privileged[@]}" virsh --connect qemu:///system undefine "$domain" >/dev/null + done + "${privileged[@]}" rm -f "$QEMU_STORAGE/coolify-dev-${name}--"* + if "${privileged[@]}" virsh --connect qemu:///system net-info "coolify-dev-${slot}" >/dev/null 2>&1; then + "${privileged[@]}" virsh --connect qemu:///system net-destroy "coolify-dev-${slot}" >/dev/null 2>&1 || true + "${privileged[@]}" virsh --connect qemu:///system net-undefine "coolify-dev-${slot}" >/dev/null + fi + if [[ -n "$subnet" ]]; then + "${privileged[@]}" iptables -D LIBVIRT_FWI -s "$subnet" -d "10.221.${slot}.0/24" -o "cdevbr${slot}" -j ACCEPT >/dev/null 2>&1 || true + fi + fi + rm -f "$(env_file "$name")" "$ENV_DIR/$name.appkey" + awk -v n="$name" '$1 != n' "$SLOTS_FILE" >"$SLOTS_FILE.tmp" + mv "$SLOTS_FILE.tmp" "$SLOTS_FILE" +} + +command="${1:-start}" +shift || true +case "$command" in + --help | -h | help) + usage + exit 0 + ;; + start | stop | run) + if [[ $# -gt 1 ]]; then + usage >&2 + exit 2 + fi + ;; + destroy) + if [[ $# -ne 1 ]]; then + echo 'Usage: ./scripts/dev destroy ' >&2 + exit 2 + fi + ;; + urls | ls | ps | logs | exec | container | teardown) ;; + *) + usage >&2 + exit 2 + ;; +esac + +profile_arg="${1:-}" +profile="${profile_arg:-${COOLIFY_DEV_KVM_PROFILE:-ubuntu-root}}" +select_backend + +case "$command" in + urls | ls) + cmd_urls + exit 0 + ;; + container) + project_name "$(current_instance)" + exit 0 + ;; + destroy) + cmd_destroy "$(normalize_name "$1")" + exit 0 + ;; + teardown) + # Jean runs this before it deletes a worktree; a failure blocks the deletion. + if ! is_linked_worktree; then + echo 'teardown only destroys the instance of a linked worktree.' >&2 + exit 1 + fi + instance="$(current_instance)" + if [[ ! -f "$(env_file "$instance")" ]]; then + echo "No dev instance for ${instance}; nothing to destroy." + exit 0 + fi + cmd_destroy "$instance" + exit 0 + ;; +esac + +instance="$(current_instance)" +ensure_env "$instance" + case "$command" in start) start_backend @@ -109,11 +397,30 @@ case "$command" in stop_backend ;; run) + cleanup() { + status=$? + trap - EXIT INT TERM + stop_backend || true + compose "$instance" down --remove-orphans || true + exit "$status" + } trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM - docker rm -f coolify coolify-minio-init coolify-minio coolify-testing-host coolify-redis coolify-db coolify-mail coolify-vite >/dev/null 2>&1 || true start_backend - "${compose[@]}" logs -f --tail=100 + compose "$instance" logs -f --tail=100 + ;; + ps) + compose "$instance" ps + ;; + logs) + compose "$instance" logs -f "$@" + ;; + exec) + [[ $# -gt 0 ]] || { + usage >&2 + exit 2 + } + compose "$instance" exec coolify "$@" ;; esac diff --git a/scripts/dev-helper b/scripts/dev-helper index 1e1bfae27b..1d0de9fbfb 100755 --- a/scripts/dev-helper +++ b/scripts/dev-helper @@ -5,7 +5,7 @@ set -euo pipefail ROOT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) REPOSITORY=${HELPER_IMAGE_REPOSITORY:-docker.io/coollabsio/coolify-helper} DEFAULT_TAG="dev-$(git -C "$ROOT_DIR" rev-parse --short HEAD)" -COOLIFY_CONTAINER=${COOLIFY_CONTAINER:-coolify} +COOLIFY_CONTAINER=${COOLIFY_CONTAINER:-$("$ROOT_DIR/scripts/dev" container)} usage() { cat <&2 - exit 1 - ;; - esac -} - -normalize_name() { - case "$1" in - a|1) echo a ;; - b|2) echo b ;; - *) - echo "Unknown instance '$1' (use a or b)" >&2 - exit 1 - ;; - esac -} - -project_name() { echo "coolify-$1"; } -env_file() { echo "${ENV_DIR}/$1.env"; } - -ensure_env() { - local name="$1" - local offset env_file app_port existing_key key - offset="$(instance_offset "$name")" - env_file="$(env_file "$name")" - app_port=$((8000 + offset)) - mkdir -p "$ENV_DIR" - - # Never rotate APP_KEY once set: encrypted DB columns (private keys, secrets) - # become unreadable ("The MAC is invalid") if APP_KEY changes while volumes persist. - existing_key="" - if [[ -f "$env_file" ]]; then - existing_key="$(grep -E '^APP_KEY=base64:' "$env_file" 2>/dev/null | tail -n1 | cut -d= -f2- || true)" - fi - if [[ -z "$existing_key" && -f ".dev-instances/${name}.appkey" ]]; then - existing_key="$(cat ".dev-instances/${name}.appkey" 2>/dev/null || true)" - fi - if [[ -z "$existing_key" ]]; then - existing_key="base64:$(openssl rand -base64 32)" - fi - printf '%s\n' "$existing_key" >".dev-instances/${name}.appkey" - - cat >"$env_file" <&2 - return 1 - } - return - fi - # Rebuild if CSS entry is missing from disk (stale/partial build). - local css - css="$(php -r ' - $m=@json_decode(@file_get_contents("public/build/manifest.json"), true); - $f=$m["resources/css/app.css"]["file"] ?? ""; - echo $f && is_file("public/build/".$f) ? "ok" : "missing"; - ' 2>/dev/null || echo missing)" - if [[ "$css" != "ok" ]]; then - echo "Frontend build incomplete — running npm run build..." - npm run build || echo "Warning: npm run build failed." >&2 - fi -} - -# Vite profile is only useful for a single instance; drop it when starting both. -filter_vite_profile() { - local -a kept=() - local p want_vite=0 - for p in "${PROFILES[@]:-}"; do - if [[ "$p" == "vite" ]]; then - want_vite=1 - continue - fi - kept+=("$p") - done - if [[ $want_vite -eq 1 ]]; then - if [[ ${#NAMES[@]} -gt 1 ]]; then - echo "Note: ignoring --with vite (shared public/hot cannot serve two instances)." - echo " Frontend uses npm run build. For HMR, run a single instance: $0 up a --with vite" - else - kept+=("vite") - fi - fi - PROFILES=("${kept[@]}") -} - -cmd_up() { - parse_args "$@" - filter_vite_profile - # Prefer built assets before containers serve HTML. - ensure_frontend_assets || true - local name - local -a saved_profiles=("${PROFILES[@]:-}") - for name in "${NAMES[@]}"; do - PROFILES=("${saved_profiles[@]}") - # Only the first (only) instance may run the vite profile. - if [[ ${#NAMES[@]} -eq 1 ]] && printf '%s\n' "${PROFILES[@]:-}" | grep -qx vite; then - : - else - local -a no_vite=() - local p - for p in "${PROFILES[@]:-}"; do - [[ "$p" != "vite" ]] && no_vite+=("$p") - done - PROFILES=("${no_vite[@]}") - fi - echo "==> Starting ${name} → http://localhost:$((8000 + $(instance_offset "$name")))" - compose "$name" up -d --build - done - # If a lone instance started vite, keep public/hot; otherwise force built assets. - if [[ ${#NAMES[@]} -gt 1 ]] || ! printf '%s\n' "${saved_profiles[@]:-}" | grep -qx vite; then - ensure_frontend_assets || true - fi - echo - cmd_urls "${NAMES[@]}" - echo - echo "Frontend: production build (public/build). Both instances share the same assets." -} - -cmd_down() { - parse_args "$@" - local name - for name in "${NAMES[@]}"; do - if [[ ! -f "$(env_file "$name")" ]]; then - ensure_env "$name" >/dev/null - fi - echo "==> Stopping ${name}" - compose "$name" down --remove-orphans - done -} - -cmd_ps() { - parse_args "$@" - local name - for name in "${NAMES[@]}"; do - [[ -f "$(env_file "$name")" ]] || ensure_env "$name" >/dev/null - echo "==> $(project_name "$name")" - compose "$name" ps - echo - done -} - -cmd_urls() { - local names=("$@") - if [[ ${#names[@]} -eq 0 ]]; then - names=("${INSTANCES_ALL[@]}") - else - parse_args "$@" - names=("${NAMES[@]}") - fi - printf '%-6s %-28s %-8s %-8s %-12s %-12s\n' "NAME" "URL" "DB" "REDIS" "REVERB" "TERMINAL" - local name envf - for name in "${names[@]}"; do - name="$(normalize_name "$name")" - ensure_env "$name" >/dev/null - envf="$(env_file "$name")" - printf '%-6s %-28s %-8s %-8s %-12s %-12s\n' \ - "$name" \ - "$(grep -E '^APP_URL=' "$envf" | cut -d= -f2-)" \ - "$(grep -E '^FORWARD_DB_PORT=' "$envf" | cut -d= -f2-)" \ - "$(grep -E '^FORWARD_REDIS_PORT=' "$envf" | cut -d= -f2-)" \ - "$(grep -E '^FORWARD_PUSHER_PORT=' "$envf" | cut -d= -f2-)" \ - "$(grep -E '^FORWARD_TERMINAL_PORT=' "$envf" | cut -d= -f2-)" - done -} - -cmd_logs() { - local name="${1:-}" - shift || true - [[ -z "$name" ]] && usage 1 - name="$(normalize_name "$name")" - compose "$name" logs -f "$@" -} - -cmd_exec() { - local name="${1:-}" - shift || true - [[ -z "$name" || $# -eq 0 ]] && usage 1 - name="$(normalize_name "$name")" - compose "$name" exec coolify "$@" -} - -main() { - local cmd="${1:-}" - shift || true - case "$cmd" in - up) cmd_up "$@" ;; - down) cmd_down "$@" ;; - ps) cmd_ps "$@" ;; - urls|ls|list) cmd_urls "$@" ;; - logs) cmd_logs "$@" ;; - exec) cmd_exec "$@" ;; - -h|--help|help|"") usage 0 ;; - *) - echo "Unknown command: $cmd" >&2 - usage 1 - ;; - esac -} - -main "$@" diff --git a/scripts/seed-transfer-demo.php b/scripts/seed-transfer-demo.php index 865f4eb7ab..03c6ae17d2 100644 --- a/scripts/seed-transfer-demo.php +++ b/scripts/seed-transfer-demo.php @@ -3,7 +3,7 @@ /** * Seed a rich transfer-demo inventory on the current Coolify instance. * Run: php artisan tinker scripts/seed-transfer-demo.php - * Or: ./scripts/dev-instances exec a php artisan tinker --execute "require 'scripts/seed-transfer-demo.php';" + * Or: ./scripts/dev exec php artisan tinker --execute "require 'scripts/seed-transfer-demo.php';" */ use App\Models\Application; diff --git a/tests/Feature/DevKvmScriptTest.php b/tests/Feature/DevKvmScriptTest.php deleted file mode 100644 index 0d89186c2c..0000000000 --- a/tests/Feature/DevKvmScriptTest.php +++ /dev/null @@ -1,100 +0,0 @@ -run(); - - $invalid = new Process(['bash', $script, 'start', 'ubuntu-root', 'extra'], base_path()); - $invalid->run(); - $unknown = new Process(['bash', $script, 'restart'], base_path()); - $unknown->run(); - - expect($help->isSuccessful())->toBeTrue() - ->and($help->getOutput())->toContain('start [qemu-profile]', 'stop [qemu-profile]', 'run [qemu-profile]') - ->and($invalid->isSuccessful())->toBeFalse() - ->and($unknown->isSuccessful())->toBeFalse(); -}); - -it('stops only the container stack when testing-host is selected', function () { - $directory = sys_get_temp_dir().'/coolify-dev-container-test-'.bin2hex(random_bytes(4)); - mkdir($directory.'/bin', 0777, true); - $log = $directory.'/commands.log'; - $fake = <<<'BASH' -#!/usr/bin/env bash -printf '%s %s\n' "$(basename "$0")" "$*" >> "$DEV_KVM_LOG" -BASH; - foreach (['docker', 'virsh'] as $binary) { - file_put_contents($directory.'/bin/'.$binary, $fake); - chmod($directory.'/bin/'.$binary, 0755); - } - - try { - $process = new Process(['bash', base_path('scripts/dev'), 'stop'], base_path(), [ - 'PATH' => $directory.'/bin:'.getenv('PATH'), - 'DEV_KVM_LOG' => $log, - 'COOLIFY_DEV_SERVER_BACKEND' => 'testing-host', - ]); - $process->run(); - - expect($process->isSuccessful())->toBeTrue($process->getErrorOutput()) - ->and(file_get_contents($log))->toContain('docker compose -f docker-compose.yml -f docker-compose.dev.yml stop') - ->not->toContain('virsh') - ->not->toContain('docker-compose.dev-kvm.yml'); - } finally { - foreach (['docker', 'virsh'] as $binary) { - unlink($directory.'/bin/'.$binary); - } - if (file_exists($log)) { - unlink($log); - } - rmdir($directory.'/bin'); - rmdir($directory); - } -}); - -it('shuts down the selected vm and stops the compose services', function () { - $directory = sys_get_temp_dir().'/coolify-dev-kvm-test-'.bin2hex(random_bytes(4)); - mkdir($directory.'/bin', 0777, true); - $log = $directory.'/commands.log'; - $fake = <<<'BASH' -#!/usr/bin/env bash -printf '%s %s\n' "$(basename "$0")" "$*" >> "$DEV_KVM_LOG" -if [[ "$(basename "$0") $3" == 'virsh domstate' ]]; then - echo running -fi -if [[ "$(basename "$0") $3" == 'virsh list' ]]; then - echo coolify-dev-debian-root -fi -BASH; - file_put_contents($directory.'/bin/docker', $fake); - file_put_contents($directory.'/bin/virsh', $fake); - chmod($directory.'/bin/docker', 0755); - chmod($directory.'/bin/virsh', 0755); - - try { - $process = new Process(['bash', base_path('scripts/dev'), 'stop', 'debian-root'], base_path(), [ - 'PATH' => $directory.'/bin:'.getenv('PATH'), - 'DEV_KVM_LOG' => $log, - ]); - $process->run(); - - expect($process->isSuccessful())->toBeTrue($process->getErrorOutput()) - ->and(file_get_contents($log))->toContain('virsh --connect qemu:///system list --all --name') - ->toContain('virsh --connect qemu:///system domstate coolify-dev-debian-root') - ->toContain('virsh --connect qemu:///system shutdown coolify-dev-debian-root') - ->toContain('docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.dev-kvm.yml stop') - ->not->toContain('dev-kvm.yml down'); - } finally { - unlink($directory.'/bin/docker'); - unlink($directory.'/bin/virsh'); - if (file_exists($log)) { - unlink($log); - } - rmdir($directory.'/bin'); - rmdir($directory); - } -}); diff --git a/tests/Feature/DevScriptTest.php b/tests/Feature/DevScriptTest.php new file mode 100644 index 0000000000..dcc250cc1d --- /dev/null +++ b/tests/Feature/DevScriptTest.php @@ -0,0 +1,265 @@ +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 ') + ->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(); +}); diff --git a/tests/Feature/DevelopmentQemuVmTest.php b/tests/Feature/DevelopmentQemuVmTest.php index f23653e7d4..9c2cd95d69 100644 --- a/tests/Feature/DevelopmentQemuVmTest.php +++ b/tests/Feature/DevelopmentQemuVmTest.php @@ -190,34 +190,93 @@ it('replaces the seeded qemu server with the selected non-root equivalent', func ->and(Server::query()->where('uuid', 'like', 'development-qemu-%')->count())->toBe(1); }); -it('deletes managed vm data and freshly creates only the selected vm', function () { - $storagePath = sys_get_temp_dir().'/coolify-qemu-reset-test-'.uniqid(); +it('reuses an existing vm without touching other managed vms', function () { + $storagePath = sys_get_temp_dir().'/coolify-qemu-reuse-test-'.uniqid(); config(['development-qemu.storage_path' => $storagePath]); File::ensureDirectoryExists($storagePath); - File::put("{$storagePath}/coolify-dev-ubuntu-root.qcow2", 'old data'); - File::put("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2", 'old data'); + File::put("{$storagePath}/coolify-dev-ubuntu-root.qcow2", 'other vm data'); + File::put("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2", 'vm data'); Process::fake([ '* net-dumpxml *' => Process::result(output: ''), '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), - '* iptables -C *' => Process::result(exitCode: 1), '* dominfo *' => Process::result(output: 'exists'), + '* domstate *' => Process::result(output: "shut off\n"), '*' => Process::result(), ]); StartDevelopmentQemuVm::run('ubuntu-non-root'); - Process::assertRan(fn ($process) => str_contains($process->command, 'virsh destroy') && str_contains($process->command, 'coolify-dev-ubuntu-root')); - Process::assertRan(fn ($process) => str_contains($process->command, 'virsh destroy') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); - Process::assertRan(fn ($process) => str_contains($process->command, 'virsh undefine') && str_contains($process->command, 'coolify-dev-ubuntu-root')); - Process::assertRan(fn ($process) => str_contains($process->command, 'virsh undefine') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); + Process::assertRan(fn ($process) => $process->command === "virsh start 'coolify-dev-ubuntu-non-root'"); + Process::assertNotRan(fn ($process) => str_starts_with($process->command, 'virt-install ')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh destroy')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh undefine')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'coolify-dev-ubuntu-root')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI')); + Process::assertRan(fn ($process) => str_contains($process->command, 'docker exec') && str_contains($process->command, 'coolify')); + expect(File::get("{$storagePath}/coolify-dev-ubuntu-root.qcow2"))->toBe('other vm data') + ->and(File::get("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2"))->toBe('vm data'); +}); + +it('does not restart an already running vm', function () { + config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-running-test-'.uniqid()]); + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '* dominfo *' => Process::result(output: 'exists'), + '* domstate *' => Process::result(output: "running\n"), + '*' => Process::result(), + ]); + + StartDevelopmentQemuVm::run('ubuntu-root'); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh start')); + Process::assertNotRan(fn ($process) => str_starts_with($process->command, 'virt-install ')); + Process::assertRan(fn ($process) => str_contains($process->command, 'docker exec') && str_contains($process->command, 'fsockopen')); +}); + +it('freshly recreates only the selected vm', function () { + $storagePath = sys_get_temp_dir().'/coolify-qemu-fresh-test-'.uniqid(); + config(['development-qemu.storage_path' => $storagePath]); + File::ensureDirectoryExists($storagePath); + File::put("{$storagePath}/coolify-dev-ubuntu-root.qcow2", 'other vm data'); + File::put("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2", 'old data'); + File::put("{$storagePath}/coolify-dev-ubuntu-non-root-prepared.qcow2", 'prepared image'); + + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '* dominfo *' => Process::result(output: 'exists'), + '*' => Process::result(), + ]); + + StartDevelopmentQemuVm::run('ubuntu-non-root', true); + + Process::assertRan(fn ($process) => $process->command === "virsh destroy 'coolify-dev-ubuntu-non-root'"); + Process::assertRan(fn ($process) => $process->command === "virsh undefine 'coolify-dev-ubuntu-non-root'"); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'coolify-dev-ubuntu-root')); Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh start')); Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); Process::assertRan(fn ($process) => str_contains($process->command, 'net-update') && str_contains($process->command, 'ip-dhcp-host') && str_contains($process->command, '192.168.122.11')); - Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -D LIBVIRT_FWI')); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker exec') && str_contains($process->command, 'coolify')); - expect(File::exists("{$storagePath}/coolify-dev-ubuntu-root.qcow2"))->toBeFalse() - ->and(File::exists("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2"))->toBeFalse(); + expect(File::get("{$storagePath}/coolify-dev-ubuntu-root.qcow2"))->toBe('other vm data') + ->and(File::exists("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2"))->toBeFalse() + ->and(File::exists("{$storagePath}/coolify-dev-ubuntu-non-root-prepared.qcow2"))->toBeTrue(); +}); + +it('passes the fresh option from the command to the selected vms only', function () { + config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-fresh-command-test-'.uniqid()]); + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '* dominfo *' => Process::result(output: 'exists'), + '*' => Process::result(), + ]); + + expect(Artisan::call('dev:qemu', ['profiles' => ['debian-root'], '--fresh' => true]))->toBe(Command::SUCCESS); + + Process::assertRan(fn ($process) => $process->command === "virsh undefine 'coolify-dev-debian-root'"); + Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-debian-root')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh destroy') && ! str_contains($process->command, 'coolify-dev-debian-root')); }); it('rejects qemu vm management outside development', function () { @@ -262,6 +321,7 @@ it('can create a vm without a host database connection', function () { Process::assertRan(fn ($process) => str_contains($process->command, 'qemu-img create') && str_contains($process->command, 'prepared.qcow2')); Process::assertRan(fn ($process) => str_contains($process->command, 'xorriso -as mkisofs -V cidata -graft-points')); Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'bus=virtio,readonly=on')); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'mac_in_use')); $userData = File::get(config('development-qemu.storage_path').'/coolify-dev-ubuntu-root-user-data.yaml'); expect($userData)->toContain('docker compose version', 'docker buildx version', 'docker info', 'cloud-init.disabled'); $cloudInit = Yaml::parse($userData); @@ -276,6 +336,7 @@ it('reuses a prepared vm image without reinstalling docker', function () { Process::fake([ '* net-dumpxml *' => Process::result(output: ''), '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '* dominfo *' => Process::result(exitCode: 1), '*' => Process::result(), ]); @@ -294,10 +355,11 @@ it('prepares alpine with the compose and buildx packages for a non-root user', f Process::fake([ '* net-dumpxml *' => Process::result(output: ''), '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '* dominfo *' => Process::result(exitCode: 1), '*' => Process::result(), ]); - StartDevelopmentQemuVm::run('alpine-non-root', false); + StartDevelopmentQemuVm::run('alpine-non-root'); $cloudInit = Yaml::parse(File::get("{$storagePath}/coolify-dev-alpine-non-root-user-data.yaml")); expect($cloudInit['packages'])->toContain('docker', 'docker-cli-compose', 'docker-cli-buildx') @@ -317,6 +379,10 @@ it('does not cache a vm when preparation does not finish', function () { return Process::result(output: "172.18.0.0/16\n"); } + if (str_contains($process->command, 'virsh dominfo')) { + return Process::result(exitCode: 1); + } + return str_contains($process->command, 'timeout 900 bash') ? Process::result(exitCode: 124) : Process::result(); @@ -342,7 +408,7 @@ it('seeds through the coolify container when the host database is unavailable', ManageDevelopmentQemuVm::run('ubuntu-root'); - Process::assertRan(fn ($process) => str_contains($process->command, 'docker exec coolify php artisan dev:qemu:seed') && str_contains($process->command, 'ubuntu-root')); + Process::assertRan(fn ($process) => str_contains($process->command, "docker exec 'coolify' php artisan dev:qemu:seed") && str_contains($process->command, 'ubuntu-root')); }); it('passes localhost mode to the container when the host database is unavailable', function () { @@ -367,6 +433,7 @@ it('starts and seeds root and non-root profiles together', function () { Process::fake([ '* net-dumpxml *' => Process::result(output: ''), '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '* dominfo *' => Process::result(exitCode: 1), '*' => Process::result(), ]); @@ -379,3 +446,194 @@ it('starts and seeds root and non-root profiles together', function () { Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-ubuntu-root')); Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install') && str_contains($process->command, 'coolify-dev-ubuntu-non-root')); }); + +it('seeds through the instance coolify container when the host database is unavailable', function () { + config([ + 'development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-instance-fallback-test-'.uniqid(), + 'development-qemu.coolify_container' => 'coolify-fix-deploy-env', + ]); + SeedDevelopmentQemuServer::mock() + ->shouldReceive('handle') + ->once() + ->andThrow(new QueryException('pgsql', 'select 1', [], new Exception('unavailable'))); + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '*' => Process::result(), + ]); + + ManageDevelopmentQemuVm::run('ubuntu-root'); + + Process::assertRan(fn ($process) => str_contains($process->command, "docker exec 'coolify-fix-deploy-env' php artisan dev:qemu:seed")); + Process::assertRan(fn ($process) => str_contains($process->command, "docker exec 'coolify-fix-deploy-env' php -r")); + Process::assertNotRan(fn ($process) => str_contains($process->command, "docker exec 'coolify' ")); +}); + +it('resolves legacy values when no dev instance is configured', function () { + $config = developmentQemuConfigFor([]); + + expect($config['instance'])->toBeNull() + ->and($config['libvirt_network'])->toBe('default') + ->and($config['bridge'])->toBe('virbr0') + ->and($config['gateway'])->toBe('192.168.122.1') + ->and($config['subnet'])->toBe('192.168.122.0/24') + ->and($config['docker_network'])->toBe('coolify') + ->and($config['coolify_container'])->toBe('coolify') + ->and($config['profiles']['ubuntu-root']['domain'])->toBe('coolify-dev-ubuntu-root') + ->and($config['profiles']['ubuntu-root']['template'])->toBe('coolify-dev-ubuntu-root') + ->and($config['profiles']['ubuntu-root']['ip'])->toBe('192.168.122.10') + ->and($config['profiles']['alpine-non-root']['ip'])->toBe('192.168.122.41'); +}); + +it('resolves isolated per-instance values from the environment', function () { + $legacy = developmentQemuConfigFor([]); + $config = developmentQemuConfigFor([ + 'DEVELOPMENT_QEMU_INSTANCE' => 'fix-deploy-env', + 'DEVELOPMENT_QEMU_SLOT' => '7', + 'DEVELOPMENT_QEMU_DOCKER_NETWORK' => 'coolify-fix-deploy-env', + 'DEVELOPMENT_QEMU_COOLIFY_CONTAINER' => 'coolify-fix-deploy-env', + ]); + $profiles = collect($config['profiles']); + + expect($config['instance'])->toBe('fix-deploy-env') + ->and($config['slot'])->toBe(7) + ->and($config['libvirt_network'])->toBe('coolify-dev-7') + ->and($config['bridge'])->toBe('cdevbr7') + ->and($config['gateway'])->toBe('10.221.7.1') + ->and($config['subnet'])->toBe('10.221.7.0/24') + ->and($config['prefix'])->toBe(24) + ->and($config['docker_network'])->toBe('coolify-fix-deploy-env') + ->and($config['coolify_container'])->toBe('coolify-fix-deploy-env') + ->and($config['profiles']['ubuntu-root']['domain'])->toBe('coolify-dev-fix-deploy-env--ubuntu-root') + ->and($config['profiles']['ubuntu-root']['template'])->toBe('coolify-dev-ubuntu-root') + ->and($config['profiles']['ubuntu-root']['ip'])->toBe('10.221.7.10') + ->and($config['profiles']['alpine-non-root']['ip'])->toBe('10.221.7.41') + ->and($profiles->every(fn (array $profile, string $key) => $profile['domain'] === "coolify-dev-fix-deploy-env--{$key}"))->toBeTrue() + ->and($profiles->pluck('ip')->unique()->count())->toBe(8) + ->and($profiles->pluck('mac')->all())->toBe(collect($legacy['profiles'])->pluck('mac')->all()) + ->and($profiles->pluck('uuid')->all())->toBe(collect($legacy['profiles'])->pluck('uuid')->all()); +}); + +it('configures the instance libvirt network and forwarding rule', function () { + $storagePath = sys_get_temp_dir().'/coolify-qemu-instance-network-test-'.uniqid(); + useDevelopmentQemuInstance('main', 3, $storagePath); + Process::fake([ + '* net-info *' => Process::result(exitCode: 1), + '* network inspect *' => Process::result(output: "172.30.0.0/16\n"), + '*' => Process::result(), + ]); + + ConfigureDevelopmentQemuHost::run(); + + $networkXml = File::get("{$storagePath}/libvirt-network-coolify-dev-3.xml"); + expect($networkXml)->toContain( + 'coolify-dev-3', + '', + '', + '', + )->not->toContain('virbr0', '192.168.122'); + Process::assertRan(fn ($process) => $process->command === "virsh net-info 'coolify-dev-3'"); + Process::assertRan(fn ($process) => $process->command === "virsh net-start 'coolify-dev-3'"); + Process::assertRan(fn ($process) => $process->command === "virsh net-autostart 'coolify-dev-3'"); + Process::assertRan(fn ($process) => str_contains($process->command, "docker network inspect 'coolify-main'")); + Process::assertRan(fn ($process) => $process->command === "iptables -I LIBVIRT_FWI 1 -s '172.30.0.0/16' -d '10.221.3.0/24' -o 'cdevbr3' -j ACCEPT"); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'virbr0')); +}); + +it('keeps the legacy libvirt network definition unchanged', function () { + $storagePath = sys_get_temp_dir().'/coolify-qemu-legacy-network-test-'.uniqid(); + config(['development-qemu.storage_path' => $storagePath]); + Process::fake([ + '* net-info *' => Process::result(exitCode: 1), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '*' => Process::result(), + ]); + + ConfigureDevelopmentQemuHost::run(); + + expect(File::get("{$storagePath}/libvirt-network-default.xml"))->toContain( + 'default', + '', + '', + '', + ); + Process::assertRan(fn ($process) => $process->command === "iptables -I LIBVIRT_FWI 1 -s '172.18.0.0/16' -d '192.168.122.0/24' -o 'virbr0' -j ACCEPT"); +}); + +it('creates instance vms with instance domains, ips, and shared prepared images', function () { + $storagePath = sys_get_temp_dir().'/coolify-qemu-instance-vm-test-'.uniqid(); + useDevelopmentQemuInstance('main', 3, $storagePath); + File::ensureDirectoryExists($storagePath); + File::put("{$storagePath}/coolify-dev-ubuntu-root-prepared.qcow2", 'prepared image'); + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.30.0.0/16\n"), + '* dominfo *' => Process::result(exitCode: 1), + '*' => Process::result(), + ]); + + StartDevelopmentQemuVm::run('ubuntu-root'); + + Process::assertRan(fn ($process) => $process->command === "virsh dominfo 'coolify-dev-main--ubuntu-root'"); + Process::assertRan(fn ($process) => str_starts_with($process->command, "virsh net-update 'coolify-dev-3' add-last ip-dhcp-host") && str_contains($process->command, '52:54:00:ca:00:01') && str_contains($process->command, '10.221.3.10')); + Process::assertRan(fn ($process) => str_contains($process->command, 'qemu-img create') + && str_contains($process->command, "'{$storagePath}/coolify-dev-ubuntu-root-prepared.qcow2'") + && str_contains($process->command, "'{$storagePath}/coolify-dev-main--ubuntu-root.qcow2'")); + Process::assertRan(fn ($process) => str_starts_with($process->command, 'virt-install ') + && str_contains($process->command, "--name 'coolify-dev-main--ubuntu-root'") + && str_contains($process->command, "--network network='coolify-dev-3',model=virtio,mac='52:54:00:ca:00:01'") + && str_ends_with($process->command, '--check mac_in_use=off')); + Process::assertRan(fn ($process) => str_contains($process->command, "docker exec 'coolify-main' php -r") && str_contains($process->command, "'10.221.3.10'")); + Process::assertNotRan(fn ($process) => str_contains($process->command, 'curl --fail --location')); +}); + +it('rejects invalid instance names and slots', function (string $instance, int $slot) { + useDevelopmentQemuInstance($instance, $slot, sys_get_temp_dir().'/coolify-qemu-invalid-instance-'.uniqid()); + Process::fake(); + + expect(fn () => ConfigureDevelopmentQemuHost::run())->toThrow(RuntimeException::class, 'DEVELOPMENT_QEMU_'); + Process::assertNothingRan(); +})->with([ + 'repeated dashes' => ['fix--deploy', 1], + 'trailing dash' => ['main-', 1], + 'uppercase' => ['Main', 1], + 'slot zero' => ['main', 0], + 'slot too large' => ['main', 255], +]); + +/** + * Evaluate config/development-qemu.php with the given environment, restoring the environment afterwards. + * + * @param array $environment + * @return array + */ +function developmentQemuConfigFor(array $environment): array +{ + $keys = ['DEVELOPMENT_QEMU_INSTANCE', 'DEVELOPMENT_QEMU_SLOT', 'DEVELOPMENT_QEMU_DOCKER_NETWORK', 'DEVELOPMENT_QEMU_COOLIFY_CONTAINER']; + $original = collect($keys)->mapWithKeys(fn (string $key) => [$key => getenv($key)])->all(); + + foreach ($keys as $key) { + isset($environment[$key]) ? putenv("{$key}={$environment[$key]}") : putenv($key); + } + + try { + return require config_path('development-qemu.php'); + } finally { + foreach ($original as $key => $value) { + $value === false ? putenv($key) : putenv("{$key}={$value}"); + } + } +} + +function useDevelopmentQemuInstance(string $instance, int $slot, string $storagePath): void +{ + config(['development-qemu' => [ + ...developmentQemuConfigFor([ + 'DEVELOPMENT_QEMU_INSTANCE' => $instance, + 'DEVELOPMENT_QEMU_SLOT' => (string) $slot, + 'DEVELOPMENT_QEMU_DOCKER_NETWORK' => "coolify-{$instance}", + 'DEVELOPMENT_QEMU_COOLIFY_CONTAINER' => "coolify-{$instance}", + ]), + 'storage_path' => $storagePath, + ]]); +} diff --git a/tests/Feature/JeanRunScriptTest.php b/tests/Feature/JeanRunScriptTest.php index d317d9fe1d..6c370a9e50 100644 --- a/tests/Feature/JeanRunScriptTest.php +++ b/tests/Feature/JeanRunScriptTest.php @@ -1,119 +1,9 @@ toBe('./scripts/dev run') + ->and($config['scripts']['teardown'])->toBe('./scripts/dev teardown') ->and($config['ports'][0]['port'])->toBe(8000); }); - -it('starts the testing host and restores localhost when kvm is not selected', function () { - $directory = sys_get_temp_dir().'/coolify-jean-run-test-'.bin2hex(random_bytes(4)); - mkdir($directory.'/bin', 0777, true); - $log = $directory.'/commands.log'; - file_put_contents($directory.'/bin/docker', <<<'BASH' -#!/usr/bin/env bash -printf '%s\n' "$*" >> "$JEAN_TEST_LOG" -if [[ "$1" == inspect ]]; then - echo 'running healthy' -fi -BASH); - chmod($directory.'/bin/docker', 0755); - - try { - $process = new Process(['bash', base_path('scripts/dev'), 'run'], base_path(), [ - 'PATH' => $directory.'/bin:'.getenv('PATH'), - 'JEAN_TEST_LOG' => $log, - 'COOLIFY_DEV_SERVER_BACKEND' => 'testing-host', - ]); - $process->run(); - - expect($process->isSuccessful())->toBeTrue($process->getErrorOutput()) - ->and(file_get_contents($log))->toContain('compose -f docker-compose.yml -f docker-compose.dev.yml up -d') - ->toContain('inspect --format {{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}} coolify') - ->toContain('compose -f docker-compose.yml -f docker-compose.dev.yml exec -T coolify php artisan db:seed --class=ServerSeeder --force') - ->toContain('compose -f docker-compose.yml -f docker-compose.dev.yml logs -f') - ->toContain('compose -f docker-compose.yml -f docker-compose.dev.yml down') - ->not->toContain('docker-compose.dev-kvm.yml'); - } finally { - unlink($directory.'/bin/docker'); - if (file_exists($log)) { - unlink($log); - } - rmdir($directory.'/bin'); - rmdir($directory); - } -}); - -it('starts the kvm vm and uses the kvm compose override when available', 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.'); - } - - $directory = sys_get_temp_dir().'/coolify-jean-kvm-test-'.bin2hex(random_bytes(4)); - mkdir($directory.'/bin', 0777, true); - $log = $directory.'/commands.log'; - $fake = <<<'BASH' -#!/usr/bin/env bash -printf '%s %s\n' "$(basename "$0")" "$*" >> "$JEAN_TEST_LOG" -if [[ "$1" == inspect ]]; then - echo 'running healthy' -fi -BASH; - - foreach (['docker', 'php', 'virsh'] as $binary) { - file_put_contents($directory.'/bin/'.$binary, $fake); - chmod($directory.'/bin/'.$binary, 0755); - } - - try { - $process = new Process(['bash', base_path('scripts/dev'), 'run'], base_path(), [ - 'PATH' => $directory.'/bin:'.getenv('PATH'), - 'JEAN_TEST_LOG' => $log, - 'COOLIFY_DEV_KVM_PROFILE' => 'debian-root', - ]); - $process->run(); - - expect($process->isSuccessful())->toBeTrue($process->getErrorOutput()) - ->and(file_get_contents($log))->toContain('docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.dev-kvm.yml up -d') - ->toContain('docker inspect --format {{.State.Status}} {{if .State.Health}}{{.State.Health.Status}}{{end}} coolify') - ->toContain('php artisan dev:qemu debian-root --as-localhost') - ->toContain('docker compose -f docker-compose.yml -f docker-compose.dev.yml -f docker-compose.dev-kvm.yml down') - ->not->toContain('db:seed --class=ServerSeeder'); - } finally { - foreach (['docker', 'php', 'virsh'] as $binary) { - unlink($directory.'/bin/'.$binary); - } - if (file_exists($log)) { - unlink($log); - } - rmdir($directory.'/bin'); - rmdir($directory); - } -}); - -it('stops waiting when the coolify container exits', function () { - $directory = sys_get_temp_dir().'/coolify-health-test-'.bin2hex(random_bytes(4)); - mkdir($directory); - file_put_contents($directory.'/docker', <<<'BASH' -#!/usr/bin/env bash -echo 'exited unhealthy' -BASH); - chmod($directory.'/docker', 0755); - - try { - $process = new Process(['bash', base_path('scripts/dev'), 'start'], base_path(), [ - 'PATH' => $directory.':'.getenv('PATH'), - 'COOLIFY_DEV_SERVER_BACKEND' => 'testing-host', - ]); - $process->run(); - - expect($process->isSuccessful())->toBeFalse() - ->and($process->getErrorOutput())->toContain('did not become healthy'); - } finally { - unlink($directory.'/docker'); - rmdir($directory); - } -}); diff --git a/tests/Feature/ReverbAndTerminalPackagingTest.php b/tests/Feature/ReverbAndTerminalPackagingTest.php index 8b5fab4903..d0c93a9910 100644 --- a/tests/Feature/ReverbAndTerminalPackagingTest.php +++ b/tests/Feature/ReverbAndTerminalPackagingTest.php @@ -304,7 +304,8 @@ it('resolves the browser websocket port like 4.3.23', function (string $pageUrl, ]); it('uses current Reverb and terminal names in development tooling', function () { - expect(file_get_contents(base_path('scripts/dev-instances'))) - ->toContain('"REVERB" "TERMINAL"') - ->not->toContain('"SOKETI"'); + expect(file_get_contents(base_path('scripts/dev'))) + ->toContain('FORWARD_PUSHER_PORT') + ->toContain('FORWARD_TERMINAL_PORT') + ->not->toContain('SOKETI'); }); diff --git a/vite.config.js b/vite.config.js index 410c9e25e0..1c7c5c47b3 100644 --- a/vite.config.js +++ b/vite.config.js @@ -22,7 +22,7 @@ export default defineConfig(({ mode }) => { return { server: { watch: { - ignored: ["**/dev_*_data/**", "**/storage/**"], + ignored: ["**/dev_*_data/**", "**/storage/**", "**/vendor/**"], }, // Listen on all interfaces so Docker / remote clients can reach the dev server host: "0.0.0.0",