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 () {