mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 02:24:11 -05:00
Merge remote-tracking branch 'origin/main' into next
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
];
|
||||
@@ -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>
|
||||
|
||||
@@ -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'));
|
||||
});
|
||||
@@ -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 () {
|
||||
|
||||
Reference in New Issue
Block a user