mirror of
https://github.com/coollabsio/coolify.git
synced 2026-09-26 17:30:46 -04:00
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:
co-authored by
Claude Opus 5.5
parent
60bac941ea
commit
338f1f837f
@@ -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
@@ -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,
|
||||
];
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# Opt-in KVM development mode. The default Compose setup still starts testing-host.
|
||||
services:
|
||||
testing-host:
|
||||
profiles: ["testing-host"]
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"scripts": {
|
||||
"setup": "cp $JEAN_ROOT_PATH/.env .",
|
||||
"teardown": null,
|
||||
"teardown": "./scripts/dev teardown",
|
||||
"run": "./scripts/dev run"
|
||||
},
|
||||
"ports": [
|
||||
|
||||
+368
-61
@@ -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
@@ -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
|
||||
|
||||
@@ -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 "$@"
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user