Merge remote-tracking branch 'origin/main' into next

This commit is contained in:
github-actions[bot]
2026-08-18 20:42:25 +00:00
13 changed files with 962 additions and 13 deletions
@@ -0,0 +1,122 @@
<?php
namespace App\Actions\Development;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
class ConfigureDevelopmentQemuHost
{
use AsAction;
public function handle(): void
{
$this->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 <<<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">
<dhcp>
<range start="192.168.122.2" end="192.168.122.254"/>
</dhcp>
</ip>
</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.');
}
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Actions\Development;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\Process;
use Lorisleiva\Actions\Concerns\AsAction;
class ManageDevelopmentQemuVm
{
use AsAction;
/** @param string|array<int, string> $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;
}
}
}
}
}
@@ -0,0 +1,60 @@
<?php
namespace App\Actions\Development;
use App\Models\PrivateKey;
use App\Models\Server;
use InvalidArgumentException;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
class SeedDevelopmentQemuServer
{
use AsAction;
public function handle(string $profileName, bool $removeOtherServers = true): Server
{
$this->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.');
}
}
}
@@ -0,0 +1,219 @@
<?php
namespace App\Actions\Development;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
use InvalidArgumentException;
use Lorisleiva\Actions\Concerns\AsAction;
use RuntimeException;
class StartDevelopmentQemuVm
{
use AsAction;
public function handle(string $profileName, bool $resetManagedVms = true): void
{
$this->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 <<<YAML
#cloud-config
disable_root: false
users:
- name: {$profile['user']}
{$sudo} shell: /bin/bash
lock_passwd: true
ssh_authorized_keys:
- {$publicKey}
package_update: true
packages:
{$packages}
runcmd:
- {$startDocker}{$addUserToDockerGroup}
YAML;
}
/** @param array{mac: string} $profile */
private function networkConfig(array $profile): string
{
return <<<YAML
version: 2
ethernets:
default:
match:
macaddress: "{$profile['mac']}"
dhcp4: true
YAML;
}
/** @param array{domain: string, ip: string, mac: string} $profile */
private function configureDhcpReservation(array $profile): void
{
$network = escapeshellarg(config('development-qemu.libvirt_network'));
$networkXml = Process::run("virsh net-dumpxml {$network}");
if ($networkXml->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("<host mac='%s' name='%s' ip='%s'/>", $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.');
}
}
}
@@ -0,0 +1,40 @@
<?php
namespace App\Console\Commands;
use App\Actions\Development\ManageDevelopmentQemuVm;
use Illuminate\Console\Command;
use function Laravel\Prompts\multiselect;
class ManageDevelopmentQemuVmCommand extends Command
{
protected $signature = 'dev:qemu {profiles?* : Profile keys from config/development-qemu.php}';
protected $description = 'Recreate selected development QEMU VMs and seed their Coolify servers';
public function handle(): int
{
if (! isDev()) {
$this->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;
}
}
@@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands;
use App\Actions\Development\SeedDevelopmentQemuServer;
use Illuminate\Console\Command;
class SeedDevelopmentQemuServerCommand extends Command
{
protected $signature = 'dev:qemu:seed {profile : Profile key from config/development-qemu.php} {--keep-others}';
protected $description = 'Seed one development QEMU server in the Coolify database';
public function handle(): int
{
if (! isDev()) {
$this->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;
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
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',
'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',
],
],
];
+30
View File
@@ -2121,6 +2121,36 @@ input[type="search"]::-webkit-search-results-decoration {
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;
@@ -1,5 +1,6 @@
@props([
'closeWithX' => false,
'mobileFullscreen' => false,
'open' => false,
'size' => 'lg',
])
@@ -40,7 +41,11 @@
<div class="fixed inset-0 overflow-y-auto">
<div @if (! $closeWithX) @click.self="processDialogOpen = false" @endif
class="flex min-h-full items-center justify-center p-4 sm:p-6">
@class([
'flex min-h-full items-center justify-center',
'p-4 sm:p-6' => ! $mobileFullscreen,
'p-0 sm:p-6' => $mobileFullscreen,
])>
<div x-show="processDialogOpen"
x-trap.inert.noscroll="processDialogOpen"
x-transition:enter="ease-out duration-150"
@@ -54,6 +59,7 @@
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)]',
@@ -184,7 +184,7 @@
</div>
@endif
<x-process-dialog closeWithX size="xl" :open="$isValidating">
<x-process-dialog closeWithX mobileFullscreen size="xl" :open="$isValidating">
<x-slot:title>Validate and configure</x-slot:title>
<x-slot:content>
<livewire:server.validate-and-install :server="$server"
@@ -84,15 +84,26 @@
</x-forms.button>
@else
<div data-validation-checkpoints
class="overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]">
class="shrink-0 overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]">
<div class="border-b border-neutral-200 px-4 py-2.5 dark:border-white/[0.08]">
<h3 class="text-[13px] font-medium text-neutral-600 dark:text-fg-dim">Validation checkpoints</h3>
</div>
<div class="divide-y divide-neutral-200 dark:divide-white/[0.07]">
@foreach ($checkpoints as $checkpoint)
<x-checkpoint-item :title="$checkpoint['title']" :description="$checkpoint['description']"
:status="$checkpoint['status']" />
@endforeach
<div class="checkpoint-scroll-fade relative min-w-0" x-data="{
observer: null,
scrollToRunning() {
this.$refs.track.querySelector('[data-checkpoint-status=running]')?.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
}
}"
x-init="$nextTick(() => scrollToRunning()); observer = new MutationObserver(() => $nextTick(() => scrollToRunning())); observer.observe($refs.track, { subtree: true, childList: true, attributes: true, attributeFilter: ['data-checkpoint-status'] })"
x-destroy="observer?.disconnect()">
<div class="flex min-w-0 snap-x snap-mandatory overflow-x-auto overscroll-x-contain scroll-smooth scrollbar divide-x divide-neutral-200 dark:divide-white/[0.07]"
x-ref="track">
@foreach ($checkpoints as $checkpoint)
<x-checkpoint-item :title="$checkpoint['title']" :description="$checkpoint['description']"
:status="$checkpoint['status']" data-checkpoint-status="{{ $checkpoint['status'] }}"
class="basis-[88%] shrink-0 snap-start sm:basis-72 lg:basis-80" />
@endforeach
</div>
</div>
</div>
@@ -107,7 +118,7 @@
</x-forms.button>
</div>
@elseif ($isInstalling)
<section class="application-settings-section">
<section class="application-settings-section validation-installation-logs">
<div class="application-settings-section-body">
<livewire:activity-monitor :header="$installationStep.' installation logs'" :showWaiting="false" />
</div>
+242
View File
@@ -0,0 +1,242 @@
<?php
use App\Actions\Development\ConfigureDevelopmentQemuHost;
use App\Actions\Development\ManageDevelopmentQemuVm;
use App\Actions\Development\SeedDevelopmentQemuServer;
use App\Actions\Development\StartDevelopmentQemuVm;
use App\Console\Commands\ManageDevelopmentQemuVmCommand;
use App\Console\Commands\SeedDevelopmentQemuServerCommand;
use App\Models\Server;
use Database\Seeders\PrivateKeySeeder;
use Database\Seeders\TeamSeeder;
use Database\Seeders\UserSeeder;
use Illuminate\Console\Command;
use Illuminate\Database\QueryException;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Process;
uses(RefreshDatabase::class);
beforeEach(function () {
config(['app.env' => '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></network>'),
'* 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: '<network></network>');
}
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></network>'),
'* 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></network>'),
'* 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'));
});
+39 -4
View File
@@ -4,12 +4,29 @@ test('server revalidation opens in the centered process dialog', function () {
$view = file_get_contents(resource_path('views/livewire/server/show.blade.php'));
expect($view)
->toContain('<x-process-dialog closeWithX size="xl" :open="$isValidating">')
->toContain('<x-process-dialog closeWithX mobileFullscreen size="xl" :open="$isValidating">')
->toContain(':isHighlighted="! $server->isFunctional()"')
->toContain('@click="processDialogOpen = true" wire:click.prevent="validateServer"')
->not->toContain('<x-slide-over');
});
test('server revalidation uses the full mobile viewport', function () {
$view = file_get_contents(resource_path('views/livewire/server/show.blade.php'));
$dialog = file_get_contents(resource_path('views/components/process-dialog.blade.php'));
$styles = file_get_contents(resource_path('css/app.css'));
expect($view)
->toContain('<x-process-dialog closeWithX mobileFullscreen size="xl" :open="$isValidating">')
->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('completed server validation shows a close action instead of empty logs', f
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('server validation content scrolls within the dialog', function () {
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 () {