mirror of
https://github.com/coollabsio/coolify.git
synced 2026-08-24 10:05:47 -05:00
Add configurable development QEMU profiles, host setup, VM lifecycle management, and commands to seed managed VMs as Coolify servers. Improve installation dialog logs with responsive mobile presentation.
220 lines
7.5 KiB
PHP
220 lines
7.5 KiB
PHP
<?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.');
|
|
}
|
|
}
|
|
}
|