feat(dev): run one isolated dev instance per git branch

Merge scripts/dev-instances into scripts/dev. Each branch gets its own
Compose project, container, volumes, port block, libvirt network, and
KVM VMs, so stop and start reuse the same data and worktrees do not
collide.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-25 11:55:49 +02:00
co-authored by Claude Opus 5.5
parent 60bac941ea
commit 338f1f837f
20 changed files with 1227 additions and 802 deletions
+12 -11
View File
@@ -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 <name> # 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: <main checkout>/.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-<slot>` (`10.221.<slot>.0/24`) and VMs `coolify-dev-<branch>--<profile>`; VMs are reused, use `php artisan dev:qemu <profile> --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=<name>` to run another instance from the same checkout.
## Testing the Self-Hosted Upgrade Process
@@ -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 <<<XML
<network>
<name>{$network}</name>
<forward mode="nat"/>
<bridge name="virbr0" stp="on" delay="0"/>
<ip address="192.168.122.1" netmask="255.255.255.0">
<bridge name="{$bridge}" stp="on" delay="0"/>
<ip address="{$gateway}" netmask="{$netmask}">
<dhcp>
<range start="192.168.122.2" end="192.168.122.254"/>
<range start="{$rangeStart}" end="{$rangeEnd}"/>
</dhcp>
</ip>
</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.');
}
}
}
@@ -10,20 +10,25 @@ class ManageDevelopmentQemuVm
{
use AsAction;
/** @param string|array<int, string> $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<int, string> $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;
@@ -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("<host mac='%s' name='%s' ip='%s'/>", $profile['mac'], $profile['domain'], $profile['ip']);
$host = sprintf("<host mac='%s' name='%s' ip='%s'/>", $profile['mac'], $profile['template'], $profile['ip']);
$this->runOrFail("virsh net-update {$network} add-last ip-dhcp-host ".escapeshellarg($host).' --live --config');
}
@@ -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];
+145 -113
View File
@@ -1,124 +1,156 @@
<?php
/*
* Development QEMU VMs can run in two modes:
*
* - Legacy mode (DEVELOPMENT_QEMU_INSTANCE unset): one set of VMs on the libvirt
* "default" network (192.168.122.0/24, bridge virbr0).
* - Instance mode (DEVELOPMENT_QEMU_INSTANCE + DEVELOPMENT_QEMU_SLOT set): every dev
* instance gets its own isolated libvirt network "coolify-dev-{slot}" (10.221.{slot}.0/24,
* bridge cdevbr{slot}) and its own domains named "coolify-dev-{instance}--{profile}".
* MAC addresses stay identical to legacy mode because the prepared images match them.
*
* Values are computed here so the host and the Coolify container resolve identical settings.
*/
$developmentQemuInstance = trim((string) env('DEVELOPMENT_QEMU_INSTANCE', ''));
$developmentQemuInstance = $developmentQemuInstance === '' ? null : $developmentQemuInstance;
$developmentQemuSlot = $developmentQemuInstance === null ? null : (int) env('DEVELOPMENT_QEMU_SLOT', 0);
$developmentQemuProfiles = [
'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',
],
];
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,
];
-4
View File
@@ -1,4 +0,0 @@
# Opt-in KVM development mode. The default Compose setup still starts testing-host.
services:
testing-host:
profiles: ["testing-host"]
+41 -29
View File
@@ -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: <checkout>/.env, then <main checkout>/.dev-instances/<name>.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
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"scripts": {
"setup": "cp $JEAN_ROOT_PATH/.env .",
"teardown": null,
"teardown": "./scripts/dev teardown",
"run": "./scripts/dev run"
},
"ports": [
+368 -61
View File
@@ -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 <cmd...> | container
# ./scripts/dev destroy <name> # 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=<name> 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=<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.<slot>.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" <<EOF
# Generated by scripts/dev for instance ${name} — do not commit
COMPOSE_PROJECT_NAME=$(project_name "$name")
DEV_CHECKOUT=${ROOT}
DEVELOPMENT_QEMU_INSTANCE=${name}
DEVELOPMENT_QEMU_SLOT=${slot}
APP_NAME=Coolify-${name}
APP_URL=$(app_url "$app")
APP_KEY=${existing_key}
APP_PORT=${app}
FORWARD_PUSHER_PORT=${reverb}
FORWARD_TERMINAL_PORT=${terminal}
FORWARD_DB_PORT=${db}
FORWARD_REDIS_PORT=${redis}
VITE_PORT=${vite}
FORWARD_MAILPIT_PORT=${mail}
FORWARD_MAILPIT_DASHBOARD_PORT=${mail_ui}
FORWARD_MINIO_PORT=${minio}
FORWARD_MINIO_PORT_CONSOLE=${minio_ui}
PUSHER_APP_ID=coolify-${name}
PUSHER_APP_KEY=coolify-${name}
PUSHER_APP_SECRET=coolify-${name}
EOF
}
# compose <name> <args...>: .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 <name>' >&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
+1 -1
View File
@@ -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 <<EOF
-318
View File
@@ -1,318 +0,0 @@
#!/usr/bin/env bash
# Two local Coolify instances (isolated stacks) for multi-control-plane testing.
#
# Usage:
# ./scripts/dev-instances up # start a + b
# ./scripts/dev-instances up a # start a only
# ./scripts/dev-instances up a --with vite # HMR only for a single instance
# ./scripts/dev-instances down # stop a + b
# ./scripts/dev-instances urls
# ./scripts/dev-instances ps
# ./scripts/dev-instances logs a
# ./scripts/dev-instances exec a php artisan migrate --force
#
# Ports (fixed):
# a app 8000 db 5432 redis 6379 Reverb 6001/6002 flux 6443
# b app 8001 db 5433 redis 6380 Reverb 6011/6012 flux 6444
#
# Frontend: multi-instance uses npm run build (shared public/build). Do not use
# two Vite HMR servers — public/hot is a single shared file.
#
# Compose: docker-compose.dev-multi.yml
# Env: .dev-instances/{a,b}.env (generated, gitignored)
#
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
COMPOSE_FILE="docker-compose.dev-multi.yml"
ENV_DIR=".dev-instances"
INSTANCES_ALL=(a b)
usage() {
sed -n '2,18p' "$0" | sed 's/^# \?//'
exit "${1:-0}"
}
# Fixed port offsets: a=0, b=1
instance_offset() {
case "$1" in
a|1) echo 0 ;;
b|2) echo 1 ;;
*)
echo "Unknown instance '$1' (use a or b)" >&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" <<EOF
# Generated by scripts/dev-instances for instance ${name} — do not commit
COMPOSE_PROJECT_NAME=coolify-${name}
APP_NAME=Coolify-${name}
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost:${app_port}
APP_KEY=${existing_key}
APP_PORT=${app_port}
FORWARD_DB_PORT=$((5432 + offset))
FORWARD_REDIS_PORT=$((6379 + offset))
FORWARD_PUSHER_PORT=$((6001 + offset * 10))
FORWARD_TERMINAL_PORT=$((6002 + offset * 10))
FORWARD_FLUX_PORT=$((6443 + offset))
VITE_PORT=$((5173 + offset))
FORWARD_MAILPIT_PORT=$((1025 + offset * 100))
FORWARD_MAILPIT_DASHBOARD_PORT=$((8025 + offset * 100))
FORWARD_MINIO_PORT=$((9000 + offset * 10))
FORWARD_MINIO_PORT_CONSOLE=$((9001 + offset * 10))
DB_DATABASE=coolify
DB_USERNAME=coolify
DB_PASSWORD=password
PUSHER_APP_ID=coolify-${name}
PUSHER_APP_KEY=coolify-${name}
PUSHER_APP_SECRET=coolify-${name}
EOF
}
compose() {
local name="$1"
shift
local -a profiles=()
local p
for p in "${PROFILES[@]:-}"; do
[[ -n "$p" ]] && profiles+=(--profile "$p")
done
ensure_env "$name"
docker compose -p "$(project_name "$name")" -f "$COMPOSE_FILE" --env-file "$(env_file "$name")" "${profiles[@]}" "$@"
}
# Parse: [a] [b] [--with vite mailpit ...]
# Default instances when none given: a b
parse_args() {
NAMES=()
PROFILES=()
local mode=names
local arg
for arg in "$@"; do
if [[ "$arg" == "--with" ]]; then
mode=profiles
continue
fi
if [[ "$mode" == "profiles" ]]; then
PROFILES+=("$arg")
else
NAMES+=("$(normalize_name "$arg")")
fi
done
if [[ ${#NAMES[@]} -eq 0 ]]; then
NAMES=("${INSTANCES_ALL[@]}")
fi
}
# Multi-instance stacks share the repo bind-mount. Laravel's public/hot can only
# point at one Vite server, so dual HMR is broken (last Vite wins → missing CSS).
# Use a production frontend build shared by all instances instead.
ensure_frontend_assets() {
if [[ -f public/hot ]]; then
echo "Removing public/hot (multi-instance cannot share Vite HMR)..."
rm -f public/hot
fi
if [[ ! -d node_modules ]]; then
echo "Installing npm dependencies..."
npm install
fi
if [[ ! -f public/build/manifest.json ]]; then
echo "Building frontend assets (npm run build)..."
npm run build || {
echo "Warning: npm run build failed — UI will have no CSS/JS." >&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 "$@"
+1 -1
View File
@@ -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;
-100
View File
@@ -1,100 +0,0 @@
<?php
use Symfony\Component\Process\Process;
it('shows start and stop usage and rejects extra arguments', function () {
$script = base_path('scripts/dev');
$help = new Process(['bash', $script, '--help'], base_path());
$help->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);
}
});
+265
View File
@@ -0,0 +1,265 @@
<?php
use Symfony\Component\Process\Process;
beforeEach(function () {
$this->devRoot = sys_get_temp_dir().'/coolify-dev-script-'.bin2hex(random_bytes(4));
$main = $this->devRoot.'/main';
$this->devLog = $this->devRoot.'/commands.log';
$this->qemuStorage = $this->devRoot.'/qemu';
mkdir($main.'/scripts', 0777, true);
mkdir($this->devRoot.'/bin');
mkdir($this->qemuStorage);
copy(base_path('scripts/dev'), $main.'/scripts/dev');
chmod($main.'/scripts/dev', 0755);
copy(base_path('docker-compose.dev-multi.yml'), $main.'/docker-compose.dev-multi.yml');
file_put_contents($main.'/.gitignore', ".env\n.dev-instances/\n");
$git = Process::fromShellCommandline(implode(' && ', [
'git init -q -b main',
'git add -A',
'git -c user.name=test -c user.email=test@example.com commit -q -m init',
'git worktree add -q -b fix/Some_thing ../wt',
]), $main);
$git->run();
expect($git->isSuccessful())->toBeTrue($git->getErrorOutput());
$env = "APP_URL=https://devserver.example.ts.net:8000\nDEV_BIND_ADDRESS=127.0.0.1\n";
file_put_contents($main.'/.env', $env);
file_put_contents($this->devRoot.'/wt/.env', $env);
$fake = <<<'BASH'
#!/usr/bin/env bash
printf '%s %s\n' "$(basename "$0")" "$*" >> "$DEV_TEST_LOG"
case "$(basename "$0") $1" in
'docker inspect') echo "${DEV_TEST_HEALTH:-running healthy}" ;;
'docker ps') [[ -n "${DEV_TEST_RUNNING:-}" && "$*" == *"$DEV_TEST_RUNNING"* ]] && echo abc123 ;;
esac
if [[ "$(basename "$0")" == virsh && "$*" == *' list '* ]]; then
printf '%s\n' coolify-dev-fix-some_thing--ubuntu-root coolify-dev-fix-some_thing-2--ubuntu-root coolify-dev-ubuntu-root
fi
exit 0
BASH;
foreach (['docker', 'virsh', 'php', 'tailscale', 'iptables'] as $binary) {
file_put_contents($this->devRoot.'/bin/'.$binary, $fake);
chmod($this->devRoot.'/bin/'.$binary, 0755);
}
});
afterEach(function () {
Process::fromShellCommandline('rm -rf '.escapeshellarg($this->devRoot))->run();
});
function runDevScript(string $directory, array $arguments, array $env = []): Process
{
$process = new Process(['bash', 'scripts/dev', ...$arguments], test()->devRoot.'/'.$directory, [
'PATH' => test()->devRoot.'/bin:'.getenv('PATH'),
'DEV_TEST_LOG' => test()->devLog,
'DEVELOPMENT_QEMU_STORAGE_PATH' => test()->qemuStorage,
'COOLIFY_DEV_SERVER_BACKEND' => 'testing-host',
...$env,
]);
$process->run();
return $process;
}
function devScriptLog(): string
{
return file_exists(test()->devLog) ? file_get_contents(test()->devLog) : '';
}
function devInstanceEnv(string $name): string
{
return file_get_contents(test()->devRoot."/main/.dev-instances/{$name}.env");
}
it('shows usage and rejects invalid arguments', function () {
$help = runDevScript('main', ['--help']);
$extra = runDevScript('main', ['start', 'ubuntu-root', 'extra']);
$unknown = runDevScript('main', ['restart']);
$destroyWithoutName = runDevScript('main', ['destroy']);
expect($help->isSuccessful())->toBeTrue()
->and($help->getOutput())->toContain('start [qemu-profile]', 'stop [qemu-profile]', 'run [qemu-profile]', 'destroy <name>')
->and($extra->isSuccessful())->toBeFalse()
->and($unknown->isSuccessful())->toBeFalse()
->and($destroyWithoutName->isSuccessful())->toBeFalse();
});
it('starts the branch instance of the main checkout on the default ports', function () {
$process = runDevScript('main', ['start']);
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and(devInstanceEnv('main'))
->toContain('COMPOSE_PROJECT_NAME=coolify-dev-main')
->toContain('DEVELOPMENT_QEMU_INSTANCE=main')
->toContain('DEVELOPMENT_QEMU_SLOT=1')
->toContain('APP_URL=https://devserver.example.ts.net:8000')
->toContain('APP_PORT=8000')
->toContain('FORWARD_DB_PORT=5432')
->and(devScriptLog())
->toContain('docker stop coolify coolify-db coolify-redis')
->toContain('docker compose -p coolify-dev-main -f docker-compose.dev-multi.yml --env-file .env --env-file')
->toContain('--profile vite --profile mailpit --profile minio --profile testing-host up -d --build')
->toContain('coolify-dev-main')
->toContain('exec -T coolify php artisan db:seed --class=ServerSeeder --force')
->not->toContain('dev:qemu');
});
it('gives a worktree branch its own port block, tailscale ports, and stable data', function () {
runDevScript('main', ['start']);
file_put_contents($this->devLog, '');
$first = runDevScript('wt', ['start']);
$appKey = preg_replace('/.*^APP_KEY=(\S+)$.*/ms', '$1', devInstanceEnv('fix-some_thing'));
$second = runDevScript('wt', ['start']);
expect($first->isSuccessful())->toBeTrue($first->getErrorOutput())
->and($second->isSuccessful())->toBeTrue($second->getErrorOutput())
->and(devInstanceEnv('fix-some_thing'))
->toContain('COMPOSE_PROJECT_NAME=coolify-dev-fix-some_thing')
->toContain('DEVELOPMENT_QEMU_SLOT=2')
->toContain('APP_URL=https://devserver.example.ts.net:20020')
->toContain('FORWARD_PUSHER_PORT=20021')
->toContain('FORWARD_TERMINAL_PORT=20022')
->toContain('FORWARD_DB_PORT=20023')
->toContain('VITE_PORT=20025')
->toContain("APP_KEY={$appKey}")
->toContain('DEV_CHECKOUT='.realpath($this->devRoot.'/wt'))
->and(file_get_contents($this->devRoot.'/main/.dev-instances/slots'))->toBe("main 1\nfix-some_thing 2\n")
->and(devScriptLog())
->toContain('tailscale serve --bg --https=20020 http://127.0.0.1:20020')
->toContain('tailscale serve --bg --https=20021 http://127.0.0.1:20021')
->toContain('tailscale serve --bg --https=20022 http://127.0.0.1:20022')
->toContain('tailscale serve --bg --https=20025 http://127.0.0.1:20025')
->not->toContain('docker stop coolify ')
->and($this->devRoot.'/wt/.dev-instances')->not->toBeDirectory();
});
it('stops the other running instance of the same checkout', function () {
runDevScript('main', ['start'], ['COOLIFY_DEV_INSTANCE' => 'older']);
file_put_contents($this->devLog, '');
$process = runDevScript('main', ['start'], ['DEV_TEST_RUNNING' => 'coolify-dev-older']);
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and(devScriptLog())->toContain('docker compose -p coolify-dev-older stop');
});
it('fails when the coolify container exits', function () {
$process = runDevScript('main', ['start'], ['DEV_TEST_HEALTH' => 'exited unhealthy']);
expect($process->isSuccessful())->toBeFalse()
->and($process->getErrorOutput())->toContain('did not become healthy');
});
it('removes the containers when a run ends', function () {
$process = runDevScript('wt', ['run']);
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and(devScriptLog())->toContain('logs -f --tail=100')
->toContain(' stop')
->toContain('down --remove-orphans');
});
it('lists instances with their urls and checkouts', function () {
runDevScript('main', ['start']);
runDevScript('wt', ['start']);
$process = runDevScript('wt', ['urls']);
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and($process->getOutput())
->toMatch('/^main\s+https:\/\/devserver\.example\.ts\.net:8000\s+running healthy\s+5432/m')
->toMatch('/^fix-some_thing\s+https:\/\/devserver\.example\.ts\.net:20020\s+running healthy\s+20023/m');
});
it('destroys only the named instance data, vms, and slot', function () {
runDevScript('main', ['start']);
runDevScript('wt', ['start']);
foreach (['coolify-dev-fix-some_thing--ubuntu-root.qcow2', 'coolify-dev-fix-some_thing-2--ubuntu-root.qcow2', 'coolify-dev-ubuntu-root-prepared.qcow2'] as $file) {
touch($this->qemuStorage.'/'.$file);
}
file_put_contents($this->devLog, '');
$process = runDevScript('main', ['destroy', 'fix-some_thing']);
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and(devScriptLog())
->toContain('down --remove-orphans --volumes')
->toContain('virsh --connect qemu:///system undefine coolify-dev-fix-some_thing--ubuntu-root')
->not->toContain('undefine coolify-dev-fix-some_thing-2--ubuntu-root')
->not->toContain('undefine coolify-dev-ubuntu-root')
->toContain('virsh --connect qemu:///system net-undefine coolify-dev-2')
->toContain('tailscale serve --https=20020 off')
->and($this->qemuStorage.'/coolify-dev-fix-some_thing--ubuntu-root.qcow2')->not->toBeFile()
->and($this->qemuStorage.'/coolify-dev-fix-some_thing-2--ubuntu-root.qcow2')->toBeFile()
->and($this->qemuStorage.'/coolify-dev-ubuntu-root-prepared.qcow2')->toBeFile()
->and($this->devRoot.'/main/.dev-instances/fix-some_thing.env')->not->toBeFile()
->and(file_get_contents($this->devRoot.'/main/.dev-instances/slots'))->toBe("main 1\n");
});
it('starts the instance kvm vm with the instance database and names', function () {
if (! function_exists('posix_geteuid') || posix_geteuid() !== 0 || ! file_exists('/dev/kvm') || filetype('/dev/kvm') !== 'char' || ! is_readable('/dev/kvm') || ! is_writable('/dev/kvm')) {
$this->markTestSkipped('KVM and root access are required for this launcher branch.');
}
file_put_contents($this->devRoot.'/bin/php', <<<'BASH'
#!/usr/bin/env bash
printf 'php %s DB=%s:%s LOG=%s QEMU=%s/%s/%s/%s\n' "$*" "$DB_HOST" "$DB_PORT" "$LOG_CHANNEL" "$DEVELOPMENT_QEMU_INSTANCE" "$DEVELOPMENT_QEMU_SLOT" "$DEVELOPMENT_QEMU_DOCKER_NETWORK" "$DEVELOPMENT_QEMU_COOLIFY_CONTAINER" >> "$DEV_TEST_LOG"
BASH);
runDevScript('main', ['start'], ['COOLIFY_DEV_SERVER_BACKEND' => 'auto']);
$start = runDevScript('wt', ['start', 'debian-root'], ['COOLIFY_DEV_SERVER_BACKEND' => 'auto']);
$stop = runDevScript('wt', ['stop'], ['COOLIFY_DEV_SERVER_BACKEND' => 'auto']);
expect($start->isSuccessful())->toBeTrue($start->getErrorOutput())
->and($stop->isSuccessful())->toBeTrue($stop->getErrorOutput())
->and(devScriptLog())
->toContain('php artisan dev:qemu debian-root --as-localhost DB=127.0.0.1:20023 LOG=stderr QEMU=fix-some_thing/2/coolify-dev-fix-some_thing/coolify-dev-fix-some_thing')
->toContain('virsh --connect qemu:///system shutdown coolify-dev-fix-some_thing--ubuntu-root')
->not->toContain('shutdown coolify-dev-fix-some_thing-2--ubuntu-root')
->not->toContain('shutdown coolify-dev-ubuntu-root')
->not->toContain('--profile testing-host up');
});
it('keeps fresh worktree instances writable and stable during the first composer install', function () {
$initSetup = file_get_contents(base_path('docker/development/etc/s6-overlay/scripts/init-setup.sh'));
expect(strpos($initSetup, 'touch storage/logs/laravel.log'))->toBeLessThan(strpos($initSetup, 'chown -R www-data:www-data storage'))
->and(file_get_contents(base_path('vite.config.js')))->toContain('"**/vendor/**"');
});
it('tears down the instance of a deleted worktree', function () {
runDevScript('main', ['start']);
runDevScript('wt', ['start']);
file_put_contents($this->devLog, '');
$process = runDevScript('wt', ['teardown']);
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and(devScriptLog())->toContain('docker compose -p coolify-dev-fix-some_thing')
->toContain('down --remove-orphans --volumes')
->not->toContain('-p coolify-dev-main ')
->and($this->devRoot.'/main/.dev-instances/fix-some_thing.env')->not->toBeFile()
->and($this->devRoot.'/main/.dev-instances/main.env')->toBeFile();
});
it('lets jean delete a worktree that never started an instance', function () {
$process = runDevScript('wt', ['teardown']);
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput())
->and($process->getOutput())->toContain('nothing to destroy')
->and(devScriptLog())->not->toContain('docker compose');
});
it('never tears down the main checkout instance', function () {
runDevScript('main', ['start']);
$process = runDevScript('main', ['teardown']);
expect($process->isSuccessful())->toBeFalse()
->and($this->devRoot.'/main/.dev-instances/main.env')->toBeFile();
});
+273 -15
View File
@@ -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></network>'),
'* 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></network>'),
'* 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></network>'),
'* 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></network>'),
'* 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></network>'),
'* 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></network>'),
'* 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></network>'),
'* 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></network>'),
'* 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(
'<name>coolify-dev-3</name>',
'<bridge name="cdevbr3" stp="on" delay="0"/>',
'<ip address="10.221.3.1" netmask="255.255.255.0">',
'<range start="10.221.3.2" end="10.221.3.254"/>',
)->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(
'<name>default</name>',
'<bridge name="virbr0" stp="on" delay="0"/>',
'<ip address="192.168.122.1" netmask="255.255.255.0">',
'<range start="192.168.122.2" end="192.168.122.254"/>',
);
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></network>'),
'* 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<string, string> $environment
* @return array<string, mixed>
*/
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,
]]);
}
+1 -111
View File
@@ -1,119 +1,9 @@
<?php
use Symfony\Component\Process\Process;
it('uses the jean launcher in the run configuration', function () {
$config = json_decode(file_get_contents(base_path('jean.json')), true, flags: JSON_THROW_ON_ERROR);
expect($config['scripts']['run'])->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);
}
});
@@ -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');
});
+1 -1
View File
@@ -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",