From b4f9c9b51d07394177be69eb409bf195e213104c Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Tue, 18 Aug 2026 21:14:29 +0200 Subject: [PATCH] feat(development): add QEMU VM provisioning and server seeding Add configurable development QEMU profiles, host setup, VM lifecycle management, and commands to seed managed VMs as Coolify servers. Improve installation dialog logs with responsive mobile presentation. --- .../ConfigureDevelopmentQemuHost.php | 122 +++++++++ .../Development/ManageDevelopmentQemuVm.php | 33 +++ .../Development/SeedDevelopmentQemuServer.php | 60 +++++ .../Development/StartDevelopmentQemuVm.php | 219 ++++++++++++++++ .../ManageDevelopmentQemuVmCommand.php | 40 +++ .../SeedDevelopmentQemuServerCommand.php | 27 ++ config/development-qemu.php | 124 +++++++++ resources/css/app.css | 30 +++ .../views/components/process-dialog.blade.php | 8 +- .../views/livewire/server/show.blade.php | 2 +- .../server/validate-and-install.blade.php | 25 +- tests/Feature/DevelopmentQemuVmTest.php | 242 ++++++++++++++++++ tests/Feature/ServerValidationDialogTest.php | 43 +++- 13 files changed, 962 insertions(+), 13 deletions(-) create mode 100644 app/Actions/Development/ConfigureDevelopmentQemuHost.php create mode 100644 app/Actions/Development/ManageDevelopmentQemuVm.php create mode 100644 app/Actions/Development/SeedDevelopmentQemuServer.php create mode 100644 app/Actions/Development/StartDevelopmentQemuVm.php create mode 100644 app/Console/Commands/ManageDevelopmentQemuVmCommand.php create mode 100644 app/Console/Commands/SeedDevelopmentQemuServerCommand.php create mode 100644 config/development-qemu.php create mode 100644 tests/Feature/DevelopmentQemuVmTest.php diff --git a/app/Actions/Development/ConfigureDevelopmentQemuHost.php b/app/Actions/Development/ConfigureDevelopmentQemuHost.php new file mode 100644 index 000000000..a999b77ad --- /dev/null +++ b/app/Actions/Development/ConfigureDevelopmentQemuHost.php @@ -0,0 +1,122 @@ +ensureDevelopmentEnvironment(); + $this->installDependencies(); + $this->runOrFail('systemctl enable --now libvirtd'); + $this->configureLibvirtNetwork(); + $this->configureIpForwarding(); + $this->configureStorage(); + $this->configureDockerForwarding(); + } + + private function installDependencies(): void + { + $binaries = ['curl', 'docker', 'iptables', 'qemu-img', 'virsh', 'virt-install']; + $check = collect($binaries)->map(fn (string $binary) => 'command -v '.escapeshellarg($binary))->implode(' && '); + + if (Process::run($check)->successful()) { + return; + } + + if (! File::exists('/usr/bin/apt-get')) { + throw new RuntimeException('Missing QEMU dependencies. Automatic installation currently supports apt-based development hosts.'); + } + + $this->runOrFail('apt-get update'); + $this->runOrFail('DEBIAN_FRONTEND=noninteractive apt-get install -y curl iptables libvirt-clients libvirt-daemon-system qemu-utils qemu-system-x86 virtinst'); + } + + private function configureLibvirtNetwork(): void + { + $network = config('development-qemu.libvirt_network'); + $networkInfo = Process::run('virsh net-info '.escapeshellarg($network)); + + if ($networkInfo->failed()) { + $networkXml = config('development-qemu.storage_path').'/libvirt-network.xml'; + File::ensureDirectoryExists(dirname($networkXml), 0777, true); + File::put($networkXml, $this->libvirtNetworkXml($network)); + $this->runOrFail('virsh net-define '.escapeshellarg($networkXml)); + $networkInfo = Process::result(output: 'Active: no'); + } + + if (! preg_match('/^Active:\s+yes$/m', $networkInfo->output())) { + $this->runOrFail('virsh net-start '.escapeshellarg($network)); + } + + $this->runOrFail('virsh net-autostart '.escapeshellarg($network)); + } + + private function configureIpForwarding(): void + { + $this->runOrFail("printf 'net.ipv4.ip_forward=1\\n' > /etc/sysctl.d/99-coolify-development-qemu.conf"); + $this->runOrFail('sysctl -w net.ipv4.ip_forward=1'); + } + + private function configureStorage(): void + { + $directory = config('development-qemu.storage_path'); + File::ensureDirectoryExists($directory, 0777, true); + File::chmod($directory, 0777); + } + + private function configureDockerForwarding(): void + { + $dockerNetwork = escapeshellarg(config('development-qemu.docker_network')); + $subnetResult = Process::run("docker network inspect {$dockerNetwork} --format ".escapeshellarg('{{(index .IPAM.Config 0).Subnet}}')); + $subnet = trim($subnetResult->output()); + + if ($subnetResult->failed() || $subnet === '') { + 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'))); + + Process::run("iptables -D LIBVIRT_FWI {$rule}"); + $this->runOrFail("iptables -I LIBVIRT_FWI 1 {$rule}"); + } + + private function libvirtNetworkXml(string $network): string + { + return << + {$network} + + + + + + + + +XML; + } + + private function runOrFail(string $command): void + { + $result = Process::forever()->run($command); + + if ($result->failed()) { + throw new RuntimeException(trim($result->errorOutput()) ?: "Command failed: {$command}"); + } + } + + private function ensureDevelopmentEnvironment(): void + { + if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) { + throw new RuntimeException('QEMU host configuration may only run in development environments.'); + } + } +} diff --git a/app/Actions/Development/ManageDevelopmentQemuVm.php b/app/Actions/Development/ManageDevelopmentQemuVm.php new file mode 100644 index 000000000..a1257cbda --- /dev/null +++ b/app/Actions/Development/ManageDevelopmentQemuVm.php @@ -0,0 +1,33 @@ + $profileNames */ + public function handle(string|array $profileNames): void + { + $profileNames = is_array($profileNames) ? array_values(array_unique($profileNames)) : [$profileNames]; + + foreach ($profileNames as $index => $profileName) { + StartDevelopmentQemuVm::run($profileName, $index === 0); + + try { + SeedDevelopmentQemuServer::run($profileName, $index === 0); + } catch (QueryException $exception) { + $keepOthers = $index === 0 ? '' : ' --keep-others'; + $result = Process::run('docker exec coolify php artisan dev:qemu:seed '.escapeshellarg($profileName).$keepOthers); + + if ($result->failed()) { + throw $exception; + } + } + } + } +} diff --git a/app/Actions/Development/SeedDevelopmentQemuServer.php b/app/Actions/Development/SeedDevelopmentQemuServer.php new file mode 100644 index 000000000..10cb0b3c7 --- /dev/null +++ b/app/Actions/Development/SeedDevelopmentQemuServer.php @@ -0,0 +1,60 @@ +ensureDevelopmentEnvironment(); + $profile = config("development-qemu.profiles.{$profileName}"); + + if (! is_array($profile)) { + throw new InvalidArgumentException("Unknown development QEMU profile: {$profileName}"); + } + + $privateKey = PrivateKey::query()->find(1); + + if (! $privateKey) { + throw new RuntimeException('Development private key 1 is missing. Run the development database seeders first.'); + } + + if ($removeOtherServers) { + Server::query() + ->where('uuid', 'like', 'development-qemu-%') + ->where('uuid', '!=', $profile['uuid']) + ->delete(); + } + + $server = Server::withTrashed()->where('uuid', $profile['uuid'])->first() ?? new Server; + $server->forceFill(['uuid' => $profile['uuid']]); + $server->fill([ + 'name' => $profile['name'], + 'description' => 'Development-only QEMU virtual machine managed by dev:qemu.', + 'ip' => $profile['ip'], + 'port' => 22, + 'user' => $profile['user'], + 'team_id' => 0, + 'private_key_id' => $privateKey->id, + ]); + $server->deleted_at = null; + $server->save(); + + return $server->fresh(); + } + + private function ensureDevelopmentEnvironment(): void + { + if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) { + throw new RuntimeException('QEMU VM servers may only be seeded in development environments.'); + } + } +} diff --git a/app/Actions/Development/StartDevelopmentQemuVm.php b/app/Actions/Development/StartDevelopmentQemuVm.php new file mode 100644 index 000000000..d6d9c00b2 --- /dev/null +++ b/app/Actions/Development/StartDevelopmentQemuVm.php @@ -0,0 +1,219 @@ +ensureDevelopmentEnvironment(); + $profiles = config('development-qemu.profiles'); + $profile = $profiles[$profileName] ?? null; + + if (! is_array($profile)) { + throw new InvalidArgumentException("Unknown development QEMU profile: {$profileName}"); + } + + 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']); + } + } + + $this->createVm($profile); + + 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 createVm(array $profile): void + { + $directory = config('development-qemu.storage_path'); + File::ensureDirectoryExists($directory); + File::chmod($directory, 0777); + $this->moveLegacyFiles($directory); + $baseImage = "{$directory}/{$profile['image']}"; + $disk = "{$directory}/{$profile['domain']}.qcow2"; + $userData = "{$directory}/{$profile['domain']}-user-data.yaml"; + $networkConfig = "{$directory}/{$profile['domain']}-network.yaml"; + + if (! File::exists($baseImage)) { + $this->runOrFail(sprintf( + 'curl --fail --location --output %s %s', + escapeshellarg($baseImage), + escapeshellarg($profile['image_url']), + )); + } + + if (! File::exists($disk)) { + $this->runOrFail(sprintf( + 'qemu-img create -f qcow2 -F qcow2 -b %s %s %s', + escapeshellarg($baseImage), + escapeshellarg($disk), + escapeshellarg(config('development-qemu.disk_size')), + )); + } + + if (File::exists($baseImage)) { + File::chmod($baseImage, 0644); + } + + if (File::exists($disk)) { + File::chmod($disk, 0666); + } + + File::put($userData, $this->userData($profile)); + File::put($networkConfig, $this->networkConfig($profile)); + + $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 --network network=%s,model=virtio,mac=%s --cloud-init user-data=%s,network-config=%s,disable=on --noautoconsole', + escapeshellarg($profile['domain']), + config('development-qemu.memory'), + config('development-qemu.vcpus'), + escapeshellarg($profile['os_variant']), + escapeshellarg($disk), + escapeshellarg(config('development-qemu.libvirt_network')), + escapeshellarg($profile['mac']), + escapeshellarg($userData), + escapeshellarg($networkConfig), + )); + } + + private function moveLegacyFiles(string $directory): void + { + $legacyDirectory = storage_path('app/development-qemu'); + + if ($legacyDirectory === $directory || ! File::isDirectory($legacyDirectory)) { + return; + } + + foreach (File::files($legacyDirectory) as $file) { + $destination = "{$directory}/{$file->getFilename()}"; + + if (! File::exists($destination)) { + File::move($file->getPathname(), $destination); + } + } + } + + private function deleteVmData(string $domain): void + { + $directory = config('development-qemu.storage_path'); + File::delete([ + "{$directory}/{$domain}.qcow2", + "{$directory}/{$domain}-user-data.yaml", + "{$directory}/{$domain}-network.yaml", + ]); + } + + /** @param array{user: string, provisioner: string} $profile */ + private function userData(array $profile): string + { + $publicKey = config('development-qemu.public_key'); + $adminGroup = $profile['provisioner'] === 'apt' ? 'sudo' : 'wheel'; + $sudo = $profile['user'] === 'root' ? '' : " groups: [{$adminGroup}]\n sudo: ALL=(ALL) NOPASSWD:ALL\n"; + + [$packages, $startDocker] = match ($profile['provisioner']) { + 'apk' => [" - docker\n - sudo", 'rc-update add docker default && service docker start'], + 'rpm' => [" - curl\n - sudo", 'curl -fsSL https://get.docker.com | sh && systemctl enable --now docker'], + default => [" - docker.io\n - sudo", 'systemctl enable --now docker'], + }; + $addUserToDockerGroup = $profile['user'] === 'root' ? '' : "\n - usermod -aG docker {$profile['user']}"; + + return <<failed()) { + throw new RuntimeException(trim($networkXml->errorOutput()) ?: 'Unable to inspect the libvirt network.'); + } + + if (str_contains($networkXml->output(), $profile['mac']) && str_contains($networkXml->output(), $profile['ip'])) { + return; + } + + $host = sprintf("", $profile['mac'], $profile['domain'], $profile['ip']); + $this->runOrFail("virsh net-update {$network} add-last ip-dhcp-host ".escapeshellarg($host).' --live --config'); + } + + private function waitForSsh(string $ip): void + { + $container = escapeshellarg(config('development-qemu.coolify_container')); + $probe = <<<'PHP' +$deadline = time() + 120; +do { + $socket = @fsockopen($argv[1], 22, $errorCode, $errorMessage, 1); + if (is_resource($socket)) { + fclose($socket); + exit(0); + } + sleep(1); +} while (time() < $deadline); +exit(1); +PHP; + $this->runOrFail("docker exec {$container} php -r ".escapeshellarg($probe).' '.escapeshellarg($ip)); + } + + private function runOrFail(string $command): void + { + $result = Process::forever()->run($command); + + if ($result->failed()) { + throw new RuntimeException(trim($result->errorOutput()) ?: "Command failed: {$command}"); + } + + } + + private function ensureDevelopmentEnvironment(): void + { + if (! in_array(config('app.env'), ['local', 'development', 'dev'], true)) { + throw new RuntimeException('QEMU VMs may only be managed in development environments.'); + } + } +} diff --git a/app/Console/Commands/ManageDevelopmentQemuVmCommand.php b/app/Console/Commands/ManageDevelopmentQemuVmCommand.php new file mode 100644 index 000000000..a93e1779b --- /dev/null +++ b/app/Console/Commands/ManageDevelopmentQemuVmCommand.php @@ -0,0 +1,40 @@ +error('This command may only run in development mode.'); + + return self::FAILURE; + } + + $profiles = config('development-qemu.profiles'); + $profileNames = $this->argument('profiles') ?: multiselect( + label: 'Which QEMU servers should be started and seeded?', + options: collect($profiles)->mapWithKeys(fn (array $profile, string $key) => [$key => $profile['label']])->all(), + required: true, + ); + + ManageDevelopmentQemuVm::run($profileNames); + + foreach ($profileNames as $profileName) { + $profile = $profiles[$profileName]; + $this->info("Started and seeded {$profile['label']} at {$profile['ip']}."); + } + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/SeedDevelopmentQemuServerCommand.php b/app/Console/Commands/SeedDevelopmentQemuServerCommand.php new file mode 100644 index 000000000..4772317c3 --- /dev/null +++ b/app/Console/Commands/SeedDevelopmentQemuServerCommand.php @@ -0,0 +1,27 @@ +error('This command may only run in development mode.'); + + return self::FAILURE; + } + + $server = SeedDevelopmentQemuServer::run($this->argument('profile'), ! $this->option('keep-others')); + $this->info("Seeded {$server->name} at {$server->ip}."); + + return self::SUCCESS; + } +} diff --git a/config/development-qemu.php b/config/development-qemu.php new file mode 100644 index 000000000..ae77c8341 --- /dev/null +++ b/config/development-qemu.php @@ -0,0 +1,124 @@ + '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', + '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', + ], + ], +]; diff --git a/resources/css/app.css b/resources/css/app.css index 9dc39b7cc..30ccf4ac0 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -2121,6 +2121,36 @@ .process-dialog-body > * { width: 100%; } +.validation-installation-logs { + border: 1px solid var(--coollabs-fill); +} + +.checkpoint-scroll-fade::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + z-index: 1; + width: 2rem; + background: linear-gradient(to left, var(--coollabs-base), transparent); + pointer-events: none; +} + +@media (max-width: 639px) { + .process-dialog-mobile-fullscreen { + height: 100dvh !important; + min-height: 100dvh; + max-height: 100dvh; + border-radius: 0; + box-shadow: inset 0 0 0 1px var(--coollabs-hairline) !important; + } + + .process-dialog-mobile-fullscreen .process-dialog-body { + border-radius: 0; + } +} + /* Data table (layer-card body, full-bleed) */ .data-table-header { display: grid; diff --git a/resources/views/components/process-dialog.blade.php b/resources/views/components/process-dialog.blade.php index dc4f9c3b5..193e5680c 100644 --- a/resources/views/components/process-dialog.blade.php +++ b/resources/views/components/process-dialog.blade.php @@ -1,5 +1,6 @@ @props([ 'closeWithX' => false, + 'mobileFullscreen' => false, 'open' => false, 'size' => 'lg', ]) @@ -40,7 +41,11 @@ class="fixed inset-0 bg-black/50 backdrop-blur-[2px] dark:bg-black/60">
+ @class([ + 'flex min-h-full items-center justify-center', + 'p-4 sm:p-6' => ! $mobileFullscreen, + 'p-0 sm:p-6' => $mobileFullscreen, + ])>
aria-labelledby="process-dialog-title" @class([ 'application-settings-section application-settings-form process-dialog relative flex flex-col overflow-hidden', + 'process-dialog-mobile-fullscreen' => $mobileFullscreen, $panelWidth, // Fixed shell size so empty “waiting for process” state does not collapse. 'min-h-[min(70dvh,28rem)] h-[min(85dvh,52rem)] max-h-[calc(100dvh-2rem)]', diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index d2d28f095..1ee4279bd 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -184,7 +184,7 @@ class="absolute top-9 right-0 z-50 w-56 rounded-lg border border-neutral-200 bg-
@endif - + Validate and configure + class="shrink-0 overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]">

Validation checkpoints

-
- @foreach ($checkpoints as $checkpoint) - - @endforeach +
+
+ @foreach ($checkpoints as $checkpoint) + + @endforeach +
@@ -107,7 +118,7 @@ class="overflow-hidden rounded-[10px] border border-neutral-200 dark:border-whit
@elseif ($isInstalling) -
+
diff --git a/tests/Feature/DevelopmentQemuVmTest.php b/tests/Feature/DevelopmentQemuVmTest.php new file mode 100644 index 000000000..18e5ff995 --- /dev/null +++ b/tests/Feature/DevelopmentQemuVmTest.php @@ -0,0 +1,242 @@ + 'local']); + $this->seed([UserSeeder::class, TeamSeeder::class, PrivateKeySeeder::class]); +}); + +it('registers the interactive qemu command', function () { + expect(Artisan::all()) + ->toHaveKey('dev:qemu') + ->toHaveKey('dev:qemu:seed') + ->and(Artisan::all()['dev:qemu'])->toBeInstanceOf(ManageDevelopmentQemuVmCommand::class) + ->and(Artisan::all()['dev:qemu']->getDefinition()->getArgument('profiles')->isArray())->toBeTrue() + ->and(Artisan::all()['dev:qemu:seed'])->toBeInstanceOf(SeedDevelopmentQemuServerCommand::class); +}); + +it('prevents qemu commands from running outside development', function () { + config(['app.env' => 'production']); + Process::fake(); + + expect(Artisan::call('dev:qemu', ['profiles' => ['ubuntu-root']]))->toBe(Command::FAILURE) + ->and(Artisan::call('dev:qemu:seed', ['profile' => 'ubuntu-root']))->toBe(Command::FAILURE); + + Process::assertNothingRan(); +}); + +it('provides root and non-root profiles for every supported distribution', function () { + $profiles = collect(config('development-qemu.profiles')); + + expect($profiles->keys()->all())->toBe([ + 'ubuntu-root', + 'ubuntu-non-root', + 'debian-root', + 'debian-non-root', + 'centos-root', + 'centos-non-root', + 'alpine-root', + 'alpine-non-root', + ])->and($profiles->pluck('ip')->unique()->count())->toBe(8) + ->and($profiles->pluck('mac')->unique()->count())->toBe(8) + ->and($profiles->filter(fn (array $profile) => $profile['user'] === 'root')->count())->toBe(4) + ->and($profiles->filter(fn (array $profile) => $profile['user'] !== 'root')->count())->toBe(4); +}); + +it('stores vm disks in a libvirt-accessible directory', function () { + expect(config('development-qemu.storage_path'))->toStartWith('/var/lib/libvirt/images/'); +}); + +it('automatically configures the qemu host', function () { + Process::fake(function ($process) { + if (str_contains($process->command, 'command -v')) { + return Process::result(); + } + + if (str_contains($process->command, 'net-info')) { + return Process::result(exitCode: 1); + } + + if (str_contains($process->command, 'network inspect')) { + return Process::result(output: "172.18.0.0/16\n"); + } + + if (str_contains($process->command, 'iptables -C')) { + return Process::result(exitCode: 1); + } + + return Process::result(); + }); + + ConfigureDevelopmentQemuHost::run(); + + Process::assertRan(fn ($process) => str_contains($process->command, 'systemctl enable --now libvirtd')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh net-define')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh net-start')); + Process::assertRan(fn ($process) => str_contains($process->command, 'virsh net-autostart')); + Process::assertRan(fn ($process) => str_contains($process->command, 'sysctl -w net.ipv4.ip_forward=1')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI')); +}); + +it('does not restart an active libvirt network', function () { + Process::fake(function ($process) { + if (str_contains($process->command, 'net-info')) { + return Process::result(output: "Name: default\nActive: yes\n"); + } + + if (str_contains($process->command, 'network inspect')) { + return Process::result(output: "172.18.0.0/16\n"); + } + + return Process::result(); + }); + + ConfigureDevelopmentQemuHost::run(); + + Process::assertNotRan(fn ($process) => str_contains($process->command, 'virsh net-start')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -D LIBVIRT_FWI')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI 1')); +}); + +it('seeds one predefined root qemu server', function () { + $server = SeedDevelopmentQemuServer::run('ubuntu-root'); + + expect($server->uuid)->toBe('development-qemu-ubuntu-root') + ->and($server->ip)->toBe('192.168.122.10') + ->and($server->user)->toBe('root') + ->and($server->team_id)->toBe(0) + ->and(Server::query()->where('uuid', 'like', 'development-qemu-%')->count())->toBe(1); +}); + +it('replaces the seeded qemu server with the selected non-root equivalent', function () { + SeedDevelopmentQemuServer::run('ubuntu-root'); + $server = SeedDevelopmentQemuServer::run('ubuntu-non-root'); + + expect($server->ip)->toBe('192.168.122.11') + ->and($server->user)->toBe('coolify') + ->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(); + 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'); + + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '* iptables -C *' => Process::result(exitCode: 1), + '* dominfo *' => Process::result(output: 'exists'), + '*' => 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::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(); +}); + +it('rejects qemu vm management outside development', function () { + config(['app.env' => 'production']); + + expect(fn () => StartDevelopmentQemuVm::run('ubuntu-root')) + ->toThrow(RuntimeException::class, 'development environments'); +}); + +it('can create a vm without a host database connection', function () { + config([ + 'development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-test-'.uniqid(), + ]); + DB::enableQueryLog(); + Process::fake(function ($process) { + if (str_contains($process->command, 'net-dumpxml')) { + return Process::result(output: ''); + } + + if (str_contains($process->command, 'network inspect')) { + return Process::result(output: "172.18.0.0/16\n"); + } + + if (str_contains($process->command, 'virsh dominfo')) { + return Process::result(exitCode: 1); + } + + if (str_contains($process->command, 'iptables -C')) { + return Process::result(exitCode: 1); + } + + return Process::result(); + }); + + StartDevelopmentQemuVm::run('ubuntu-root'); + + expect(DB::getQueryLog())->toBeEmpty(); + Process::assertRan(fn ($process) => str_contains($process->command, 'virt-install')); + Process::assertRan(fn ($process) => str_contains($process->command, 'iptables -I LIBVIRT_FWI')); +}); + +it('seeds through the coolify container when the host database is unavailable', function () { + config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-fallback-test-'.uniqid()]); + SeedDevelopmentQemuServer::mock() + ->shouldReceive('handle') + ->once() + ->andThrow(new QueryException('pgsql', 'select 1', [], new Exception('unavailable'))); + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '* dominfo *' => Process::result(output: 'exists'), + '*' => Process::result(), + ]); + + 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')); +}); + +it('starts and seeds root and non-root profiles together', function () { + config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-multi-test-'.uniqid()]); + Process::fake([ + '* net-dumpxml *' => Process::result(output: ''), + '* network inspect *' => Process::result(output: "172.18.0.0/16\n"), + '*' => Process::result(), + ]); + + ManageDevelopmentQemuVm::run(['ubuntu-root', 'ubuntu-non-root']); + + expect(Server::query()->whereIn('uuid', [ + 'development-qemu-ubuntu-root', + 'development-qemu-ubuntu-non-root', + ])->count())->toBe(2); + 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')); +}); diff --git a/tests/Feature/ServerValidationDialogTest.php b/tests/Feature/ServerValidationDialogTest.php index a22056cac..e1c75e14a 100644 --- a/tests/Feature/ServerValidationDialogTest.php +++ b/tests/Feature/ServerValidationDialogTest.php @@ -4,12 +4,29 @@ $view = file_get_contents(resource_path('views/livewire/server/show.blade.php')); expect($view) - ->toContain('') + ->toContain('') ->toContain(':isHighlighted="! $server->isFunctional()"') ->toContain('@click="processDialogOpen = true" wire:click.prevent="validateServer"') ->not->toContain('toContain('') + ->and($dialog) + ->toContain("'mobileFullscreen' => false") + ->toContain("'process-dialog-mobile-fullscreen' => \$mobileFullscreen") + ->and($styles) + ->toContain('@media (max-width: 639px)') + ->toContain('.process-dialog-mobile-fullscreen') + ->toContain('height: 100dvh !important') + ->toContain('box-shadow: inset 0 0 0 1px var(--coollabs-hairline) !important'); +}); + test('completed server validation shows a close action instead of empty logs', function () { $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); @@ -25,11 +42,17 @@ test('installation logs are only shown after an installation starts', function () { $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); $component = file_get_contents(app_path('Livewire/Server/ValidateAndInstall.php')); + $styles = file_get_contents(resource_path('css/app.css')); - expect($view)->toContain('@elseif ($isInstalling)') + expect($view) + ->toContain('@elseif ($isInstalling)') + ->toContain('application-settings-section validation-installation-logs') ->and($component) ->toContain('public bool $isInstalling = false;') - ->toContain('$this->isInstalling = true;'); + ->toContain('$this->isInstalling = true;') + ->and($styles) + ->toContain('.validation-installation-logs') + ->toContain('border: 1px solid var(--coollabs-fill)'); }); test('server validation content scrolls within the dialog', function () { @@ -43,10 +66,22 @@ test('validation checkpoints use the standard bordered list treatment', function () { $view = file_get_contents(resource_path('views/livewire/server/validate-and-install.blade.php')); + $styles = file_get_contents(resource_path('css/app.css')); expect($view) ->toContain('data-validation-checkpoints') - ->toContain('overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]'); + ->toContain('shrink-0 overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]') + ->toContain('checkpoint-scroll-fade') + ->toContain('snap-x snap-mandatory overflow-x-auto overscroll-x-contain scroll-smooth scrollbar') + ->toContain('data-checkpoint-status="{{ $checkpoint[\'status\'] }}"') + ->toContain('basis-[88%] shrink-0 snap-start sm:basis-72 lg:basis-80') + ->toContain("querySelector('[data-checkpoint-status=running]')") + ->toContain("scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })") + ->toContain('new MutationObserver') + ->toContain("attributeFilter: ['data-checkpoint-status']") + ->toContain('x-destroy="observer?.disconnect()"') + ->and($styles) + ->toContain('.checkpoint-scroll-fade::after'); }); test('all validation checkpoints remain visible while only the current phase runs', function () {