From c890e88164f3a431e289ba9cef53d1256ecd0a33 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sun, 27 Sep 2026 17:03:15 +0200 Subject: [PATCH 1/4] [Feat] Add Proxmox server provider --- app/Actions/Server/InstallServer.php | 5 +- .../ServerProvider/CreateServerProvider.php | 2 + app/DTOs/DynamicField.php | 32 + app/Plugins/RegisterServerProvider.php | 25 + .../ServerProviderServiceProvider.php | 100 +++ app/ServerProviders/Proxmox.php | 586 ++++++++++++++++++ app/Support/helpers.php | 9 + public/api-docs/openapi/server-providers.yaml | 4 +- public/api-docs/openapi/servers.yaml | 12 +- .../openapi/user-server-providers.yaml | 4 +- resources/js/components/dialogs/registry.ts | 2 + .../components/dialogs/setup-guide-dialog.tsx | 78 +++ resources/js/components/ui/dynamic-field.tsx | 43 +- .../components/connect-server-provider.tsx | 33 +- .../servers/components/create-server.tsx | 24 + resources/js/types/dynamic-field-config.d.ts | 8 + resources/js/types/index.d.ts | 1 + tests/Feature/Jobs/ServerInstallJobTest.php | 35 ++ tests/Feature/ProxmoxProviderTest.php | 372 +++++++++++ 19 files changed, 1349 insertions(+), 26 deletions(-) create mode 100644 app/ServerProviders/Proxmox.php create mode 100644 resources/js/components/dialogs/setup-guide-dialog.tsx create mode 100644 tests/Feature/ProxmoxProviderTest.php diff --git a/app/Actions/Server/InstallServer.php b/app/Actions/Server/InstallServer.php index 968353be2..f0f5d4769 100644 --- a/app/Actions/Server/InstallServer.php +++ b/app/Actions/Server/InstallServer.php @@ -29,7 +29,8 @@ public function run(Server $server): void { $this->server = $server; - $maxWait = self::MAX_WAIT_SECONDS; + $timeout = (int) (config('server-provider.providers.'.$server->provider.'.provision_timeout') ?? self::MAX_WAIT_SECONDS); + $maxWait = $timeout; $connected = false; $lastError = null; @@ -51,7 +52,7 @@ public function run(Server $server): void if (! $connected) { throw new SSHConnectionError( - 'The server did not become reachable within '.self::MAX_WAIT_SECONDS.' seconds.', + 'The server did not become reachable within '.$timeout.' seconds.', previous: $lastError, ); } diff --git a/app/Actions/ServerProvider/CreateServerProvider.php b/app/Actions/ServerProvider/CreateServerProvider.php index d726b6e33..a15b34632 100644 --- a/app/Actions/ServerProvider/CreateServerProvider.php +++ b/app/Actions/ServerProvider/CreateServerProvider.php @@ -29,6 +29,8 @@ public function create(User $user, array $input, ?int $projectId): ServerProvide try { $provider->connect($input); + } catch (ValidationException $e) { + throw $e; } catch (Exception) { throw ValidationException::withMessages([ 'provider' => [ diff --git a/app/DTOs/DynamicField.php b/app/DTOs/DynamicField.php index 7195d85f0..6e82e1e22 100644 --- a/app/DTOs/DynamicField.php +++ b/app/DTOs/DynamicField.php @@ -18,6 +18,7 @@ public function __construct( private ?array $link = null, private ?string $className = null, private ?array $componentProps = null, + private ?string $width = null, ) {} public static function make(string $name): self @@ -81,6 +82,19 @@ public function alert(): self return $this; } + /** + * An alert with a button that opens a step-by-step setup guide. + * + * @param array $steps + */ + public function guide(array $steps): self + { + $this->type = 'guide'; + $this->componentProps = ['steps' => $steps]; + + return $this; + } + public function tooling(): self { $this->type = 'tooling'; @@ -141,6 +155,23 @@ public function toolingSelector(array $toolClasses, array $labelOverrides = [], return $this; } + /** + * Layout hint for forms rendered in columns; fields are full width by default. + */ + public function half(): self + { + $this->width = 'half'; + + return $this; + } + + public function third(): self + { + $this->width = 'third'; + + return $this; + } + public function name(string $name): self { $this->name = $name; @@ -227,6 +258,7 @@ public function toArray(): array 'link' => $this->link, 'className' => $this->className, 'componentProps' => $this->componentProps, + 'width' => $this->width, ]; } } diff --git a/app/Plugins/RegisterServerProvider.php b/app/Plugins/RegisterServerProvider.php index 287eb85f9..b50689057 100644 --- a/app/Plugins/RegisterServerProvider.php +++ b/app/Plugins/RegisterServerProvider.php @@ -12,6 +12,8 @@ public function __construct( private string $handler = '', private ?DynamicForm $form = null, private string $defaultUser = '', + private ?DynamicForm $createForm = null, + private ?int $provisionTimeout = null, ) {} public static function make(string $name): self @@ -47,6 +49,16 @@ public function form(DynamicForm $form): self return $this; } + /** + * Extra provider-specific fields rendered on the create-server form. + */ + public function createForm(DynamicForm $createForm): self + { + $this->createForm = $createForm; + + return $this; + } + public function defaultUser(string $defaultUser): self { $this->defaultUser = $defaultUser; @@ -54,6 +66,17 @@ public function defaultUser(string $defaultUser): self return $this; } + /** + * How long the install waits for a new server to boot and accept SSH, for + * providers that provision slower than the default. + */ + public function provisionTimeout(int $seconds): self + { + $this->provisionTimeout = $seconds; + + return $this; + } + public function register(): void { $providers = config('server-provider.providers'); @@ -63,6 +86,8 @@ public function register(): void 'handler' => $this->handler, 'form' => $this->form ? $this->form->toArray() : [], 'default_user' => $this->defaultUser, + 'create_form' => $this->createForm ? $this->createForm->toArray() : [], + 'provision_timeout' => $this->provisionTimeout, ]; config(['server-provider.providers' => $providers]); diff --git a/app/Providers/ServerProviderServiceProvider.php b/app/Providers/ServerProviderServiceProvider.php index 697c7aa9b..3d16a4bd1 100644 --- a/app/Providers/ServerProviderServiceProvider.php +++ b/app/Providers/ServerProviderServiceProvider.php @@ -4,12 +4,14 @@ use App\DTOs\DynamicField; use App\DTOs\DynamicForm; +use App\Enums\OperatingSystem; use App\Plugins\RegisterServerProvider; use App\ServerProviders\AWS; use App\ServerProviders\Custom; use App\ServerProviders\DigitalOcean; use App\ServerProviders\Hetzner; use App\ServerProviders\Linode; +use App\ServerProviders\Proxmox; use App\ServerProviders\Vultr; use Illuminate\Support\ServiceProvider; @@ -25,6 +27,7 @@ public function boot(): void $this->digitalOcean(); $this->linode(); $this->vultr(); + $this->proxmox(); } private function custom(): void @@ -118,4 +121,101 @@ private function vultr(): void ->defaultUser('root') ->register(); } + + private function proxmox(): void + { + RegisterServerProvider::make(Proxmox::id()) + ->label('Proxmox VE') + ->handler(Proxmox::class) + ->form( + DynamicForm::make([ + DynamicField::make('proxmox_guide') + ->guide($this->proxmoxGuide()) + ->label('Proxmox setup guide') + ->description('Requires Proxmox VE 8.1+, an API token and a cloud-init template for each Ubuntu version.'), + DynamicField::make('api_url') + ->text() + ->half() + ->label('API URL') + ->placeholder('https://proxmox.example.com:8006'), + DynamicField::make('storage') + ->text() + ->half() + ->label('Disk Storage') + ->placeholder('Same as template'), + DynamicField::make('token_id') + ->text() + ->half() + ->label('API Token ID') + ->placeholder('vito@pve!vito'), + DynamicField::make('token_secret') + ->password() + ->half() + ->label('API Token Secret'), + ...array_map( + fn (string $os): DynamicField => DynamicField::make(Proxmox::templateField($os)) + ->text() + ->third() + ->label('Ubuntu '.OperatingSystem::from($os)->getVersion().' Template') + ->placeholder('VMID'), + config('core.operating_systems'), + ), + DynamicField::make('verify_ssl') + ->checkbox() + ->label('Verify SSL certificate') + ->default(true), + ]) + ) + ->createForm( + DynamicForm::make([ + DynamicField::make('static_ip') + ->text() + ->half() + ->label('Static IP (CIDR)') + ->placeholder('192.168.1.50/24') + ->description('Leave empty to use DHCP. DHCP needs qemu-guest-agent in the template so Vito can find the IP.'), + DynamicField::make('gateway') + ->text() + ->half() + ->label('Gateway') + ->placeholder('192.168.1.1'), + ]) + ) + ->defaultUser('root') + ->provisionTimeout(600) + ->register(); + } + + /** + * @return array + */ + private function proxmoxGuide(): array + { + return [ + [ + 'title' => 'Create an API token', + 'description' => 'Run this in the Proxmox host shell and use vito@pve!vito as the Token ID and the printed value as the secret. If you create the token in the web UI instead, untick Privilege Separation or grant these roles to the token itself.', + 'code' => "pveum user add vito@pve\npveum acl modify / --users vito@pve --roles PVEVMAdmin,PVEDatastoreUser,PVESDNUser,PVEAuditor\npveum user token add vito@pve vito --privsep 0", + ], + [ + 'title' => 'Download the Ubuntu cloud image', + 'description' => 'Use an official cloud image, not an ISO install. Replace noble with jammy for Ubuntu 22.04.', + 'code' => "cd /root\nwget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img", + ], + [ + 'title' => 'Add the QEMU guest agent', + 'description' => 'Vito reads a DHCP-assigned IP from the guest agent. Skip this step if you always give servers a static IP.', + 'code' => "apt install -y libguestfs-tools\nvirt-customize -a noble-server-cloudimg-amd64.img --install qemu-guest-agent", + ], + [ + 'title' => 'Create the template', + 'description' => 'Change vmbr0 and local-lvm if your bridge or storage is named differently. Vito grows the disk to the plan size when it creates a server.', + 'code' => "qm create 9000 --name ubuntu-2404-cloud --memory 2048 --cores 2 --ostype l26 --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-pci\nqm set 9000 --scsi0 local-lvm:0,import-from=/root/noble-server-cloudimg-amd64.img\nqm set 9000 --ide2 local-lvm:cloudinit --boot order=scsi0 --serial0 socket --vga serial0 --agent 1\nqm template 9000", + ], + [ + 'title' => 'Connect Vito', + 'description' => 'Use https://your-host:8006 as the API URL, turn off Verify SSL for a self-signed certificate, and enter 9000 as the Ubuntu 24.04 template VMID. Vito must be able to reach the new servers over SSH.', + ], + ]; + } } diff --git a/app/ServerProviders/Proxmox.php b/app/ServerProviders/Proxmox.php new file mode 100644 index 000000000..81b77fce0 --- /dev/null +++ b/app/ServerProviders/Proxmox.php @@ -0,0 +1,586 @@ + + */ + private const PLANS = [ + 'xsmall' => ['cores' => 1, 'memory' => 1, 'disk' => 25], + 'small' => ['cores' => 1, 'memory' => 2, 'disk' => 50], + 'medium' => ['cores' => 2, 'memory' => 4, 'disk' => 80], + 'large' => ['cores' => 4, 'memory' => 8, 'disk' => 160], + 'xlarge' => ['cores' => 8, 'memory' => 16, 'disk' => 320], + '2xlarge' => ['cores' => 16, 'memory' => 32, 'disk' => 640], + ]; + + private const DISK_PATTERN = '/^(scsi|virtio|sata|ide)\d+$/'; + + private const NODE_PATTERN = '/^[a-zA-Z0-9]([a-zA-Z0-9.-]*[a-zA-Z0-9])?$/'; + + private const TASK_TIMEOUT_SECONDS = 30; + + public static function id(): string + { + return 'proxmox'; + } + + public static function templateField(string $os): string + { + return 'template_'.$os; + } + + /** + * @return array + */ + public static function templateFields(): array + { + return array_map(self::templateField(...), config('core.operating_systems')); + } + + public function createRules(array $input): array + { + return [ + 'region' => ['required', 'string', 'regex:'.self::NODE_PATTERN], + 'plan' => ['required', Rule::in(array_keys(self::PLANS))], + 'static_ip' => [ + 'nullable', + 'string', + function (string $attribute, mixed $value, Closure $fail): void { + if (! is_string($value) || ! Cidr::isValid($value) || Cidr::isV6($value) || Cidr::prefix($value) === 0) { + $fail('The static IP must be an IPv4 address in CIDR notation, e.g. 192.168.1.50/24.'); + + return; + } + + (new RestrictedIPAddressesRule)->validate($attribute, Cidr::address($value), $fail); + }, + ], + 'gateway' => ['nullable', 'required_with:static_ip', 'ipv4'], + ]; + } + + public function credentialValidationRules(array $input): array + { + $rules = [ + 'api_url' => ['required', 'url:https'], + 'token_id' => ['required', 'string', 'regex:/^[^@\s]+@[^!\s]+![^\s=]+$/'], + 'token_secret' => ['required', 'string'], + 'verify_ssl' => ['nullable', 'boolean'], + 'storage' => ['nullable', 'string', 'regex:/^[a-zA-Z][a-zA-Z0-9._-]*$/'], + ]; + + foreach (self::templateFields() as $field) { + $rules[$field] = ['nullable', 'integer', 'min:100']; + } + + return $rules; + } + + public function credentialData(array $input): array + { + $data = [ + 'api_url' => self::baseUrl((string) $input['api_url']), + 'token_id' => $input['token_id'], + 'token_secret' => $input['token_secret'], + 'verify_ssl' => filter_var($input['verify_ssl'] ?? true, FILTER_VALIDATE_BOOLEAN), + 'storage' => $input['storage'] ?? null, + ]; + + foreach (self::templateFields() as $field) { + $data[$field] = empty($input[$field]) ? null : (int) $input[$field]; + } + + return $data; + } + + public function data(array $input): array + { + return [ + 'region' => $input['region'], + 'plan' => $input['plan'], + 'static_ip' => $input['static_ip'] ?? null, + 'gateway' => $input['gateway'] ?? null, + ]; + } + + /** + * Besides the token, verifies every mapped template exists, is a template and + * carries a cloud-init drive, so a broken mapping fails here rather than mid-install. + * + * @throws CouldNotConnectToProvider + * @throws ValidationException + * @throws ServerProviderError + * @throws ConnectionException + */ + public function connect(array $credentials): bool + { + $templates = collect(self::templateFields()) + ->mapWithKeys(fn (string $field): array => [$field => (int) ($credentials[$field] ?? 0)]) + ->filter(); + + if ($templates->isEmpty()) { + throw ValidationException::withMessages([ + last(self::templateFields()) => 'Map at least one Ubuntu version to a template.', + ]); + } + + try { + $vms = collect($this->request('GET', '/cluster/resources', ['type' => 'vm'], $credentials)); + } catch (ServerProviderError|ConnectionException) { + throw new CouldNotConnectToProvider('Proxmox'); + } + + if ($vms->isEmpty()) { + throw ValidationException::withMessages([ + 'token_id' => 'The API token cannot see any VMs. If Privilege Separation is enabled on the token, grant the roles to the token itself or recreate it without privilege separation.', + ]); + } + + $errors = []; + + foreach ($templates as $field => $vmid) { + /** @var array{node?: string, template?: int, type?: string}|null $vm */ + $vm = $vms->firstWhere('vmid', $vmid); + + if (! $vm || ! isset($vm['node'])) { + $errors[$field] = "VM {$vmid} was not found or the API token cannot access it."; + + continue; + } + + if (empty($vm['template']) || ($vm['type'] ?? null) !== 'qemu') { + $errors[$field] = "VM {$vmid} is not a QEMU template."; + + continue; + } + + $config = $this->request('GET', '/nodes/'.rawurlencode($vm['node']).'/qemu/'.$vmid.'/config', [], $credentials); + + if (! $this->hasCloudInitDrive($config)) { + $errors[$field] = "Template {$vmid} has no cloud-init drive."; + } + } + + if ($errors !== []) { + throw ValidationException::withMessages($errors); + } + + return true; + } + + /** + * @return array + */ + public function plans(?string $region): array + { + try { + /** @var array{maxcpu?: int, maxmem?: int}|null $node */ + $node = collect($this->request('GET', '/nodes'))->firstWhere('node', $region); + } catch (Exception) { + $node = null; + } + + return collect(self::PLANS) + ->map(fn (array $plan, string $name): array => [ + 'label' => __('server_providers.plan', [ + 'name' => $name, + 'cpu' => $plan['cores'], + 'memory' => $plan['memory'], + 'disk' => $plan['disk'], + ]), + 'available' => ! isset($node['maxcpu'], $node['maxmem']) + || ($plan['cores'] <= $node['maxcpu'] && $plan['memory'] * 1024 ** 3 <= $node['maxmem']), + ]) + ->all(); + } + + public function regions(): array + { + try { + return collect($this->request('GET', '/nodes')) + ->where('status', 'online') + ->mapWithKeys(fn (array $node): array => [$node['node'] => $node['node']]) + ->sort() + ->all(); + } catch (Exception) { + return []; + } + } + + /** + * Starts a full clone of the mapped template and returns without waiting for it; + * `isRunning()` finishes provisioning once the clone task is done. + * + * @throws ServerProviderError + * @throws ConnectionException + */ + public function create(): void + { + $credentials = $this->serverProvider->getCredentials(); + $templateId = (int) ($credentials[self::templateField($this->server->os->value)] ?? 0); + + if ($templateId === 0) { + throw new ServerProviderError('No Proxmox template is mapped to Ubuntu '.$this->server->os->getVersion().' on this connection.'); + } + + $this->generateKeyPair(); + + /** @var array{node?: string, template?: int}|null $template */ + $template = collect($this->request('GET', '/cluster/resources', ['type' => 'vm']))->firstWhere('vmid', $templateId); + + if (! $template || empty($template['template']) || ! isset($template['node'])) { + throw new ServerProviderError("Proxmox template {$templateId} was not found."); + } + + $node = $this->server->provider_data['region']; + $vmid = (int) $this->request('GET', '/cluster/nextid'); + + $clone = [ + 'newid' => $vmid, + 'name' => Str::slug($this->server->name) ?: 'server-'.$this->server->id, + 'description' => $this->marker(), + 'full' => 1, + ]; + + if ($template['node'] !== $node) { + $clone['target'] = $node; + } + + if (! empty($credentials['storage'])) { + $clone['storage'] = $credentials['storage']; + } + + $task = $this->request('POST', '/nodes/'.rawurlencode($template['node']).'/qemu/'.$templateId.'/clone', $clone); + + $this->server->jsonUpdate('provider_data', 'vmid', $vmid, false); + $this->server->jsonUpdate('provider_data', 'task', $task, false); + + if (! empty($this->server->provider_data['static_ip'])) { + $this->server->ip = Cidr::address($this->server->provider_data['static_ip']); + } + + $this->server->save(); + } + + /** + * Polled by the install job. While the clone task runs this returns false; once it + * finishes, the VM is configured and started. With DHCP, the IP comes from the guest agent. + * + * @throws ServerProviderError + */ + public function isRunning(): bool + { + try { + if (isset($this->server->provider_data['task'])) { + if ($this->taskFinished($this->server->provider_data['task'])) { + $this->provision(); + } + + return false; + } + + $status = $this->request('GET', $this->vmPath().'/status/current'); + + if (($status['status'] ?? null) !== 'running') { + return false; + } + + if (! $this->server->ip) { + $ip = $this->guestIp(); + + if ($ip === null) { + return false; + } + + $this->server->ip = $ip; + $this->server->save(); + } + } catch (ConnectionException) { + return false; + } + + return true; + } + + public function delete(): void + { + if (! isset($this->server->provider_data['vmid'])) { + return; + } + + try { + $this->destroyVm(); + } catch (Exception) { + Notifier::send($this->server, new FailedToDeleteServerFromProvider($this->server)); + } + } + + /** + * Refuses to touch a VM without this server's marker: the VMID of a failed clone is + * freed and Proxmox can hand it out again to an unrelated VM. + * + * @throws ServerProviderError + * @throws ConnectionException + */ + private function destroyVm(): void + { + $config = $this->request('GET', $this->vmPath().'/config'); + + if (! str_contains((string) ($config['description'] ?? ''), $this->marker())) { + throw new ServerProviderError('Proxmox VM '.$this->server->provider_data['vmid'].' was not created for this server.'); + } + + $status = $this->request('GET', $this->vmPath().'/status/current'); + + if (($status['status'] ?? null) === 'running') { + $this->waitForTask((string) $this->request('POST', $this->vmPath().'/status/stop')); + } + + $this->request('DELETE', $this->vmPath(), [ + 'purge' => 1, + 'destroy-unreferenced-disks' => 1, + ]); + } + + private function marker(): string + { + return 'Managed by Vito (server #'.$this->server->id.')'; + } + + /** + * @throws ServerProviderError + * @throws ConnectionException + */ + private function provision(): void + { + $plan = self::PLANS[$this->server->provider_data['plan']]; + $staticIp = $this->server->provider_data['static_ip'] ?? null; + + $config = [ + 'cores' => $plan['cores'], + 'memory' => $plan['memory'] * 1024, + 'ciuser' => 'root', + 'sshkeys' => rawurlencode(trim($this->server->sshKey()['public_key'])), + 'ipconfig0' => $staticIp ? 'ip='.$staticIp.',gw='.$this->server->provider_data['gateway'] : 'ip=dhcp', + 'ciupgrade' => 0, + ]; + + if (! $staticIp) { + $config['agent'] = 1; + } + + $this->request('PUT', $this->vmPath().'/config', $config); + $this->resizeBootDisk($plan['disk']); + $this->waitForTask((string) $this->request('POST', $this->vmPath().'/status/start')); + + $this->server->jsonForget('provider_data', 'task'); + } + + /** + * @throws ServerProviderError + * @throws ConnectionException + */ + private function resizeBootDisk(int $size): void + { + $config = $this->request('GET', $this->vmPath().'/config'); + $disk = $this->bootDisk($config); + + if ($disk === null || $this->diskSize((string) $config[$disk]) >= $size) { + return; + } + + $task = $this->request('PUT', $this->vmPath().'/resize', [ + 'disk' => $disk, + 'size' => $size.'G', + ]); + + if (is_string($task) && str_starts_with($task, 'UPID:')) { + $this->waitForTask($task); + } + } + + /** + * @param array $config + */ + private function bootDisk(array $config): ?string + { + $disks = collect($config)->filter(fn (mixed $value, int|string $key): bool => preg_match(self::DISK_PATTERN, (string) $key) === 1 + && ! str_contains((string) $value, 'media=cdrom') + && ! str_contains((string) $value, 'cloudinit')); + + $order = explode(';', Str::after((string) ($config['boot'] ?? ''), 'order=')); + + return collect($order)->first(fn (string $key): bool => $disks->has($key)) ?? $disks->keys()->first(); + } + + /** + * Size of a drive definition such as `local-lvm:vm-100-disk-0,size=3584M`, in GiB. + */ + private function diskSize(string $disk): float + { + if (preg_match('/(?:^|,)size=(\d+(?:\.\d+)?)([KMGT]?)/', $disk, $matches) !== 1) { + return 0; + } + + return (float) $matches[1] * match ($matches[2]) { + 'K' => 1 / 1024 ** 2, + 'M' => 1 / 1024, + 'G' => 1, + 'T' => 1024, + default => 1 / 1024 ** 3, + }; + } + + /** + * @param array $config + */ + private function hasCloudInitDrive(array $config): bool + { + return collect($config)->contains(fn (mixed $value, int|string $key): bool => preg_match(self::DISK_PATTERN, (string) $key) === 1 + && str_contains((string) $value, 'cloudinit')); + } + + /** + * @throws ConnectionException + */ + private function guestIp(): ?string + { + try { + $interfaces = $this->request('GET', $this->vmPath().'/agent/network-get-interfaces'); + } catch (ServerProviderError) { + return null; + } + + foreach ($interfaces['result'] ?? [] as $interface) { + foreach ($interface['ip-addresses'] ?? [] as $address) { + $ip = $address['ip-address'] ?? null; + + if (($address['ip-address-type'] ?? null) === 'ipv4' + && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 | FILTER_FLAG_NO_RES_RANGE) !== false + && ! in_array($ip, config('core.restricted_ip_addresses'), true)) { + return $ip; + } + } + } + + return null; + } + + /** + * @throws ServerProviderError + * @throws ConnectionException + */ + private function taskFinished(string $upid): bool + { + $node = explode(':', $upid)[1] ?? ''; + $task = $this->request('GET', '/nodes/'.rawurlencode($node).'/tasks/'.rawurlencode($upid).'/status'); + + if (($task['status'] ?? null) !== 'stopped') { + return false; + } + + $exitStatus = (string) ($task['exitstatus'] ?? ''); + + if ($exitStatus !== 'OK' && ! str_starts_with($exitStatus, 'WARNINGS')) { + throw new ServerProviderError('Proxmox task failed: '.$exitStatus); + } + + return true; + } + + /** + * @throws ServerProviderError + * @throws ConnectionException + */ + private function waitForTask(string $upid): void + { + for ($second = 0; $second < self::TASK_TIMEOUT_SECONDS; $second++) { + if ($this->taskFinished($upid)) { + return; + } + + Sleep::sleep(1); + } + + throw new ServerProviderError('Timed out waiting for the Proxmox task to finish.'); + } + + private function vmPath(): string + { + return '/nodes/'.rawurlencode((string) $this->server->provider_data['region']).'/qemu/'.(int) $this->server->provider_data['vmid']; + } + + /** + * @param array $data + * @param array|null $credentials + * + * @throws ServerProviderError + * @throws ConnectionException + */ + private function request(string $method, string $path, array $data = [], ?array $credentials = null): mixed + { + $credentials ??= $this->serverProvider->getCredentials(); + + $response = Http::withHeaders([ + 'Authorization' => 'PVEAPIToken='.$credentials['token_id'].'='.$credentials['token_secret'], + ]) + ->withOptions(['verify' => filter_var($credentials['verify_ssl'] ?? true, FILTER_VALIDATE_BOOLEAN)]) + ->acceptJson() + ->asForm() + ->timeout(30) + ->send($method, self::baseUrl((string) $credentials['api_url']).'/api2/json'.$path, in_array($method, ['POST', 'PUT'], true) + ? ['form_params' => $data] + : ['query' => $data]); + + if (! $response->successful()) { + throw new ServerProviderError($this->errorMessage($response)); + } + + if (! is_array($response->json()) || ! array_key_exists('data', $response->json())) { + throw new ServerProviderError('Unexpected response from '.self::baseUrl((string) $credentials['api_url']).'. Is it a Proxmox VE API URL?'); + } + + return $response->json('data'); + } + + /** + * The API always lives at `/api2/json` on the host root, so any path, query or + * fragment (e.g. a URL copied from the web UI) is dropped. + */ + private static function baseUrl(string $url): string + { + $parts = parse_url($url); + + return 'https://'.($parts['host'] ?? '').(isset($parts['port']) ? ':'.$parts['port'] : ''); + } + + private function errorMessage(Response $response): string + { + $errors = collect((array) $response->json('errors')) + ->map(fn (mixed $message, int|string $field): string => $field.': '.trim((string) $message)) + ->implode(' '); + + return trim('Proxmox API error ('.$response->status().'): '.trim((string) ($response->json('message') ?: $response->reason())).' '.$errors); + } +} diff --git a/app/Support/helpers.php b/app/Support/helpers.php index 56debe476..5ecc00089 100755 --- a/app/Support/helpers.php +++ b/app/Support/helpers.php @@ -17,9 +17,18 @@ function generate_public_key(string $privateKeyPath, string $publicKeyPath): voi exec("ssh-keygen -y -f {$privateKeyPath} > {$publicKeyPath}"); } +/** + * ssh-keygen builds linked against LibreSSL (e.g. OpenSSH 10 on macOS) cannot write + * ed25519 keys as PEM, so fall back to the OpenSSH format, which phpseclib also reads. + */ function generate_key_pair(string $path): void { exec("ssh-keygen -t ed25519 -m PEM -N '' -f {$path}"); + + if (! file_exists($path)) { + exec("ssh-keygen -t ed25519 -N '' -f {$path}"); + } + chmod($path, 0400); } diff --git a/public/api-docs/openapi/server-providers.yaml b/public/api-docs/openapi/server-providers.yaml index 8eece644f..4b754f053 100644 --- a/public/api-docs/openapi/server-providers.yaml +++ b/public/api-docs/openapi/server-providers.yaml @@ -82,12 +82,12 @@ paths: example: 'DigitalOcean' provider: type: string - enum: ['aws', 'hetzner', 'digitalocean', 'linode', 'vultr'] + enum: ['aws', 'hetzner', 'digitalocean', 'linode', 'vultr', 'proxmox'] description: Server provider type example: 'digitalocean' credentials: type: object - description: Provider-specific credentials + description: 'Provider-specific credentials. Proxmox: `api_url` (https), `token_id` (`user@realm!name`), `token_secret`, `verify_ssl` (default true), optional `storage`, and at least one of `template_ubuntu_20`, `template_ubuntu_22`, `template_ubuntu_24` (cloud-init template VMIDs).' example: token: 'dop_v1_xxxxxxxxxxxx' global: diff --git a/public/api-docs/openapi/servers.yaml b/public/api-docs/openapi/servers.yaml index 5be390400..6a45e395a 100644 --- a/public/api-docs/openapi/servers.yaml +++ b/public/api-docs/openapi/servers.yaml @@ -76,7 +76,7 @@ paths: properties: provider: type: string - enum: ['custom', 'aws', 'hetzner', 'digitalocean', 'linode', 'vultr'] + enum: ['custom', 'aws', 'hetzner', 'digitalocean', 'linode', 'vultr', 'proxmox'] description: Server provider type example: 'digitalocean' name: @@ -97,7 +97,15 @@ paths: description: Server plan (required for non-custom provider) region: type: string - description: Server region (required for non-custom provider) + description: Server region (required for non-custom provider). For Proxmox this is the node name. + static_ip: + type: string + description: Proxmox only. Static IPv4 address in CIDR notation. Leave empty to use DHCP, which needs qemu-guest-agent in the template. + example: '192.168.1.50/24' + gateway: + type: string + description: Proxmox only. IPv4 gateway (required with static_ip). + example: '192.168.1.1' ip: type: string description: Server IP address (required for custom provider) diff --git a/public/api-docs/openapi/user-server-providers.yaml b/public/api-docs/openapi/user-server-providers.yaml index d62572f18..ecf7ebd48 100644 --- a/public/api-docs/openapi/user-server-providers.yaml +++ b/public/api-docs/openapi/user-server-providers.yaml @@ -58,12 +58,12 @@ paths: example: 'DigitalOcean' provider: type: string - enum: ['aws', 'hetzner', 'digitalocean', 'linode', 'vultr'] + enum: ['aws', 'hetzner', 'digitalocean', 'linode', 'vultr', 'proxmox'] description: Server provider type example: 'digitalocean' credentials: type: object - description: Provider-specific credentials + description: 'Provider-specific credentials. Proxmox: `api_url` (https), `token_id` (`user@realm!name`), `token_secret`, `verify_ssl` (default true), optional `storage`, and at least one of `template_ubuntu_20`, `template_ubuntu_22`, `template_ubuntu_24` (cloud-init template VMIDs).' example: token: 'dop_v1_xxxxxxxxxxxx' global: diff --git a/resources/js/components/dialogs/registry.ts b/resources/js/components/dialogs/registry.ts index bb19f42b2..5e9852458 100644 --- a/resources/js/components/dialogs/registry.ts +++ b/resources/js/components/dialogs/registry.ts @@ -1,6 +1,7 @@ import type { ComponentType } from 'react'; import LogViewerDialog from './log-viewer-dialog'; import ConfirmationDialog from './confirmation-dialog'; +import SetupGuideDialog from './setup-guide-dialog'; import StorageProviderEditDialog from '@/pages/storage-providers/components/edit-dialog'; import PluginLogsDialog from '@/pages/plugins/components/logs-dialog'; import WorkerLogsDialog from '@/pages/workers/components/logs-dialog'; @@ -58,6 +59,7 @@ export type DialogControlProps = { open: boolean; onOpenChange: (open: boolean) export const dialogs = { logViewer: LogViewerDialog, confirm: ConfirmationDialog, + setupGuide: SetupGuideDialog, storageProviderEdit: StorageProviderEditDialog, pluginLogs: PluginLogsDialog, workerLogs: WorkerLogsDialog, diff --git a/resources/js/components/dialogs/setup-guide-dialog.tsx b/resources/js/components/dialogs/setup-guide-dialog.tsx new file mode 100644 index 000000000..de09b6780 --- /dev/null +++ b/resources/js/components/dialogs/setup-guide-dialog.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react'; +import { CheckIcon, CopyIcon } from 'lucide-react'; +import { toast } from 'sonner'; +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { SetupGuideStep } from '@/types/dynamic-field-config'; + +export default function SetupGuideDialog({ + open, + onOpenChange, + title, + description, + steps, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + steps: SetupGuideStep[]; +}) { + const [copied, setCopied] = useState(null); + + const copy = (index: number, code: string) => { + navigator.clipboard + .writeText(code) + .then(() => { + setCopied(index); + setTimeout(() => setCopied(null), 2000); + }) + .catch(() => toast.error('Could not copy to clipboard')); + }; + + return ( + + e.preventDefault()}> + + {title} + {description || title} + + +
    + {steps.map((step, index) => ( +
  1. + + {index + 1} + +
    +

    {step.title}

    + {step.description &&

    {step.description}

    } + {step.code && ( +
    +
    {step.code}
    + +
    + )} +
    +
  2. + ))} +
+ + + + + + +
+
+ ); +} diff --git a/resources/js/components/ui/dynamic-field.tsx b/resources/js/components/ui/dynamic-field.tsx index 077aff05b..d9058e3e4 100644 --- a/resources/js/components/ui/dynamic-field.tsx +++ b/resources/js/components/ui/dynamic-field.tsx @@ -5,13 +5,24 @@ import { PasswordInput } from '@/components/ui/password-input'; import { Switch } from '@/components/ui/switch'; import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { DynamicFieldConfig } from '@/types/dynamic-field-config'; +import { DynamicFieldConfig, SetupGuideStep } from '@/types/dynamic-field-config'; import InputError from '@/components/ui/input-error'; import { FormField } from '@/components/ui/form'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; -import { TriangleAlertIcon } from 'lucide-react'; +import { BookOpenIcon, TriangleAlertIcon } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useDialogStore } from '@/stores/dialog-store'; import ServerProviderSelect from '@/pages/server-providers/components/server-provider-select'; +const widthSpans = { half: 'sm:col-span-3', third: 'sm:col-span-2' }; + +/** + * Column span for a field inside a `sm:grid-cols-6` grid, from the backend's width hint. + */ +export function dynamicFieldSpan(config: DynamicFieldConfig): string { + return config.width ? widthSpans[config.width] : 'sm:col-span-6'; +} + interface DynamicFieldProps { value: string | number | boolean | string[] | undefined; onChange: (value: string | number | boolean | string[]) => void; @@ -59,6 +70,34 @@ export default function DynamicField({ value, onChange, config, error }: Dynamic ); } + if (config?.type === 'guide') { + return ( + + + + + {config.description} + + + + + ); + } + // Handle checkbox if (config?.type === 'checkbox') { return ( diff --git a/resources/js/pages/server-providers/components/connect-server-provider.tsx b/resources/js/pages/server-providers/components/connect-server-provider.tsx index 0f7c3115b..15d46d240 100644 --- a/resources/js/pages/server-providers/components/connect-server-provider.tsx +++ b/resources/js/pages/server-providers/components/connect-server-provider.tsx @@ -19,7 +19,7 @@ import { Form, FormField, FormFields } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; import { DynamicFieldConfig } from '@/types/dynamic-field-config'; -import DynamicField from '@/components/ui/dynamic-field'; +import DynamicField, { dynamicFieldSpan } from '@/components/ui/dynamic-field'; import { useConfigs } from '@/stores/bootstrap-store'; type ServerProviderForm = { @@ -75,14 +75,14 @@ export default function ConnectServerProvider({ return ( {children} - + Connect to server provider Connect to a new server provider
- - + + form.setData('name', e.target.value)} /> {configs.server_provider.providers[form.data.provider]?.form?.map((field: DynamicFieldConfig) => ( - form.setData(field.name, value)} - config={field} - /*@ts-expect-error dynamic types*/ - error={form.errors[field.name]} - /> +
+ form.setData(field.name, value)} + config={field} + /*@ts-expect-error dynamic types*/ + error={form.errors[field.name]} + /> +
))} - +
form.setData('global', !form.data.global)} /> diff --git a/resources/js/pages/servers/components/create-server.tsx b/resources/js/pages/servers/components/create-server.tsx index 856e59437..a6825ace5 100644 --- a/resources/js/pages/servers/components/create-server.tsx +++ b/resources/js/pages/servers/components/create-server.tsx @@ -44,6 +44,8 @@ import { Command, CommandGroup, CommandInput, CommandItem, CommandList } from '@ import { cn } from '@/lib/utils'; import { toast } from 'sonner'; import { useSocketListener } from '@/hooks/use-socket-events'; +import DynamicField, { dynamicFieldSpan } from '@/components/ui/dynamic-field'; +import { DynamicFieldValue } from '@/types/dynamic-field-config'; type PlanOption = { label: string; @@ -62,6 +64,7 @@ type CreateServerForm = { region: string; plan: string; services: Service[]; + [key: string]: DynamicFieldValue | Service[]; }; function AddService() { @@ -333,6 +336,12 @@ export default function CreateServer({ form.clearErrors(); form.setData('region', ''); form.setData('plan', ''); + const staleFields = configs.server_provider.providers[form.data.provider]?.create_form ?? []; + form.setData((data) => { + const next = { ...data }; + staleFields.forEach((field) => delete next[field.name]); + return next; + }); setRegions({}); setPlans({}); @@ -530,6 +539,21 @@ export default function CreateServer({
)} + {!!configs.server_provider.providers[form.data.provider]?.create_form?.length && ( +
+ {configs.server_provider.providers[form.data.provider].create_form!.map((field) => ( +
+ form.setData(field.name, value)} + config={field} + error={form.errors[field.name]} + /> +
+ ))} +
+ )} + {form.data.provider === 'custom' && ( <> diff --git a/resources/js/types/dynamic-field-config.d.ts b/resources/js/types/dynamic-field-config.d.ts index 8b28371f6..23aa92009 100644 --- a/resources/js/types/dynamic-field-config.d.ts +++ b/resources/js/types/dynamic-field-config.d.ts @@ -1,5 +1,11 @@ export type DynamicFieldValue = string | number | boolean | string[]; +export interface SetupGuideStep { + title: string; + description?: string; + code?: string; +} + export interface DynamicFieldConfig { type: | 'text' @@ -10,6 +16,7 @@ export interface DynamicFieldConfig { | 'checkbox' | 'component' | 'alert' + | 'guide' | 'tooling' | 'tooling-picker' | 'tooling-selector'; @@ -27,4 +34,5 @@ export interface DynamicFieldConfig { }; className?: string; componentProps?: Record; + width?: 'half' | 'third' | null; } diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 3cbf43ac7..362c30024 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -48,6 +48,7 @@ export interface Configs { label: string; handler: string; form?: DynamicFieldConfig[]; + create_form?: DynamicFieldConfig[]; }; }; }; diff --git a/tests/Feature/Jobs/ServerInstallJobTest.php b/tests/Feature/Jobs/ServerInstallJobTest.php index 9734112d9..b6399b7c9 100644 --- a/tests/Feature/Jobs/ServerInstallJobTest.php +++ b/tests/Feature/Jobs/ServerInstallJobTest.php @@ -83,6 +83,41 @@ expect($this->server->status)->toEqual(ServerStatus::INSTALLING); }); +test('a provider can extend how long the install waits for the server', function () { + SSH::fake(); + config()->set('server-provider.providers.'.DigitalOcean::id().'.provision_timeout', 300); + + Http::fake([ + 'api.digitalocean.com/v2/droplets/*' => Http::response([ + 'droplet' => [ + 'id' => 599103218, + 'status' => 'new', + 'networks' => ['v4' => []], + ], + ]), + '*' => Http::response([]), + ]); + + $serverProvider = ServerProvider::factory()->create([ + 'user_id' => $this->user->id, + 'provider' => DigitalOcean::id(), + 'credentials' => ['token' => 'secret-token'], + ]); + + $this->server->update([ + 'provider' => DigitalOcean::id(), + 'provider_id' => $serverProvider->id, + 'provider_data' => ['plan' => 's-1vcpu-512mb-10gb', 'region' => 'nyc1', 'droplet_id' => 599103218], + 'ip' => '', + 'status' => ServerStatus::INSTALLING, + ]); + + expect(fn () => app(InstallServer::class)->run($this->server->refresh())) + ->toThrow(SSHConnectionError::class, 'The server did not become reachable within 300 seconds.'); + + Sleep::assertSleptTimes(30); +}); + test('the underlying ssh failure is preserved when the server never becomes reachable', function () { SSH::fake()->connectionWillFail(); diff --git a/tests/Feature/ProxmoxProviderTest.php b/tests/Feature/ProxmoxProviderTest.php new file mode 100644 index 000000000..450334afe --- /dev/null +++ b/tests/Feature/ProxmoxProviderTest.php @@ -0,0 +1,372 @@ +proxmox = ServerProvider::factory()->create([ + 'user_id' => $this->user->id, + 'project_id' => $this->user->current_project_id, + 'provider' => Proxmox::id(), + 'credentials' => [ + 'api_url' => 'https://pve.test:8006', + 'token_id' => 'vito@pve!vito', + 'token_secret' => 'secret', + 'verify_ssl' => true, + 'storage' => null, + 'template_ubuntu_24' => 9000, + ], + ]); + + $this->server->update([ + 'provider' => Proxmox::id(), + 'provider_id' => $this->proxmox->id, + 'provider_data' => ['region' => 'pve', 'plan' => 'medium', 'vmid' => 101], + ]); + $this->server->refresh(); +}); + +test('connect proxmox', function (string $apiUrl) { + $this->actingAs($this->user); + + Http::fake([ + '*/api2/json/cluster/resources*' => Http::response(['data' => [ + ['vmid' => 9000, 'node' => 'pve', 'template' => 1, 'type' => 'qemu'], + ]]), + '*/api2/json/nodes/pve/qemu/9000/config' => Http::response(['data' => [ + 'scsi0' => 'local-lvm:base-9000-disk-0,size=3584M', + 'ide2' => 'local-lvm:vm-9000-cloudinit,media=cdrom', + ]]), + ]); + + $this->post(route('server-providers.store'), [ + 'provider' => Proxmox::id(), + 'name' => 'homelab', + 'api_url' => $apiUrl, + 'token_id' => 'vito@pve!vito', + 'token_secret' => 'secret', + 'verify_ssl' => false, + 'template_ubuntu_24' => '9000', + ])->assertSessionDoesntHaveErrors(); + + $provider = ServerProvider::query()->where('profile', 'homelab')->firstOrFail(); + + expect($provider->credentials)->toMatchArray([ + 'api_url' => 'https://pve.test:8006', + 'verify_ssl' => false, + 'template_ubuntu_22' => null, + 'template_ubuntu_24' => 9000, + ]); + + Http::assertSent(fn (Request $request): bool => $request->hasHeader('Authorization', 'PVEAPIToken=vito@pve!vito=secret') + && str_starts_with($request->url(), 'https://pve.test:8006/api2/json/cluster/resources')); +})->with([ + 'host' => 'https://pve.test:8006/', + 'api path' => 'https://pve.test:8006/api2/json', + 'web ui url' => 'https://pve.test:8006/#v1:0:=qemu%2F100:4:::::::', +]); + +test('cannot connect proxmox when the token sees no vms', function () { + $this->actingAs($this->user); + + Http::fake(['*/api2/json/cluster/resources*' => Http::response(['data' => []])]); + + $this->post(route('server-providers.store'), [ + 'provider' => Proxmox::id(), + 'name' => 'homelab', + 'api_url' => 'https://pve.test:8006', + 'token_id' => 'root@pam!vito', + 'token_secret' => 'secret', + 'template_ubuntu_24' => '100', + ])->assertSessionHasErrors('token_id'); +}); + +test('cannot connect proxmox when the url is not a proxmox api', function () { + $this->actingAs($this->user); + + Http::fake(['*' => Http::response('Proxmox Virtual Environment')]); + + $this->post(route('server-providers.store'), [ + 'provider' => Proxmox::id(), + 'name' => 'homelab', + 'api_url' => 'https://pve.test:8006', + 'token_id' => 'root@pam!vito', + 'token_secret' => 'secret', + 'template_ubuntu_24' => '100', + ])->assertSessionHasErrors('provider'); +}); + +test('cannot connect proxmox with an invalid token', function () { + $this->actingAs($this->user); + + Http::fake(['*' => Http::response(['data' => null], 401)]); + + $this->post(route('server-providers.store'), [ + 'provider' => Proxmox::id(), + 'name' => 'homelab', + 'api_url' => 'https://pve.test:8006', + 'token_id' => 'vito@pve!vito', + 'token_secret' => 'wrong', + 'template_ubuntu_24' => '9000', + ])->assertSessionHasErrors('provider'); + + $this->assertDatabaseMissing('server_providers', ['profile' => 'homelab']); +}); + +test('cannot connect proxmox with an invalid template mapping', function (array $input, array $vm, array $config, string $error) { + $this->actingAs($this->user); + + Http::fake([ + '*/api2/json/cluster/resources*' => Http::response(['data' => [$vm]]), + '*/api2/json/nodes/pve/qemu/9000/config' => Http::response(['data' => $config]), + ]); + + $this->post(route('server-providers.store'), array_merge([ + 'provider' => Proxmox::id(), + 'name' => 'homelab', + 'api_url' => 'https://pve.test:8006', + 'token_id' => 'vito@pve!vito', + 'token_secret' => 'secret', + ], $input))->assertSessionHasErrors(['template_ubuntu_24' => $error]); + + $this->assertDatabaseMissing('server_providers', ['profile' => 'homelab']); +})->with([ + 'no mapping' => [[], [], [], 'Map at least one Ubuntu version to a template.'], + 'missing vm' => [['template_ubuntu_24' => '9000'], ['vmid' => 100, 'node' => 'pve', 'template' => 1, 'type' => 'qemu'], [], 'VM 9000 was not found or the API token cannot access it.'], + 'not a template' => [['template_ubuntu_24' => '9000'], ['vmid' => 9000, 'node' => 'pve', 'template' => 0, 'type' => 'qemu'], [], 'VM 9000 is not a QEMU template.'], + 'container template' => [['template_ubuntu_24' => '9000'], ['vmid' => 9000, 'node' => 'pve', 'template' => 1, 'type' => 'lxc'], [], 'VM 9000 is not a QEMU template.'], + 'no cloud-init drive' => [['template_ubuntu_24' => '9000'], ['vmid' => 9000, 'node' => 'pve', 'template' => 1, 'type' => 'qemu'], ['scsi0' => 'local-lvm:base-9000-disk-0,size=3584M'], 'Template 9000 has no cloud-init drive.'], +]); + +test('proxmox plans larger than the node are unavailable', function () { + $this->actingAs($this->user); + + Http::fake([ + '*/api2/json/nodes' => Http::response(['data' => [ + ['node' => 'pve', 'status' => 'online', 'maxcpu' => 4, 'maxmem' => 8 * 1024 ** 3 - 1], + ]]), + ]); + + $plans = $this->get(route('server-providers.plans', [ + 'serverProvider' => $this->proxmox->id, + 'region' => 'pve', + ])) + ->assertSuccessful() + ->json(); + + expect(collect($plans)->map(fn (array $plan): bool => $plan['available'])->all())->toBe([ + 'xsmall' => true, + 'small' => true, + 'medium' => true, + 'large' => false, + 'xlarge' => false, + '2xlarge' => false, + ]); +}); + +test('create proxmox server clones the mapped template', function () { + $this->actingAs($this->user); + + Queue::fake(); + Http::fake([ + '*/api2/json/cluster/resources*' => Http::response(['data' => [ + ['vmid' => 9000, 'node' => 'pve', 'template' => 1, 'type' => 'qemu'], + ]]), + '*/api2/json/cluster/nextid' => Http::response(['data' => '102']), + '*/api2/json/nodes/pve/qemu/9000/clone' => Http::response(['data' => 'UPID:pve:0001:qmclone:9000:vito@pve!vito:']), + ]); + + $this->post(route('servers.store'), [ + 'provider' => Proxmox::id(), + 'server_provider' => $this->proxmox->id, + 'name' => 'Web 1', + 'os' => OperatingSystem::UBUNTU24->value, + 'region' => 'pve2', + 'plan' => 'medium', + 'static_ip' => '192.168.1.50/24', + 'gateway' => '192.168.1.1', + ])->assertSessionDoesntHaveErrors(); + + $server = Server::query()->where('name', 'Web 1')->firstOrFail(); + + expect($server->ip)->toBe('192.168.1.50') + ->and($server->ssh_user)->toBe('root') + ->and($server->provider_data)->toMatchArray([ + 'region' => 'pve2', + 'plan' => 'medium', + 'vmid' => 102, + 'task' => 'UPID:pve:0001:qmclone:9000:vito@pve!vito:', + ]); + + Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/nodes/pve/qemu/9000/clone') + && $request['newid'] == 102 + && $request['name'] === 'web-1' + && $request['description'] === 'Managed by Vito (server #'.$server->id.')' + && $request['full'] == 1 + && $request['target'] === 'pve2'); + + Queue::assertPushed(InstallJob::class); +}); + +test('cannot create proxmox server with invalid input', function (array $input, string $error) { + $this->actingAs($this->user); + + Http::fake(); + + $this->post(route('servers.store'), array_merge([ + 'provider' => Proxmox::id(), + 'server_provider' => $this->proxmox->id, + 'name' => 'web', + 'os' => OperatingSystem::UBUNTU24->value, + 'region' => 'pve', + 'plan' => 'medium', + ], $input))->assertSessionHasErrors($error); + + Http::assertNothingSent(); +})->with([ + 'os without template' => [['os' => OperatingSystem::UBUNTU22->value], 'provider'], + 'ip without prefix' => [['static_ip' => '192.168.1.50', 'gateway' => '192.168.1.1'], 'static_ip'], + 'restricted ip' => [['static_ip' => '127.0.0.1/8', 'gateway' => '127.0.0.254'], 'static_ip'], + 'ip without gateway' => [['static_ip' => '192.168.1.50/24'], 'gateway'], + 'unknown plan' => [['plan' => 'huge'], 'plan'], + 'unsafe node' => [['region' => '../../access'], 'region'], +]); + +test('proxmox configures and starts the vm once the clone finishes', function (array $providerData, string $ipConfig, bool $agent) { + $this->server->update([ + 'provider_data' => array_merge($this->server->provider_data, $providerData, [ + 'task' => 'UPID:pve:0001:qmclone:9000:vito@pve!vito:', + ]), + ]); + + Http::fake([ + '*/api2/json/nodes/pve/tasks/*' => Http::response(['data' => ['status' => 'stopped', 'exitstatus' => 'OK']]), + '*/api2/json/nodes/pve/qemu/101/config' => fn (Request $request) => $request->method() === 'GET' + ? Http::response(['data' => [ + 'boot' => 'order=scsi0;ide2;net0', + 'ide2' => 'local-lvm:vm-101-cloudinit,media=cdrom', + 'scsi0' => 'local-lvm:vm-101-disk-0,size=3584M', + ]]) + : Http::response(['data' => null]), + '*/api2/json/nodes/pve/qemu/101/resize' => Http::response(['data' => 'UPID:pve:0002:resize:101:vito@pve!vito:']), + '*/api2/json/nodes/pve/qemu/101/status/start' => Http::response(['data' => 'UPID:pve:0003:qmstart:101:vito@pve!vito:']), + ]); + + expect($this->server->provider()->isRunning())->toBeFalse() + ->and($this->server->refresh()->provider_data)->not->toHaveKey('task'); + + $publicKey = rawurlencode(trim($this->server->sshKey()['public_key'])); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'PUT' + && str_ends_with($request->url(), '/qemu/101/config') + && $request['cores'] == 2 + && $request['memory'] == 4096 + && $request['ciuser'] === 'root' + && $request['sshkeys'] === $publicKey + && $request['ipconfig0'] === $ipConfig + && $request['ciupgrade'] == 0 + && isset($request['agent']) === $agent); + Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/qemu/101/resize') + && $request['disk'] === 'scsi0' + && $request['size'] === '80G'); + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && str_ends_with($request->url(), '/qemu/101/status/start')); +})->with([ + 'dhcp' => [[], 'ip=dhcp', true], + 'static ip' => [['static_ip' => '192.168.1.50/24', 'gateway' => '192.168.1.1'], 'ip=192.168.1.50/24,gw=192.168.1.1', false], +]); + +test('proxmox fails the install when the clone task fails', function () { + $this->server->update([ + 'provider_data' => array_merge($this->server->provider_data, [ + 'task' => 'UPID:pve:0001:qmclone:9000:vito@pve!vito:', + ]), + ]); + + Http::fake([ + '*/api2/json/nodes/pve/tasks/*' => Http::response(['data' => ['status' => 'stopped', 'exitstatus' => "can't lock file"]]), + ]); + + expect(fn () => $this->server->provider()->isRunning()) + ->toThrow(ServerProviderError::class, "Proxmox task failed: can't lock file"); +}); + +test('proxmox reads the dhcp ip from the guest agent', function () { + $this->server->update(['ip' => '']); + + Http::fake([ + '*/api2/json/nodes/pve/qemu/101/status/current' => Http::response(['data' => ['status' => 'running']]), + '*/api2/json/nodes/pve/qemu/101/agent/network-get-interfaces' => Http::sequence() + ->push(['data' => null], 500) + ->push(['data' => ['result' => [ + ['name' => 'lo', 'ip-addresses' => [['ip-address-type' => 'ipv4', 'ip-address' => '127.0.0.1']]], + ['name' => 'eth0', 'ip-addresses' => [ + ['ip-address-type' => 'ipv6', 'ip-address' => 'fe80::1'], + ['ip-address-type' => 'ipv4', 'ip-address' => '192.168.1.77'], + ]], + ]]]), + ]); + + expect($this->server->provider()->isRunning())->toBeFalse() + ->and($this->server->provider()->isRunning())->toBeTrue() + ->and($this->server->refresh()->ip)->toBe('192.168.1.77'); +}); + +test('proxmox ignores a restricted ip reported by the guest agent', function () { + config()->set('core.restricted_ip_addresses', ['192.168.1.77']); + $this->server->update(['ip' => '']); + + Http::fake([ + '*/api2/json/nodes/pve/qemu/101/status/current' => Http::response(['data' => ['status' => 'running']]), + '*/api2/json/nodes/pve/qemu/101/agent/network-get-interfaces' => Http::response(['data' => ['result' => [ + ['name' => 'eth0', 'ip-addresses' => [['ip-address-type' => 'ipv4', 'ip-address' => '192.168.1.77']]], + ]]]), + ]); + + expect($this->server->provider()->isRunning())->toBeFalse() + ->and($this->server->refresh()->ip)->toBe(''); +}); + +test('delete proxmox server stops and destroys the vm', function () { + Http::fake([ + '*/api2/json/nodes/pve/qemu/101/config' => Http::response(['data' => [ + 'description' => 'Managed by Vito (server #'.$this->server->id.')', + ]]), + '*/api2/json/nodes/pve/qemu/101/status/current' => Http::response(['data' => ['status' => 'running']]), + '*/api2/json/nodes/pve/qemu/101/status/stop' => Http::response(['data' => 'UPID:pve:0004:qmstop:101:vito@pve!vito:']), + '*/api2/json/nodes/pve/tasks/*' => Http::response(['data' => ['status' => 'stopped', 'exitstatus' => 'OK']]), + '*/api2/json/nodes/pve/qemu/101?*' => Http::response(['data' => 'UPID:pve:0005:qmdestroy:101:vito@pve!vito:']), + ]); + + $this->server->provider()->delete(); + + Http::assertSent(fn (Request $request): bool => $request->method() === 'POST' + && str_ends_with($request->url(), '/qemu/101/status/stop')); + Http::assertSent(fn (Request $request): bool => $request->method() === 'DELETE' + && str_contains($request->url(), '/qemu/101?') + && $request['purge'] == 1 + && $request['destroy-unreferenced-disks'] == 1); +}); + +test('delete proxmox server leaves a vm it did not create untouched', function () { + Http::fake([ + '*/api2/json/nodes/pve/qemu/101/config' => Http::response(['data' => [ + 'description' => 'Managed by Vito (server #'.($this->server->id + 1).')', + ]]), + '*' => Http::response(['data' => null]), + ]); + + $this->server->provider()->delete(); + + Http::assertNotSent(fn (Request $request): bool => in_array($request->method(), ['POST', 'DELETE'], true)); +}); From 0040f5f1bc17da084dbd3a86b99ed362f6e66147 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sun, 27 Sep 2026 17:13:06 +0200 Subject: [PATCH 2/4] Fix pint style and address review feedback - Skip starting the Proxmox VM when it is already running, so retrying an install after the clone finished doesn't fail on a duplicate start - Shell-escape the key path passed to ssh-keygen - Clear the setup guide's copy-feedback timer on re-copy and unmount --- app/ServerProviders/Proxmox.php | 27 +++++++++++++------ app/Support/helpers.php | 4 +-- .../components/dialogs/setup-guide-dialog.tsx | 17 ++++++++++-- tests/Feature/ProxmoxProviderTest.php | 24 +++++++++++++++++ 4 files changed, 60 insertions(+), 12 deletions(-) diff --git a/app/ServerProviders/Proxmox.php b/app/ServerProviders/Proxmox.php index 81b77fce0..2bb8a5638 100644 --- a/app/ServerProviders/Proxmox.php +++ b/app/ServerProviders/Proxmox.php @@ -210,7 +210,7 @@ public function plans(?string $region): array 'disk' => $plan['disk'], ]), 'available' => ! isset($node['maxcpu'], $node['maxmem']) - || ($plan['cores'] <= $node['maxcpu'] && $plan['memory'] * 1024 ** 3 <= $node['maxmem']), + || ($plan['cores'] <= $node['maxcpu'] && $node['maxmem'] >= $plan['memory'] * 1024 ** 3), ]) ->all(); } @@ -300,9 +300,7 @@ public function isRunning(): bool return false; } - $status = $this->request('GET', $this->vmPath().'/status/current'); - - if (($status['status'] ?? null) !== 'running') { + if (! $this->vmRunning()) { return false; } @@ -351,9 +349,7 @@ private function destroyVm(): void throw new ServerProviderError('Proxmox VM '.$this->server->provider_data['vmid'].' was not created for this server.'); } - $status = $this->request('GET', $this->vmPath().'/status/current'); - - if (($status['status'] ?? null) === 'running') { + if ($this->vmRunning()) { $this->waitForTask((string) $this->request('POST', $this->vmPath().'/status/stop')); } @@ -369,6 +365,9 @@ private function marker(): string } /** + * Safe to repeat when an install is retried: the config and resize are idempotent + * and a VM that is already running is not started again. + * * @throws ServerProviderError * @throws ConnectionException */ @@ -392,11 +391,23 @@ private function provision(): void $this->request('PUT', $this->vmPath().'/config', $config); $this->resizeBootDisk($plan['disk']); - $this->waitForTask((string) $this->request('POST', $this->vmPath().'/status/start')); + + if (! $this->vmRunning()) { + $this->waitForTask((string) $this->request('POST', $this->vmPath().'/status/start')); + } $this->server->jsonForget('provider_data', 'task'); } + /** + * @throws ServerProviderError + * @throws ConnectionException + */ + private function vmRunning(): bool + { + return ($this->request('GET', $this->vmPath().'/status/current')['status'] ?? null) === 'running'; + } + /** * @throws ServerProviderError * @throws ConnectionException diff --git a/app/Support/helpers.php b/app/Support/helpers.php index 5ecc00089..27f76d295 100755 --- a/app/Support/helpers.php +++ b/app/Support/helpers.php @@ -23,10 +23,10 @@ function generate_public_key(string $privateKeyPath, string $publicKeyPath): voi */ function generate_key_pair(string $path): void { - exec("ssh-keygen -t ed25519 -m PEM -N '' -f {$path}"); + exec("ssh-keygen -t ed25519 -m PEM -N '' -f ".escapeshellarg($path)); if (! file_exists($path)) { - exec("ssh-keygen -t ed25519 -N '' -f {$path}"); + exec("ssh-keygen -t ed25519 -N '' -f ".escapeshellarg($path)); } chmod($path, 0400); diff --git a/resources/js/components/dialogs/setup-guide-dialog.tsx b/resources/js/components/dialogs/setup-guide-dialog.tsx index de09b6780..d8f8f96bb 100644 --- a/resources/js/components/dialogs/setup-guide-dialog.tsx +++ b/resources/js/components/dialogs/setup-guide-dialog.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { CheckIcon, CopyIcon } from 'lucide-react'; import { toast } from 'sonner'; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; @@ -19,13 +19,26 @@ export default function SetupGuideDialog({ steps: SetupGuideStep[]; }) { const [copied, setCopied] = useState(null); + const resetTimer = useRef | null>(null); + + useEffect( + () => () => { + if (resetTimer.current) { + clearTimeout(resetTimer.current); + } + }, + [], + ); const copy = (index: number, code: string) => { navigator.clipboard .writeText(code) .then(() => { setCopied(index); - setTimeout(() => setCopied(null), 2000); + if (resetTimer.current) { + clearTimeout(resetTimer.current); + } + resetTimer.current = setTimeout(() => setCopied(null), 2000); }) .catch(() => toast.error('Could not copy to clipboard')); }; diff --git a/tests/Feature/ProxmoxProviderTest.php b/tests/Feature/ProxmoxProviderTest.php index 450334afe..486bfd399 100644 --- a/tests/Feature/ProxmoxProviderTest.php +++ b/tests/Feature/ProxmoxProviderTest.php @@ -259,6 +259,7 @@ ]]) : Http::response(['data' => null]), '*/api2/json/nodes/pve/qemu/101/resize' => Http::response(['data' => 'UPID:pve:0002:resize:101:vito@pve!vito:']), + '*/api2/json/nodes/pve/qemu/101/status/current' => Http::response(['data' => ['status' => 'stopped']]), '*/api2/json/nodes/pve/qemu/101/status/start' => Http::response(['data' => 'UPID:pve:0003:qmstart:101:vito@pve!vito:']), ]); @@ -286,6 +287,29 @@ 'static ip' => [['static_ip' => '192.168.1.50/24', 'gateway' => '192.168.1.1'], 'ip=192.168.1.50/24,gw=192.168.1.1', false], ]); +test('proxmox does not start the vm again when provisioning is retried', function () { + $this->server->update([ + 'provider_data' => array_merge($this->server->provider_data, [ + 'task' => 'UPID:pve:0001:qmclone:9000:vito@pve!vito:', + ]), + ]); + + Http::fake([ + '*/api2/json/nodes/pve/tasks/*' => Http::response(['data' => ['status' => 'stopped', 'exitstatus' => 'OK']]), + '*/api2/json/nodes/pve/qemu/101/config' => fn (Request $request) => $request->method() === 'GET' + ? Http::response(['data' => ['scsi0' => 'local-lvm:vm-101-disk-0,size=80G']]) + : Http::response(['data' => null]), + '*/api2/json/nodes/pve/qemu/101/status/current' => Http::response(['data' => ['status' => 'running']]), + '*' => Http::response(['data' => null]), + ]); + + expect($this->server->provider()->isRunning())->toBeFalse() + ->and($this->server->refresh()->provider_data)->not->toHaveKey('task'); + + Http::assertNotSent(fn (Request $request): bool => str_ends_with($request->url(), '/status/start') + || str_ends_with($request->url(), '/resize')); +}); + test('proxmox fails the install when the clone task fails', function () { $this->server->update([ 'provider_data' => array_merge($this->server->provider_data, [ From 690bf1c0ffa19889bca1a5100dc143b525fe0110 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sun, 27 Sep 2026 17:21:41 +0200 Subject: [PATCH 3/4] Assert the Proxmox provisioning retry still applies the config --- tests/Feature/ProxmoxProviderTest.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/Feature/ProxmoxProviderTest.php b/tests/Feature/ProxmoxProviderTest.php index 486bfd399..9c2ab1471 100644 --- a/tests/Feature/ProxmoxProviderTest.php +++ b/tests/Feature/ProxmoxProviderTest.php @@ -306,6 +306,9 @@ expect($this->server->provider()->isRunning())->toBeFalse() ->and($this->server->refresh()->provider_data)->not->toHaveKey('task'); + Http::assertSent(fn (Request $request): bool => $request->method() === 'PUT' + && str_ends_with($request->url(), '/qemu/101/config') + && $request['ipconfig0'] === 'ip=dhcp'); Http::assertNotSent(fn (Request $request): bool => str_ends_with($request->url(), '/status/start') || str_ends_with($request->url(), '/resize')); }); From 0140551ed39e62732f98d9038763eedfbdca0415 Mon Sep 17 00:00:00 2001 From: Saeed Vaziry Date: Sun, 27 Sep 2026 17:28:46 +0200 Subject: [PATCH 4/4] Document Proxmox VE and per-provider setup in server providers docs --- docs/4.x/settings/server-providers.md | 156 ++++++++++++++++++++++++-- 1 file changed, 146 insertions(+), 10 deletions(-) diff --git a/docs/4.x/settings/server-providers.md b/docs/4.x/settings/server-providers.md index 2692a627c..fcbe14861 100644 --- a/docs/4.x/settings/server-providers.md +++ b/docs/4.x/settings/server-providers.md @@ -15,33 +15,169 @@ A connected provider is also used to discover the private networks your servers - Digital Ocean - Vultr - Hetzner +- Proxmox VE (self-hosted) - Custom (Bring your own provider) -## Required Permissions +## Connecting a Provider -Here you can see the required permissions for each provider's API Keys. +Go to **Settings → Server Providers** and click **Connect**. You can also connect one from the create-server form, with the button next to the **Provider** field. + +1. Choose the provider and give the connection a name. +2. Enter the credentials described for your provider below. +3. Choose whether the connection is global (see [Scope](#scope)). + +Vito checks the credentials before it saves the connection, so a wrong or under-privileged key is caught right away. + +## Provider Setup ### AWS -- AWS IAM users must have Programmatic API Access. -- AWS IAM users need to belong to a group with the `AmazonEC2FullAccess` managed policies. +1. In the AWS console, open **IAM → Users** and create a user for Vito. +2. Give the user the `AmazonEC2FullAccess` managed policy, either directly or through a group. +3. Open the user's **Security credentials** tab and create an access key. AWS asks what the key is for; choose the option for an application running outside AWS. +4. In Vito, enter the **Access Key** (the access key ID) and the **Secret**. -### Linode +For each server, Vito creates an SSH key pair and a security group in the region you pick. -- `Linodes` (Read/Write) +### Akamai (Linode) + +1. In Cloud Manager, open your profile menu and go to **API Tokens → Create a Personal Access Token**. +2. Set these scopes and leave the rest at **No Access**: + - **Linodes**: Read/Write + - **Account**: Read Only (Vito uses it to check the token) + - **VPCs**: Read Only, only needed for [Provider Networks](../networks/provider-networks.md) +3. Pick an expiry. When the token expires, Vito can no longer create or delete servers until you connect a new one. +4. In Vito, enter the token as **Token**. ### Digital Ocean -- `droplet`: create, read, update, delete -- `ssh_key`: create, read, update, delete +1. In the control panel, go to **API → Tokens → Generate New Token**. +2. Choose **Full Access**, or **Custom Scopes** with at least: + - `droplet`: create, read, delete + - `ssh_key`: create, read, delete + - `regions`, `sizes` and `image`: read + - `vpc`: read, only needed for [Provider Networks](../networks/provider-networks.md) +3. In Vito, enter the token as **Token**. ### Vultr -Personal Access Token allows full access on Vultr. +1. In the customer portal, go to **Account → API** and enable the API. The Personal Access Token it shows has full access to your account. +2. Under **Access Control**, add the public IP address of your Vito server, or allow all IPv4 addresses. Vultr rejects API requests from any address that isn't on this list, and Vito then can't connect. +3. In Vito, enter the token as **Token**. ### Hetzner -Read/Write API Token +1. In the Hetzner Cloud Console, open the project you want the servers in. +2. Go to **Security → API Tokens → Generate API token** and choose **Read & Write**. +3. In Vito, enter the token as **Token**. + +API tokens belong to one project, so servers are always created in the project that owns the token. Use one connection per project. + +### Proxmox VE + +Vito creates servers on your own Proxmox VE host or cluster by cloning a cloud-init template for the Ubuntu version you pick. It sets the VM's CPU, memory and disk to the plan, adds a new SSH key through cloud-init, boots it and installs it like any other server. + +#### Requirements + +- Proxmox VE 8.1 or newer. +- Vito can reach the Proxmox API, on port `8006` of your Proxmox host. +- Vito can reach your servers over SSH (port `22`) on the IP address they get. If Vito runs outside your network, for example on a cloud VPS, and your VMs only get private LAN addresses, Vito can't connect to them. Run Vito on the same network, or connect the networks with a VPN. + +#### 1. Create an API token + +Run these commands in the Proxmox host's shell. In the web UI, select the node and click **Shell**. + +```sh +pveum user add vito@pve --comment "VitoDeploy" +pveum acl modify / --users vito@pve --roles PVEVMAdmin,PVEDatastoreUser,PVESDNUser,PVEAuditor +pveum user token add vito@pve vito --privsep 0 +``` + +The last command prints the token. Keep the `full-tokenid` (`vito@pve!vito`) and the `value`, which is the token secret. Proxmox shows the secret only once. + +- `PVEVMAdmin`: clone templates, and configure, start, stop and delete VMs. +- `PVEDatastoreUser`: allocate disk space for the new VMs. +- `PVESDNUser`: attach the VMs to your network bridge. +- `PVEAuditor`: optional. Lets Vito read each node's CPU and memory, so plans that don't fit are greyed out. + +:::warning +If you create the token in the web UI instead (**Datacenter → Permissions → API Tokens → Add**), untick **Privilege Separation**. A token with privilege separation doesn't get its user's roles, so it can't see any VMs, and Vito shows **"The API token cannot see any VMs"** when you connect. To keep privilege separation, grant the roles above to the token itself under **Datacenter → Permissions → Add → API Token Permission**, on path `/`. +::: + +#### 2. Create a cloud-init template + +Create one template for each Ubuntu version you want to use. These commands build an Ubuntu 24.04 template with VM ID `9000`: + +```sh +cd /root +wget https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img + +# Only needed for DHCP, see below +apt install -y libguestfs-tools +virt-customize -a noble-server-cloudimg-amd64.img --install qemu-guest-agent + +qm create 9000 --name ubuntu-2404-cloud --memory 2048 --cores 2 --ostype l26 \ + --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-pci +qm set 9000 --scsi0 local-lvm:0,import-from=/root/noble-server-cloudimg-amd64.img +qm set 9000 --ide2 local-lvm:cloudinit --boot order=scsi0 --serial0 socket --vga serial0 --agent 1 +qm template 9000 +``` + +Change `9000` to any free VM ID, `vmbr0` to the bridge your servers should use, and `local-lvm` to your storage. Every server copies the template's network device, including its VLAN tag. For another Ubuntu version, repeat the commands with a different VM ID and replace `noble` with `jammy` (22.04) or `focal` (20.04). + +To check a template, run `qm config 9000`. The output should include `template: 1` and a cloud-init drive such as `ide2: local-lvm:vm-9000-cloudinit,media=cdrom`. + +:::warning +Build templates from Ubuntu's **cloud images**, as above. A VM installed from an Ubuntu ISO usually ignores the cloud-init settings from Proxmox, so Vito can't log in to servers cloned from it. +::: + +**DHCP or static IP.** Each server gets either a static IP you enter when you create it, or an address from your DHCP server. With DHCP, Vito reads the server's IP from the QEMU guest agent, which the cloud images don't include; that's what the `virt-customize` step installs. If you always use static IPs, you can skip it. + +**Clusters.** Each online node is a region in Vito. Proxmox can only clone a template to another node when the template is on shared storage, such as Ceph or NFS. With templates on local storage, create servers on the template's node. + +#### 3. Connect Proxmox to Vito + +| Field | Value | +| --- | --- | +| **API URL** | Your Proxmox address starting with `https://`, for example `https://192.168.1.10:8006`. Anything after the port is ignored. | +| **Disk Storage** | Optional. The storage for the servers' disks. Leave it empty to use the template's storage. | +| **API Token ID** | `vito@pve!vito` | +| **API Token Secret** | The token secret from step 1. | +| **Ubuntu … Template** | The VM ID of your template for each Ubuntu version. Leave the versions you don't use empty; at least one is required. | +| **Verify SSL certificate** | Turn this off if Proxmox still uses its default self-signed certificate. | + +When you connect, Vito checks that each mapped VM ID exists, is a QEMU template and has a cloud-init drive. The connection form also has a **View guide** button with the commands above, ready to copy. + +#### Creating servers on Proxmox + +When you [create a server](../servers/create.md) on a Proxmox connection: + +- **Region** is the Proxmox node. +- **Plan** is one of these sizes: + +| Plan | vCPU | Memory | Disk | +| --- | --- | --- | --- | +| xsmall | 1 | 1 GB | 25 GB | +| small | 1 | 2 GB | 50 GB | +| medium | 2 | 4 GB | 80 GB | +| large | 4 | 8 GB | 160 GB | +| xlarge | 8 | 16 GB | 320 GB | +| 2xlarge | 16 | 32 GB | 640 GB | + +- **Operating System** must be an Ubuntu version with a template on the connection. +- **Static IP (CIDR)** and **Gateway** set a static address, for example `192.168.1.50/24` and `192.168.1.1`. Leave both empty to use DHCP. + +Vito waits up to 10 minutes for the clone, the first boot and SSH. The VM gets `Managed by Vito (server #…)` in its **Notes**; keep that line, because Vito only deletes VMs that carry it. When you delete the server with **Delete from Vito and Proxmox VE**, Vito stops the VM and destroys it along with its disks. + +#### Troubleshooting + +- **"The API token cannot see any VMs"**: the token has privilege separation turned on; see the warning in step 1. +- **"VM … was not found or the API token cannot access it"**: check the VM ID, and that the roles are granted on `/`. +- **"VM … is not a QEMU template"**: convert the VM with `qm template `. Containers (LXC) aren't supported. +- **"Template … has no cloud-init drive"**: add one with `qm set --ide2 local-lvm:cloudinit`. +- **"Couldn't connect to proxmox"**: check the API URL and port are reachable from the Vito server and the token is right. For a self-signed certificate, turn off **Verify SSL certificate**. +- **"The server did not become reachable within 600 seconds"**: the VM was created but Vito couldn't log in over SSH. Open the VM's **Console** in Proxmox to see which IP it got, then check Vito can reach it on port 22, the guest agent is installed if you use DHCP, and a static IP and gateway match the bridge's network. +- **"Proxmox task failed" or "Proxmox API error"**: the rest of the message comes from Proxmox, for example a full storage or a node that can't reach the template's storage. ### Custom