Files
coolify/tests/Feature/DevelopmentQemuVmTest.php

838 lines
43 KiB
PHP

<?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 App\Support\ValidationPatterns;
use Database\Seeders\PrivateKeySeeder;
use Database\Seeders\ServerSeeder;
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;
use Illuminate\Support\Facades\Validator;
use Symfony\Component\Yaml\Yaml;
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('requires one profile when a qemu vm is selected as localhost', function () {
Process::fake();
expect(Artisan::call('dev:qemu', [
'profiles' => ['ubuntu-root', 'debian-root'],
'--as-localhost' => true,
]))->toBe(Command::FAILURE);
Process::assertNothingRan();
});
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(remoteProcessScript($process), 'command -v')) {
return Process::result();
}
if (str_contains(remoteProcessScript($process), 'net-info')) {
return Process::result(exitCode: 1);
}
if (str_contains(remoteProcessScript($process), 'network inspect')) {
return Process::result(output: "172.18.0.0/16\n");
}
if (str_contains(remoteProcessScript($process), 'iptables -C')) {
return Process::result(exitCode: 1);
}
return Process::result();
});
ConfigureDevelopmentQemuHost::run();
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'command -v') && str_contains(remoteProcessScript($process), 'xorriso'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'systemctl enable --now libvirtd'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh net-define'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh net-start'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh net-autostart'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'sysctl -w net.ipv4.ip_forward=1'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'iptables -I LIBVIRT_FWI'));
});
it('does not restart an active libvirt network', function () {
Process::fake(function ($process) {
if (str_contains(remoteProcessScript($process), 'net-info')) {
return Process::result(output: "Name: default\nActive: yes\n");
}
if (str_contains(remoteProcessScript($process), 'network inspect')) {
return Process::result(output: "172.18.0.0/16\n");
}
return Process::result();
});
ConfigureDevelopmentQemuHost::run();
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh net-start'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'iptables -D LIBVIRT_FWI'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), '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->description)->toBe('Development QEMU virtual machine')
->and(Validator::make(['description' => $server->description], ['description' => ValidationPatterns::descriptionRules()])->passes())->toBeTrue()
->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('seeds a new qemu server with the caddy proxy', function () {
$server = SeedDevelopmentQemuServer::run('debian-root');
expect($server->proxyType())->toBe('CADDY')
->and($server->proxy->get('status'))->toBe('exited');
});
it('keeps the proxy of an existing server when it seeds a qemu vm for it', function () {
$this->seed(ServerSeeder::class);
$server = SeedDevelopmentQemuServer::run('ubuntu-root', true, true);
expect($server->proxyType())->toBe('TRAEFIK');
});
it('uses a qemu vm as the localhost sentinel without changing its identity', function () {
$this->seed(ServerSeeder::class);
$server = SeedDevelopmentQemuServer::run('ubuntu-root', true, true);
expect($server->id)->toBe(0)
->and($server->uuid)->toBe('localhost')
->and($server->name)->toBe('localhost')
->and($server->description)->toBe('Development QEMU virtual machine')
->and($server->ip)->toBe('192.168.122.10')
->and($server->user)->toBe('root')
->and($server->private_key_id)->toBe(1)
->and(Server::query()->count())->toBe(1);
});
it('can change the localhost qemu profile without adding a server', function () {
$this->seed(ServerSeeder::class);
SeedDevelopmentQemuServer::run('ubuntu-root', true, true);
SeedDevelopmentQemuServer::run('ubuntu-non-root', true, true);
expect(Server::query()->findOrFail(0)->ip)->toBe('192.168.122.11')
->and(Server::query()->findOrFail(0)->user)->toBe('coolify')
->and(Server::query()->count())->toBe(1);
});
it('seeds localhost through the container command', function () {
$this->seed(ServerSeeder::class);
Process::fake();
expect(Artisan::call('dev:qemu:seed', [
'profile' => 'ubuntu-root',
'--as-localhost' => true,
]))->toBe(Command::SUCCESS)
->and(Server::query()->findOrFail(0)->ip)->toBe('192.168.122.10');
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker run -d --name coolify-db')
&& str_contains(remoteProcessScript($process), 'POSTGRES_DB='));
});
it('starts the instance database container with sudo on a non-root localhost vm', function () {
$this->seed(ServerSeeder::class);
Process::fake();
Artisan::call('dev:qemu:seed', ['profile' => 'ubuntu-non-root', '--as-localhost' => true]);
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'if ! sudo docker inspect coolify-db')
&& str_contains(remoteProcessScript($process), 'sudo docker run -d --name coolify-db')
&& str_contains(remoteProcessScript($process), 'sudo docker start coolify-db'));
});
it('still seeds localhost when the instance database container cannot start', function () {
$this->seed(ServerSeeder::class);
Process::fake(fn () => Process::result(errorOutput: 'Cannot connect to the Docker daemon', exitCode: 1));
expect(Artisan::call('dev:qemu:seed', ['profile' => 'ubuntu-root', '--as-localhost' => true]))->toBe(Command::SUCCESS)
->and(Artisan::output())->toContain('Could not start the coolify-db container')
->and(Server::query()->findOrFail(0)->ip)->toBe('192.168.122.10');
});
it('seeds localhost with the forwarded ssh address of a lima vm', function () {
$this->seed(ServerSeeder::class);
Process::fake();
expect(Artisan::call('dev:qemu:seed', [
'profile' => 'debian-non-root',
'--as-localhost' => true,
'--ip' => 'host.docker.internal',
'--port' => '53022',
]))->toBe(Command::SUCCESS);
$server = Server::query()->findOrFail(0);
expect($server->ip)->toBe('host.docker.internal')
->and($server->port)->toBe(53022)
->and($server->user)->toBe('coolify')
->and(Server::query()->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('reuses an existing vm without touching other managed vms', function () {
$storagePath = sys_get_temp_dir().'/coolify-qemu-reuse-test-'.uniqid();
config(['development-qemu.storage_path' => $storagePath, 'development-qemu.memory' => 1024]);
File::ensureDirectoryExists($storagePath);
File::put("{$storagePath}/coolify-dev-ubuntu-root.qcow2", 'other vm data');
File::put("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2", 'vm data');
$ran = [];
Process::fake(function ($process) use (&$ran) {
$ran[] = remoteProcessScript($process);
return match (true) {
str_contains(remoteProcessScript($process), ' net-dumpxml ') => Process::result(output: '<network></network>'),
str_contains(remoteProcessScript($process), ' network inspect ') => Process::result(output: "172.18.0.0/16\n"),
str_contains(remoteProcessScript($process), ' dominfo ') => Process::result(output: 'exists'),
str_contains(remoteProcessScript($process), ' domstate ') => Process::result(output: "shut off\n"),
default => Process::result(),
};
});
StartDevelopmentQemuVm::run('ubuntu-non-root');
$commands = collect($ran);
$resize = $commands->search("virsh setmaxmem 'coolify-dev-ubuntu-non-root' 1024M --config");
expect($resize)->not->toBeFalse()
->and($commands->search("virsh setmem 'coolify-dev-ubuntu-non-root' 1024M --config"))->toBeGreaterThan($resize)
->and($commands->search("virsh start 'coolify-dev-ubuntu-non-root'"))->toBeGreaterThan($resize);
Process::assertNotRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-install '));
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh destroy'));
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh undefine'));
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'coolify-dev-ubuntu-root'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'iptables -I LIBVIRT_FWI'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker exec') && str_contains(remoteProcessScript($process), 'coolify'));
expect(File::get("{$storagePath}/coolify-dev-ubuntu-root.qcow2"))->toBe('other vm data')
->and(File::get("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2"))->toBe('vm data');
});
it('does not restart an already running vm', function () {
config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-running-test-'.uniqid()]);
Process::fake([
'* net-dumpxml *' => Process::result(output: '<network></network>'),
'* network inspect *' => Process::result(output: "172.18.0.0/16\n"),
'* dominfo *' => Process::result(output: 'exists'),
'* domstate *' => Process::result(output: "running\n"),
'*' => Process::result(),
]);
StartDevelopmentQemuVm::run('ubuntu-root');
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh start'));
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh setmem'));
Process::assertNotRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-install '));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'docker exec') && str_contains(remoteProcessScript($process), 'fsockopen'));
});
it('freshly recreates only the selected vm', function () {
$storagePath = sys_get_temp_dir().'/coolify-qemu-fresh-test-'.uniqid();
config(['development-qemu.storage_path' => $storagePath]);
File::ensureDirectoryExists($storagePath);
File::put("{$storagePath}/coolify-dev-ubuntu-root.qcow2", 'other vm data');
File::put("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2", 'old data');
File::put("{$storagePath}/coolify-dev-ubuntu-non-root-prepared.qcow2", 'prepared image');
Process::fake([
'* net-dumpxml *' => Process::result(output: '<network></network>'),
'* network inspect *' => Process::result(output: "172.18.0.0/16\n"),
'* dominfo *' => Process::result(output: 'exists'),
'*' => Process::result(),
]);
StartDevelopmentQemuVm::run('ubuntu-non-root', true);
Process::assertRan(fn ($process) => remoteProcessScript($process) === "virsh destroy 'coolify-dev-ubuntu-non-root'");
Process::assertRan(fn ($process) => remoteProcessScript($process) === "virsh undefine 'coolify-dev-ubuntu-non-root'");
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'coolify-dev-ubuntu-root'));
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh start'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virt-install') && str_contains(remoteProcessScript($process), 'coolify-dev-ubuntu-non-root'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'net-update') && str_contains(remoteProcessScript($process), 'ip-dhcp-host') && str_contains(remoteProcessScript($process), '192.168.122.11'));
expect(File::get("{$storagePath}/coolify-dev-ubuntu-root.qcow2"))->toBe('other vm data')
->and(File::exists("{$storagePath}/coolify-dev-ubuntu-non-root.qcow2"))->toBeFalse()
->and(File::exists("{$storagePath}/coolify-dev-ubuntu-non-root-prepared.qcow2"))->toBeTrue();
});
it('passes the fresh option from the command to the selected vms only', function () {
config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-fresh-command-test-'.uniqid()]);
Process::fake([
'* net-dumpxml *' => Process::result(output: '<network></network>'),
'* network inspect *' => Process::result(output: "172.18.0.0/16\n"),
'* dominfo *' => Process::result(output: 'exists'),
'*' => Process::result(),
]);
expect(Artisan::call('dev:qemu', ['profiles' => ['debian-root'], '--fresh' => true]))->toBe(Command::SUCCESS);
Process::assertRan(fn ($process) => remoteProcessScript($process) === "virsh undefine 'coolify-dev-debian-root'");
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virt-install') && str_contains(remoteProcessScript($process), 'coolify-dev-debian-root'));
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh destroy') && ! str_contains(remoteProcessScript($process), 'coolify-dev-debian-root'));
});
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(remoteProcessScript($process), 'net-dumpxml')) {
return Process::result(output: '<network></network>');
}
if (str_contains(remoteProcessScript($process), 'network inspect')) {
return Process::result(output: "172.18.0.0/16\n");
}
if (str_contains(remoteProcessScript($process), 'virsh dominfo')) {
return Process::result(exitCode: 1);
}
if (str_contains(remoteProcessScript($process), 'iptables -C')) {
return Process::result(exitCode: 1);
}
return Process::result();
});
StartDevelopmentQemuVm::run('ubuntu-root');
expect(DB::getQueryLog())->toBeEmpty();
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virt-install'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'iptables -I LIBVIRT_FWI'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh domstate') && str_contains(remoteProcessScript($process), 'shut off'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'mv ') && str_contains(remoteProcessScript($process), 'prepared.qcow2'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'qemu-img create') && str_contains(remoteProcessScript($process), 'prepared.qcow2'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'xorriso -as mkisofs -V cidata -graft-points'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virt-install') && str_contains(remoteProcessScript($process), 'bus=virtio,readonly=on'));
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'mac_in_use'));
$userData = File::get(config('development-qemu.storage_path').'/coolify-dev-ubuntu-root-user-data.yaml');
expect($userData)->toContain('docker compose version', 'docker buildx version', 'docker info', 'cloud-init.disabled');
$cloudInit = Yaml::parse($userData);
expect($cloudInit['runcmd'][0])->toContain('https://get.docker.com', 'systemctl enable --now docker', 'poweroff');
});
it('reuses a prepared vm image without reinstalling docker', function () {
$storagePath = sys_get_temp_dir().'/coolify-qemu-prepared-test-'.uniqid();
config(['development-qemu.storage_path' => $storagePath]);
File::ensureDirectoryExists($storagePath);
File::put("{$storagePath}/coolify-dev-ubuntu-root-prepared.qcow2", 'prepared image');
Process::fake([
'* net-dumpxml *' => Process::result(output: '<network></network>'),
'* network inspect *' => Process::result(output: "172.18.0.0/16\n"),
'* dominfo *' => Process::result(exitCode: 1),
'*' => Process::result(),
]);
StartDevelopmentQemuVm::run('ubuntu-root');
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'curl --fail --location'));
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh domstate'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'qemu-img create') && str_contains(remoteProcessScript($process), 'coolify-dev-ubuntu-root-prepared.qcow2'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virt-install') && ! str_contains(remoteProcessScript($process), '--cloud-init'));
expect(File::exists("{$storagePath}/coolify-dev-ubuntu-root-prepared.qcow2"))->toBeTrue();
});
it('prepares alpine with the compose and buildx packages for a non-root user', function () {
$storagePath = sys_get_temp_dir().'/coolify-qemu-alpine-test-'.uniqid();
config(['development-qemu.storage_path' => $storagePath]);
Process::fake([
'* net-dumpxml *' => Process::result(output: '<network></network>'),
'* network inspect *' => Process::result(output: "172.18.0.0/16\n"),
'* dominfo *' => Process::result(exitCode: 1),
'*' => Process::result(),
]);
StartDevelopmentQemuVm::run('alpine-non-root');
$cloudInit = Yaml::parse(File::get("{$storagePath}/coolify-dev-alpine-non-root-user-data.yaml"));
expect($cloudInit['packages'])->toContain('docker', 'docker-cli-compose', 'docker-cli-buildx')
->and($cloudInit['users'][0]['shell'])->toBe('/bin/ash')
->and($cloudInit['users'][0]['lock_passwd'])->toBeFalse()
->and($cloudInit['runcmd'][0])->toContain('service cgroups start', 'addgroup coolify docker', 'until docker info', 'docker compose version', 'docker buildx version');
});
it('does not cache a vm when preparation does not finish', function () {
config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-failed-preparation-'.uniqid()]);
Process::fake(function ($process) {
if (str_contains(remoteProcessScript($process), 'net-dumpxml')) {
return Process::result(output: '<network></network>');
}
if (str_contains(remoteProcessScript($process), 'network inspect')) {
return Process::result(output: "172.18.0.0/16\n");
}
if (str_contains(remoteProcessScript($process), 'virsh dominfo')) {
return Process::result(exitCode: 1);
}
return str_contains(remoteProcessScript($process), 'timeout 900 bash')
? Process::result(exitCode: 124)
: Process::result();
});
expect(fn () => StartDevelopmentQemuVm::run('ubuntu-root'))->toThrow(RuntimeException::class);
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'mv ') && str_contains(remoteProcessScript($process), 'prepared.qcow2'));
Process::assertNotRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-install ') && ! str_contains(remoteProcessScript($process), 'readonly=on'));
});
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(remoteProcessScript($process), "docker exec 'coolify' php artisan dev:qemu:seed") && str_contains(remoteProcessScript($process), 'ubuntu-root'));
});
it('passes localhost mode to the container when the host database is unavailable', function () {
config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-localhost-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"),
'*' => Process::result(),
]);
ManageDevelopmentQemuVm::run('ubuntu-root', true);
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'dev:qemu:seed') && str_contains(remoteProcessScript($process), '--as-localhost'));
});
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"),
'* dominfo *' => Process::result(exitCode: 1),
'*' => 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(remoteProcessScript($process), 'virt-install') && str_contains(remoteProcessScript($process), 'coolify-dev-ubuntu-root'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'virt-install') && str_contains(remoteProcessScript($process), 'coolify-dev-ubuntu-non-root'));
});
it('seeds through the instance coolify container when the host database is unavailable', function () {
config([
'development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-instance-fallback-test-'.uniqid(),
'development-qemu.coolify_container' => 'coolify-fix-deploy-env',
]);
SeedDevelopmentQemuServer::mock()
->shouldReceive('handle')
->once()
->andThrow(new QueryException('pgsql', 'select 1', [], new Exception('unavailable')));
Process::fake([
'* net-dumpxml *' => Process::result(output: '<network></network>'),
'* network inspect *' => Process::result(output: "172.18.0.0/16\n"),
'*' => Process::result(),
]);
ManageDevelopmentQemuVm::run('ubuntu-root');
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker exec 'coolify-fix-deploy-env' php artisan dev:qemu:seed"));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker exec 'coolify-fix-deploy-env' php -r"));
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), "docker exec 'coolify' "));
});
it('resolves legacy values when no dev instance is configured', function () {
$config = developmentQemuConfigFor([]);
expect($config['instance'])->toBeNull()
->and($config['libvirt_network'])->toBe('default')
->and($config['bridge'])->toBe('virbr0')
->and($config['gateway'])->toBe('192.168.122.1')
->and($config['subnet'])->toBe('192.168.122.0/24')
->and($config['docker_network'])->toBe('coolify')
->and($config['coolify_container'])->toBe('coolify')
->and($config['profiles']['ubuntu-root']['domain'])->toBe('coolify-dev-ubuntu-root')
->and($config['profiles']['ubuntu-root']['template'])->toBe('coolify-dev-ubuntu-root')
->and($config['profiles']['ubuntu-root']['ip'])->toBe('192.168.122.10')
->and($config['profiles']['alpine-non-root']['ip'])->toBe('192.168.122.41');
});
it('resolves isolated per-instance values from the environment', function () {
$legacy = developmentQemuConfigFor([]);
$config = developmentQemuConfigFor([
'DEVELOPMENT_QEMU_INSTANCE' => 'fix-deploy-env',
'DEVELOPMENT_QEMU_SLOT' => '7',
'DEVELOPMENT_QEMU_DOCKER_NETWORK' => 'coolify-fix-deploy-env',
'DEVELOPMENT_QEMU_COOLIFY_CONTAINER' => 'coolify-fix-deploy-env',
]);
$profiles = collect($config['profiles']);
expect($config['instance'])->toBe('fix-deploy-env')
->and($config['slot'])->toBe(7)
->and($config['libvirt_network'])->toBe('coolify-dev-7')
->and($config['bridge'])->toBe('cdevbr7')
->and($config['gateway'])->toBe('10.221.7.1')
->and($config['subnet'])->toBe('10.221.7.0/24')
->and($config['prefix'])->toBe(24)
->and($config['docker_network'])->toBe('coolify-fix-deploy-env')
->and($config['coolify_container'])->toBe('coolify-fix-deploy-env')
->and($config['profiles']['ubuntu-root']['domain'])->toBe('coolify-dev-fix-deploy-env--ubuntu-root')
->and($config['profiles']['ubuntu-root']['template'])->toBe('coolify-dev-ubuntu-root')
->and($config['profiles']['ubuntu-root']['ip'])->toBe('10.221.7.10')
->and($config['profiles']['alpine-non-root']['ip'])->toBe('10.221.7.41')
->and($profiles->every(fn (array $profile, string $key) => $profile['domain'] === "coolify-dev-fix-deploy-env--{$key}"))->toBeTrue()
->and($profiles->pluck('ip')->unique()->count())->toBe(8)
->and($profiles->pluck('mac')->all())->toBe(collect($legacy['profiles'])->pluck('mac')->all())
->and($profiles->pluck('uuid')->all())->toBe(collect($legacy['profiles'])->pluck('uuid')->all());
});
it('configures the instance libvirt network and forwarding rule', function () {
$storagePath = sys_get_temp_dir().'/coolify-qemu-instance-network-test-'.uniqid();
useDevelopmentQemuInstance('main', 3, $storagePath);
Process::fake([
'* net-info *' => Process::result(exitCode: 1),
'* network inspect *' => Process::result(output: "172.30.0.0/16\n"),
'*' => Process::result(),
]);
ConfigureDevelopmentQemuHost::run();
$networkXml = File::get("{$storagePath}/libvirt-network-coolify-dev-3.xml");
expect($networkXml)->toContain(
'<name>coolify-dev-3</name>',
'<bridge name="cdevbr3" stp="on" delay="0"/>',
'<ip address="10.221.3.1" netmask="255.255.255.0">',
'<range start="10.221.3.2" end="10.221.3.254"/>',
)->not->toContain('virbr0', '192.168.122');
Process::assertRan(fn ($process) => remoteProcessScript($process) === "virsh net-info 'coolify-dev-3'");
Process::assertRan(fn ($process) => remoteProcessScript($process) === "virsh net-start 'coolify-dev-3'");
Process::assertRan(fn ($process) => remoteProcessScript($process) === "virsh net-autostart 'coolify-dev-3'");
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker network inspect 'coolify-main'"));
Process::assertRan(fn ($process) => remoteProcessScript($process) === "iptables -I LIBVIRT_FWI 1 -s '172.30.0.0/16' -d '10.221.3.0/24' -o 'cdevbr3' -j ACCEPT");
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virbr0'));
});
it('keeps the legacy libvirt network definition unchanged', function () {
$storagePath = sys_get_temp_dir().'/coolify-qemu-legacy-network-test-'.uniqid();
config(['development-qemu.storage_path' => $storagePath]);
Process::fake([
'* net-info *' => Process::result(exitCode: 1),
'* network inspect *' => Process::result(output: "172.18.0.0/16\n"),
'*' => Process::result(),
]);
ConfigureDevelopmentQemuHost::run();
expect(File::get("{$storagePath}/libvirt-network-default.xml"))->toContain(
'<name>default</name>',
'<bridge name="virbr0" stp="on" delay="0"/>',
'<ip address="192.168.122.1" netmask="255.255.255.0">',
'<range start="192.168.122.2" end="192.168.122.254"/>',
);
Process::assertRan(fn ($process) => remoteProcessScript($process) === "iptables -I LIBVIRT_FWI 1 -s '172.18.0.0/16' -d '192.168.122.0/24' -o 'virbr0' -j ACCEPT");
});
it('creates instance vms with instance domains, ips, and shared prepared images', function () {
$storagePath = sys_get_temp_dir().'/coolify-qemu-instance-vm-test-'.uniqid();
useDevelopmentQemuInstance('main', 3, $storagePath);
File::ensureDirectoryExists($storagePath);
File::put("{$storagePath}/coolify-dev-ubuntu-root-prepared.qcow2", 'prepared image');
Process::fake([
'* net-dumpxml *' => Process::result(output: '<network></network>'),
'* network inspect *' => Process::result(output: "172.30.0.0/16\n"),
'* dominfo *' => Process::result(exitCode: 1),
'*' => Process::result(),
]);
StartDevelopmentQemuVm::run('ubuntu-root');
Process::assertRan(fn ($process) => remoteProcessScript($process) === "virsh dominfo 'coolify-dev-main--ubuntu-root'");
Process::assertRan(fn ($process) => str_starts_with(remoteProcessScript($process), "virsh net-update 'coolify-dev-3' add-last ip-dhcp-host") && str_contains(remoteProcessScript($process), '52:54:00:ca:00:01') && str_contains(remoteProcessScript($process), '10.221.3.10'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), 'qemu-img create')
&& str_contains(remoteProcessScript($process), "'{$storagePath}/coolify-dev-ubuntu-root-prepared.qcow2'")
&& str_contains(remoteProcessScript($process), "'{$storagePath}/coolify-dev-main--ubuntu-root.qcow2'"));
Process::assertRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-install ')
&& str_contains(remoteProcessScript($process), "--name 'coolify-dev-main--ubuntu-root'")
&& str_contains(remoteProcessScript($process), "--network network='coolify-dev-3',model=virtio,mac='52:54:00:ca:00:01'")
&& str_ends_with(remoteProcessScript($process), '--check mac_in_use=off'));
Process::assertRan(fn ($process) => str_contains(remoteProcessScript($process), "docker exec 'coolify-main' php -r") && str_contains(remoteProcessScript($process), "'10.221.3.10'"));
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'curl --fail --location'));
});
it('rejects invalid instance names and slots', function (string $instance, int $slot) {
useDevelopmentQemuInstance($instance, $slot, sys_get_temp_dir().'/coolify-qemu-invalid-instance-'.uniqid());
Process::fake();
expect(fn () => ConfigureDevelopmentQemuHost::run())->toThrow(RuntimeException::class, 'DEVELOPMENT_QEMU_');
Process::assertNothingRan();
})->with([
'repeated dashes' => ['fix--deploy', 1],
'trailing dash' => ['main-', 1],
'uppercase' => ['Main', 1],
'slot zero' => ['main', 0],
'slot too large' => ['main', 255],
]);
/**
* Evaluate config/development-qemu.php with the given environment, restoring the environment afterwards.
*
* @param array<string, string> $environment
* @return array<string, mixed>
*/
function developmentQemuConfigFor(array $environment): array
{
$keys = ['DEVELOPMENT_QEMU_INSTANCE', 'DEVELOPMENT_QEMU_SLOT', 'DEVELOPMENT_QEMU_DOCKER_NETWORK', 'DEVELOPMENT_QEMU_COOLIFY_CONTAINER'];
$original = collect($keys)->mapWithKeys(fn (string $key) => [$key => getenv($key)])->all();
foreach ($keys as $key) {
isset($environment[$key]) ? putenv("{$key}={$environment[$key]}") : putenv($key);
}
try {
return require config_path('development-qemu.php');
} finally {
foreach ($original as $key => $value) {
$value === false ? putenv($key) : putenv("{$key}={$value}");
}
}
}
function useDevelopmentQemuInstance(string $instance, int $slot, string $storagePath): void
{
config(['development-qemu' => [
...developmentQemuConfigFor([
'DEVELOPMENT_QEMU_INSTANCE' => $instance,
'DEVELOPMENT_QEMU_SLOT' => (string) $slot,
'DEVELOPMENT_QEMU_DOCKER_NETWORK' => "coolify-{$instance}",
'DEVELOPMENT_QEMU_COOLIFY_CONTAINER' => "coolify-{$instance}",
]),
'storage_path' => $storagePath,
]]);
}
function fakeQemuHostWithGpu(string $domainState = 'running', string $domainXml = '<domain></domain>'): void
{
Process::fake([
'* net-dumpxml *' => Process::result(output: '<network></network>'),
'* network inspect *' => Process::result(output: "172.18.0.0/16\n"),
'* dominfo *' => Process::result(output: 'exists'),
'* domstate *' => Process::result(output: "{$domainState}\n"),
'* dumpxml *' => Process::result(output: $domainXml),
'lspci *' => Process::result(output: implode("\n", [
'0000:01:00.0 "0300" "10de" "2584" -ra1 -p00 "1043" "8976"',
'0000:01:00.1 "0403" "10de" "2291" -ra1 -p00 "1043" "8976"',
])."\n"),
'*' => Process::result(),
]);
}
it('creates a vm with every function of the host nvidia gpu and more memory', function () {
$storagePath = sys_get_temp_dir().'/coolify-qemu-gpu-create-test-'.uniqid();
config(['development-qemu.storage_path' => $storagePath, 'development-qemu.memory' => 1024, 'development-qemu.gpu_memory' => 4096]);
File::ensureDirectoryExists($storagePath);
File::put("{$storagePath}/coolify-dev-ubuntu-root-prepared.qcow2", 'prepared image');
fakeQemuHostWithGpu();
StartDevelopmentQemuVm::run('ubuntu-root', true, true);
Process::assertRan(fn ($process) => remoteProcessScript($process) === 'systemctl stop nvidia-persistenced');
Process::assertRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-install ')
&& str_contains(remoteProcessScript($process), '--memory 4096 ')
&& str_contains(remoteProcessScript($process), "--hostdev 'pci_0000_01_00_0' --hostdev 'pci_0000_01_00_1'"));
});
it('stops an existing vm to attach the host gpu and starts it with the gpu memory', function () {
config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-gpu-attach-test-'.uniqid(), 'development-qemu.gpu_memory' => 4096]);
$hostdevAttached = false;
$ran = [];
Process::fake(function ($process) use (&$hostdevAttached, &$ran) {
$ran[] = remoteProcessScript($process);
$hostdevAttached = $hostdevAttached || str_starts_with(remoteProcessScript($process), 'virt-xml ');
return match (true) {
str_contains(remoteProcessScript($process), ' net-dumpxml ') => Process::result(output: '<network></network>'),
str_contains(remoteProcessScript($process), ' network inspect ') => Process::result(output: "172.18.0.0/16\n"),
str_contains(remoteProcessScript($process), ' dominfo ') => Process::result(output: 'exists'),
str_contains(remoteProcessScript($process), ' domstate ') => Process::result(output: $hostdevAttached ? "shut off\n" : "running\n"),
str_contains(remoteProcessScript($process), ' dumpxml ') => Process::result(output: $hostdevAttached ? "<domain><hostdev mode='subsystem'/></domain>" : '<domain></domain>'),
str_starts_with(remoteProcessScript($process), 'lspci ') => Process::result(output: "0000:01:00.0 \"0300\" \"10de\" \"2584\"\n0000:01:00.1 \"0403\" \"10de\" \"2291\"\n"),
default => Process::result(),
};
});
StartDevelopmentQemuVm::run('ubuntu-root', false, true);
$commands = collect($ran);
$shutdown = $commands->search("virsh shutdown 'coolify-dev-ubuntu-root'");
$attach = $commands->search("virt-xml 'coolify-dev-ubuntu-root' --add-device --hostdev 'pci_0000_01_00_0'");
expect($shutdown)->not->toBeFalse()
->and($attach)->toBeGreaterThan($shutdown)
->and($commands)->toContain("virt-xml 'coolify-dev-ubuntu-root' --add-device --hostdev 'pci_0000_01_00_1'")
->and($commands->search("virsh setmem 'coolify-dev-ubuntu-root' 4096M --config"))->toBeGreaterThan($attach)
->and($commands->search("virsh start 'coolify-dev-ubuntu-root'"))->toBeGreaterThan($commands->search('systemctl stop nvidia-persistenced'));
Process::assertNotRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-install '));
});
it('keeps the gpu memory of a gpu vm that starts without the gpu option', function () {
config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-gpu-restart-test-'.uniqid(), 'development-qemu.memory' => 1024, 'development-qemu.gpu_memory' => 4096]);
fakeQemuHostWithGpu('shut off', "<domain><hostdev mode='subsystem'/></domain>");
StartDevelopmentQemuVm::run('ubuntu-root');
Process::assertRan("virsh setmem 'coolify-dev-ubuntu-root' 4096M --config");
Process::assertRan('systemctl stop nvidia-persistenced');
Process::assertNotRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-xml '));
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), '1024M'));
});
it('fails before it changes the vm when the host has no nvidia gpu', function () {
config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-no-gpu-test-'.uniqid()]);
Process::fake([
'* net-dumpxml *' => Process::result(output: '<network></network>'),
'* network inspect *' => Process::result(output: "172.18.0.0/16\n"),
'* dominfo *' => Process::result(output: 'exists'),
'* domstate *' => Process::result(output: "shut off\n"),
'*' => Process::result(),
]);
expect(fn () => StartDevelopmentQemuVm::run('ubuntu-root', false, true))
->toThrow(RuntimeException::class, 'No NVIDIA GPU found');
Process::assertNotRan(fn ($process) => str_starts_with(remoteProcessScript($process), 'virt-xml ') || str_contains(remoteProcessScript($process), 'virsh start'));
});
it('allows gpu passthrough only for one ubuntu root profile', function (array $profiles) {
Process::fake();
expect(Artisan::call('dev:qemu', ['profiles' => $profiles, '--gpu' => true]))->toBe(Command::FAILURE);
Process::assertNothingRan();
})->with([
'non-root' => [['ubuntu-non-root']],
'other distribution' => [['debian-root']],
'two profiles' => [['ubuntu-root', 'debian-root']],
]);
it('seeds a gpu vm through the container so it installs the driver', function () {
config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-gpu-seed-test-'.uniqid()]);
SeedDevelopmentQemuServer::mock()->shouldNotReceive('handle');
fakeQemuHostWithGpu('shut off', "<domain><hostdev mode='subsystem'/></domain>");
ManageDevelopmentQemuVm::run('ubuntu-root', true, false, true);
Process::assertRan("docker exec 'coolify' php artisan dev:qemu:seed 'ubuntu-root' --as-localhost --gpu");
});
it('does not start a gpu vm while host processes still use the gpu', function () {
config(['development-qemu.storage_path' => sys_get_temp_dir().'/coolify-qemu-gpu-busy-test-'.uniqid()]);
Process::fake([
'* net-dumpxml *' => Process::result(output: '<network></network>'),
'* network inspect *' => Process::result(output: "172.18.0.0/16\n"),
'* dominfo *' => Process::result(output: 'exists'),
'* domstate *' => Process::result(output: "shut off\n"),
'* dumpxml --inactive *' => Process::result(output: "<hostdev mode='subsystem' type='pci' managed='yes'>"),
'fuser *' => Process::result(output: "/dev/nvidia0: root 1210 F...m Xorg\n"),
'*' => Process::result(),
]);
expect(fn () => StartDevelopmentQemuVm::run('ubuntu-root'))
->toThrow(RuntimeException::class, 'Xorg');
Process::assertNotRan(fn ($process) => str_contains(remoteProcessScript($process), 'virsh start'));
});