Ctrl K
ComunidadGuías…

tests/Feature/DevelopmentQemuVmTest.php

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
<?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'));
});