diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md index 030289d6a..2156a598f 100644 --- a/.github/instructions/frontend.instructions.md +++ b/.github/instructions/frontend.instructions.md @@ -19,8 +19,8 @@ App-level dialogs are **not** mounted inline next to their trigger. They live in **The pieces:** - `resources/js/components/dialogs/registry.ts` — maps a typed key to a dialog component. - `resources/js/hooks/use-dialog.ts` — `useDialog()` returns `dialog..open(props)` / `.close()` with full prop typing. -- `resources/js/stores/dialog-store.ts` — Zustand store holding the single active dialog. -- `resources/js/components/dialogs/dialog-host.tsx` — renders the active dialog once, app-wide. +- `resources/js/stores/dialog-store.ts` — Zustand store holding the active dialog, plus an optional nested one on top of it. +- `resources/js/components/dialogs/dialog-host.tsx` — renders the active (and nested) dialog once, app-wide. **Opening a dialog:** ```tsx @@ -41,6 +41,11 @@ dialog.confirm.open({ }); ``` +**Opening a dialog on top of another — `openNested`:** `open()` replaces whatever dialog is showing. To open one over the current dialog without closing it (e.g. a guide from inside a form dialog, so unsaved input survives), use `dialog..openNested(props)`. The nested dialog closes itself through its own `onOpenChange` and returns to the one underneath; `dialog..close()` and closing the underlying dialog close both. +```tsx +dialog.setupGuide.openNested({ title: 'Ubuntu 26.04 Template', steps }); +``` + **Opening from a dropdown — this is the whole point of the pattern:** use a plain `DropdownMenuItem` with the default `onSelect` so the menu closes, then open the dialog. **Never** wrap a ``/`` inside a `DropdownMenuItem` with `onSelect={(e) => e.preventDefault()}` — that leaves the dropdown stuck open behind the dialog. ```tsx dialog.editHostedDomain.open({ hostedDomain })}>Edit diff --git a/app/Actions/Server/CreateServer.php b/app/Actions/Server/CreateServer.php index 84ca85e66..727bd0c22 100755 --- a/app/Actions/Server/CreateServer.php +++ b/app/Actions/Server/CreateServer.php @@ -2,6 +2,7 @@ namespace App\Actions\Server; +use App\Enums\OperatingSystem; use App\Jobs\Server\InstallJob; use App\Models\Project; use App\Models\Server; @@ -9,6 +10,7 @@ use App\Models\User; use App\ServerProviders\Custom; use App\ValidationRules\RestrictedIPAddressesRule; +use App\ValidationRules\ServiceVersionAvailableRule; use Exception; use Illuminate\Database\Query\Builder; use Illuminate\Support\Facades\Validator; @@ -137,6 +139,7 @@ private function validate(Project $project, array $input): void 'services.*.version' => [ 'string', Rule::in(collect(config('service.services'))->pluck('versions')->flatten()->toArray()), + new ServiceVersionAvailableRule(is_string($input['os'] ?? null) ? OperatingSystem::tryFrom($input['os']) : null), ], ]; diff --git a/app/Actions/ServerProvider/EditServerProvider.php b/app/Actions/ServerProvider/EditServerProvider.php index de57ec8f5..cda78d069 100644 --- a/app/Actions/ServerProvider/EditServerProvider.php +++ b/app/Actions/ServerProvider/EditServerProvider.php @@ -6,20 +6,35 @@ use App\Events\SocketEvent; use App\Http\Resources\ServerProviderResource; use App\Models\ServerProvider; +use App\ServerProviders\HasEditableCredentials; +use Exception; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\ValidationException; class EditServerProvider { /** * @param array $input + * + * @throws ValidationException */ public function edit(ServerProvider $serverProvider, array $input, ?int $projectId): ServerProvider { - Validator::make($input, [ + $provider = $serverProvider->editableProvider(); + + Validator::make($input, array_merge([ 'name' => [ 'required', ], - ])->validate(); + ], $provider?->editRules($input) ?? []))->validate(); + + $credentials = $provider?->editCredentials($input) ?? $serverProvider->credentials; + + if ($provider && $credentials !== $serverProvider->credentials) { + $this->verify($serverProvider, $provider, $credentials, $input); + $serverProvider->credentials = $credentials; + } $serverProvider->profile = $input['name']; $serverProvider->project_id = $projectId; @@ -34,4 +49,42 @@ public function edit(ServerProvider $serverProvider, array $input, ?int $project return $serverProvider; } + + /** + * Errors on fields the edit form doesn't render are reported on `provider`, + * so they are still shown to the user. + * + * @param array $credentials + * @param array $input + * + * @throws ValidationException + */ + private function verify(ServerProvider $serverProvider, HasEditableCredentials $provider, array $credentials, array $input): void + { + try { + $provider->connect($credentials); + } catch (ValidationException $e) { + $fields = array_keys($provider->editRules($input)); + $errors = []; + + foreach ($e->errors() as $field => $messages) { + $key = in_array($field, $fields, true) ? $field : 'provider'; + $errors[$key] = [...($errors[$key] ?? []), ...$messages]; + } + + throw ValidationException::withMessages($errors); + } catch (Exception $e) { + Log::error('Failed to verify server provider credentials', [ + 'server_provider_id' => $serverProvider->id, + 'provider' => $serverProvider->provider, + 'exception' => $e::class, + ]); + + throw ValidationException::withMessages([ + 'provider' => [ + sprintf("Couldn't connect to %s. Please check your credentials.", $serverProvider->provider), + ], + ]); + } + } } diff --git a/app/Actions/Service/Install.php b/app/Actions/Service/Install.php index ffb1d9e63..c5d790007 100644 --- a/app/Actions/Service/Install.php +++ b/app/Actions/Service/Install.php @@ -7,6 +7,7 @@ use App\Jobs\Service\InstallJob; use App\Models\Server; use App\Models\Service; +use App\ValidationRules\ServiceVersionAvailableRule; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rule; @@ -19,7 +20,7 @@ class Install */ public function install(Server $server, array $input): Service { - $this->validate($input); + $this->validate($server, $input); $name = $input['name']; $input['type'] = config("service.services.$name.type"); @@ -48,7 +49,7 @@ public function install(Server $server, array $input): Service return $service; } - private function validate(array $input): void + private function validate(Server $server, array $input): void { $installable = collect(config('service.services')) ->reject(fn (array $service): bool => ($service['type'] ?? null) === 'vpn') @@ -62,6 +63,7 @@ private function validate(array $input): void ], 'version' => [ 'required', + new ServiceVersionAvailableRule($server->os), ], ]; if (isset($input['name'])) { diff --git a/app/DTOs/DynamicField.php b/app/DTOs/DynamicField.php index 6e82e1e22..5822481fa 100644 --- a/app/DTOs/DynamicField.php +++ b/app/DTOs/DynamicField.php @@ -90,6 +90,17 @@ public function alert(): self public function guide(array $steps): self { $this->type = 'guide'; + + return $this->withGuide($steps); + } + + /** + * Adds a button inside the input that opens a step-by-step guide. + * + * @param array $steps + */ + public function withGuide(array $steps): self + { $this->componentProps = ['steps' => $steps]; return $this; diff --git a/app/Enums/OperatingSystem.php b/app/Enums/OperatingSystem.php index 659b8453c..aaa9aab0a 100644 --- a/app/Enums/OperatingSystem.php +++ b/app/Enums/OperatingSystem.php @@ -10,6 +10,7 @@ enum OperatingSystem: string implements VitoEnum case UBUNTU20 = 'ubuntu_20'; case UBUNTU22 = 'ubuntu_22'; case UBUNTU24 = 'ubuntu_24'; + case UBUNTU26 = 'ubuntu_26'; public function getColor(): string { @@ -28,6 +29,18 @@ public function getVersion(): string self::UBUNTU20 => '20.04', self::UBUNTU22 => '22.04', self::UBUNTU24 => '24.04', + self::UBUNTU26 => '26.04', + }; + } + + public function getCodename(): string + { + return match ($this) { + self::UBUNTU18 => 'bionic', + self::UBUNTU20 => 'focal', + self::UBUNTU22 => 'jammy', + self::UBUNTU24 => 'noble', + self::UBUNTU26 => 'resolute', }; } } diff --git a/app/Http/Resources/ServerProviderResource.php b/app/Http/Resources/ServerProviderResource.php index fcd948d20..e3c439321 100644 --- a/app/Http/Resources/ServerProviderResource.php +++ b/app/Http/Resources/ServerProviderResource.php @@ -21,6 +21,7 @@ public function toArray(Request $request): array 'global' => is_null($this->project_id), 'name' => $this->profile, 'provider' => $this->provider, + 'editable_data' => $this->editableDataFor($request->user()), 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/app/Models/ServerProvider.php b/app/Models/ServerProvider.php index 989c155a2..9885d0350 100644 --- a/app/Models/ServerProvider.php +++ b/app/Models/ServerProvider.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\ServerProviders\HasEditableCredentials; use App\Traits\HasProjectScopedQueries; use Database\Factories\ServerProviderFactory; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -77,6 +78,30 @@ public function provider(): \App\ServerProviders\ServerProvider return $provider; } + /** + * The handler, if users can edit this connection's credentials. The class is + * checked first, so providers whose plugin is gone never build a missing handler. + */ + public function editableProvider(): ?HasEditableCredentials + { + if (! is_a((string) config('server-provider.providers.'.$this->provider.'.handler'), HasEditableCredentials::class, true)) { + return null; + } + + $provider = $this->provider(); + + return $provider instanceof HasEditableCredentials ? $provider : null; + } + + public function editableDataFor(?User $user): object + { + if (! $user?->can('revealCredentials', $this)) { + return (object) []; + } + + return (object) ($this->editableProvider()?->editableData() ?? []); + } + /** * @return BelongsTo */ diff --git a/app/Plugins/RegisterServerProvider.php b/app/Plugins/RegisterServerProvider.php index b50689057..110990a23 100644 --- a/app/Plugins/RegisterServerProvider.php +++ b/app/Plugins/RegisterServerProvider.php @@ -14,6 +14,7 @@ public function __construct( private string $defaultUser = '', private ?DynamicForm $createForm = null, private ?int $provisionTimeout = null, + private ?DynamicForm $editForm = null, ) {} public static function make(string $name): self @@ -59,6 +60,17 @@ public function createForm(DynamicForm $createForm): self return $this; } + /** + * Credential fields users can change after connecting, rendered in the + * connection's edit form. Never include secrets here. + */ + public function editForm(DynamicForm $editForm): self + { + $this->editForm = $editForm; + + return $this; + } + public function defaultUser(string $defaultUser): self { $this->defaultUser = $defaultUser; @@ -87,6 +99,7 @@ public function register(): void 'form' => $this->form ? $this->form->toArray() : [], 'default_user' => $this->defaultUser, 'create_form' => $this->createForm ? $this->createForm->toArray() : [], + 'edit_form' => $this->editForm ? $this->editForm->toArray() : [], 'provision_timeout' => $this->provisionTimeout, ]; diff --git a/app/Plugins/RegisterServiceType.php b/app/Plugins/RegisterServiceType.php index a7f78ebd4..49ec0e099 100644 --- a/app/Plugins/RegisterServiceType.php +++ b/app/Plugins/RegisterServiceType.php @@ -11,6 +11,7 @@ class RegisterServiceType * @param array $versions * @param array $data * @param array $configPaths + * @param array> $unavailableVersions */ public function __construct( private string $name, @@ -21,7 +22,8 @@ public function __construct( private ?DynamicForm $form = null, private array $versions = ['latest'], private array $data = [], - private array $configPaths = [] + private array $configPaths = [], + private array $unavailableVersions = [] ) {} public static function make(string $name): self @@ -81,6 +83,19 @@ public function versions(array $versions): self return $this; } + /** + * Versions that can't be installed on an operating system, keyed by its + * OperatingSystem value. + * + * @param array> $unavailableVersions + */ + public function unavailableVersions(array $unavailableVersions): self + { + $this->unavailableVersions = $unavailableVersions; + + return $this; + } + /** * @param array $data */ @@ -116,6 +131,7 @@ public function register(): void 'handler' => $this->handler, 'form' => $this->form ? $this->form->toArray() : [], 'versions' => $this->versions, + 'unavailable_versions' => $this->unavailableVersions, 'data' => $this->data, 'config_paths' => $this->configPaths, ]; diff --git a/app/Policies/ServerProviderPolicy.php b/app/Policies/ServerProviderPolicy.php index 7037d927d..2e03f6521 100644 --- a/app/Policies/ServerProviderPolicy.php +++ b/app/Policies/ServerProviderPolicy.php @@ -2,10 +2,12 @@ namespace App\Policies; +use App\Models\PersonalAccessToken; use App\Models\ServerProvider; use App\Models\User; use App\Traits\ChecksTokenProjectScope; use Illuminate\Auth\Access\HandlesAuthorization; +use Laravel\Sanctum\TransientToken; class ServerProviderPolicy { @@ -34,6 +36,22 @@ public function update(User $user, ServerProvider $serverProvider): bool && $user->tokenAllowsProject($serverProvider->project_id, write: true); } + /** + * Non-secret credential values are only for callers who can already + * rewrite them. API tokens must additionally carry the write ability. + */ + public function revealCredentials(User $user, ServerProvider $serverProvider): bool + { + /** @var PersonalAccessToken|TransientToken|null $token */ + $token = $user->currentAccessToken(); + + if ($token !== null && ! $token->can('write')) { + return false; + } + + return $this->update($user, $serverProvider); + } + public function delete(User $user, ServerProvider $serverProvider): bool { return $user->id === $serverProvider->user_id diff --git a/app/Providers/ServerProviderServiceProvider.php b/app/Providers/ServerProviderServiceProvider.php index 3d16a4bd1..536c34bce 100644 --- a/app/Providers/ServerProviderServiceProvider.php +++ b/app/Providers/ServerProviderServiceProvider.php @@ -152,14 +152,7 @@ private function proxmox(): void ->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'), - ), + ...$this->proxmoxTemplateFields(), DynamicField::make('verify_ssl') ->checkbox() ->label('Verify SSL certificate') @@ -181,11 +174,32 @@ private function proxmox(): void ->placeholder('192.168.1.1'), ]) ) + ->editForm(DynamicForm::make($this->proxmoxTemplateFields())) ->defaultUser('root') ->provisionTimeout(600) ->register(); } + /** + * @return array + */ + private function proxmoxTemplateFields(): array + { + return array_map( + function (string $value): DynamicField { + $os = OperatingSystem::from($value); + + return DynamicField::make(Proxmox::templateField($value)) + ->text() + ->half() + ->label('Ubuntu '.$os->getVersion().' Template') + ->placeholder('VMID') + ->withGuide($this->proxmoxTemplateGuide($os)); + }, + config('core.operating_systems'), + ); + } + /** * @return array */ @@ -198,23 +212,45 @@ private function proxmoxGuide(): array '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' => 'Build the templates', + 'description' => 'Create a cloud-init template for each Ubuntu version you want to use. The guide button in each Ubuntu template field shows the commands for that version.', + ], + [ + 'title' => 'Connect Vito', + 'description' => 'Use https://your-host:8006 as the API URL, turn off Verify SSL for a self-signed certificate, and enter the VM ID of each template you built. Vito must be able to reach the new servers over SSH.', + ], + ]; + } + + /** + * @return array + */ + private function proxmoxTemplateGuide(OperatingSystem $os): array + { + $version = $os->getVersion(); + $image = $os->getCodename().'-server-cloudimg-amd64.img'; + $vmid = 9000 + (int) $version; + + return [ + [ + 'title' => "Download the Ubuntu {$version} cloud image", + 'description' => 'Run these commands in the Proxmox host shell. Use an official cloud image, not an ISO install.', + 'code' => "cd /root\nwget https://cloud-images.ubuntu.com/{$os->getCodename()}/current/{$image}", ], [ '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", + 'code' => "apt install -y libguestfs-tools\nvirt-customize -a {$image} --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", + 'description' => "Change vmbr0 and local-lvm if your bridge or storage is named differently, and {$vmid} if that VM ID is taken. Vito grows the disk to the plan size when it creates a server.", + 'code' => "qm create {$vmid} --name ubuntu-".str_replace('.', '', $version)."-cloud --memory 2048 --cores 2 --ostype l26 --net0 virtio,bridge=vmbr0 --scsihw virtio-scsi-pci\nqm set {$vmid} --scsi0 local-lvm:0,import-from=/root/{$image}\nqm set {$vmid} --ide2 local-lvm:cloudinit --boot order=scsi0 --serial0 socket --vga serial0 --agent 1\nqm template {$vmid}", ], [ - '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.', + 'title' => 'Check the template', + 'description' => "The output should include template: 1 and a cloud-init drive. Then enter {$vmid} in this field.", + 'code' => "qm config {$vmid}", ], ]; } diff --git a/app/Providers/ServiceTypeServiceProvider.php b/app/Providers/ServiceTypeServiceProvider.php index c7aefae5e..d3be7760c 100644 --- a/app/Providers/ServiceTypeServiceProvider.php +++ b/app/Providers/ServiceTypeServiceProvider.php @@ -2,6 +2,7 @@ namespace App\Providers; +use App\Enums\OperatingSystem; use App\Plugins\RegisterServiceType; use App\Services\Database\Mariadb; use App\Services\Database\Mysql; @@ -132,6 +133,9 @@ private function databases(): void '11.4', '10.11', ]) + ->unavailableVersions([ + OperatingSystem::UBUNTU26->value => ['11.4', '10.11'], + ]) ->configPaths([ [ 'name' => 'my.cnf', diff --git a/app/ServerProviders/HasEditableCredentials.php b/app/ServerProviders/HasEditableCredentials.php new file mode 100644 index 000000000..02fa2f771 --- /dev/null +++ b/app/ServerProviders/HasEditableCredentials.php @@ -0,0 +1,32 @@ + + */ + public function editableData(): array; + + /** + * @param array $input + * @return array + */ + public function editRules(array $input): array; + + /** + * The stored credentials with the edit form's changes merged in. + * + * @param array $input + * @return array + */ + public function editCredentials(array $input): array; +} diff --git a/app/ServerProviders/Proxmox.php b/app/ServerProviders/Proxmox.php index 2bb8a5638..075c0ae8d 100644 --- a/app/ServerProviders/Proxmox.php +++ b/app/ServerProviders/Proxmox.php @@ -12,13 +12,14 @@ use Exception; use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\Response; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Http; use Illuminate\Support\Sleep; use Illuminate\Support\Str; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; -class Proxmox extends AbstractProvider +class Proxmox extends AbstractProvider implements HasEditableCredentials { /** * Memory and disk are in GiB. @@ -108,12 +109,43 @@ public function credentialData(array $input): array ]; foreach (self::templateFields() as $field) { - $data[$field] = empty($input[$field]) ? null : (int) $input[$field]; + $data[$field] = self::templateVmid($input[$field] ?? null); } return $data; } + public function editableData(): array + { + return collect(self::templateFields()) + ->mapWithKeys(fn (string $field): array => [$field => $this->serverProvider->credentials[$field] ?? null]) + ->all(); + } + + public function editRules(array $input): array + { + return Arr::only($this->credentialValidationRules($input), self::templateFields()); + } + + public function editCredentials(array $input): array + { + $credentials = $this->serverProvider->credentials; + + foreach (self::templateFields() as $field) { + if (! array_key_exists($field, $input)) { + continue; + } + + $vmid = self::templateVmid($input[$field]); + + if ($vmid !== ($credentials[$field] ?? null)) { + $credentials[$field] = $vmid; + } + } + + return $credentials; + } + public function data(array $input): array { return [ @@ -586,6 +618,11 @@ private static function baseUrl(string $url): string return 'https://'.($parts['host'] ?? '').(isset($parts['port']) ? ':'.$parts['port'] : ''); } + private static function templateVmid(mixed $value): ?int + { + return empty($value) ? null : (int) $value; + } + private function errorMessage(Response $response): string { $errors = collect((array) $response->json('errors')) diff --git a/app/Services/PHP/PHP.php b/app/Services/PHP/PHP.php index c990107bc..14a87159e 100644 --- a/app/Services/PHP/PHP.php +++ b/app/Services/PHP/PHP.php @@ -69,6 +69,7 @@ public function install(): void view('ssh.services.php.install-php', [ 'version' => $this->service->version, 'user' => $server->getSshUser(), + 'useSury' => $this->usesSuryRepository(), ]), 'install-php-'.$this->service->version ); @@ -230,4 +231,13 @@ public function logs(): array return $logs; } + + /** + * Ondřej's Launchpad PPA stops at Ubuntu 24.04; later releases get PHP from + * its successor, packages.sury.org. + */ + private function usesSuryRepository(): bool + { + return version_compare($this->service->server->os->getVersion(), '26.04', '>='); + } } diff --git a/app/Tables/ServerProviderTable.php b/app/Tables/ServerProviderTable.php index 8c910a620..ca3cfa1ab 100644 --- a/app/Tables/ServerProviderTable.php +++ b/app/Tables/ServerProviderTable.php @@ -32,6 +32,7 @@ protected function columns(): array ->accessor('project_id') ->sortable(), Column::data('global', fn ($m) => $m->project_id === null), + Column::data('editable_data', fn ($m) => $m->editableDataFor(user())), Column::data('global_color', fn ($m) => $m->project_id === null ? 'success' : 'danger'), DateTimeColumn::make('created_at', 'Created at')->sortable()->toLocal(), ActionsColumn::make(), diff --git a/app/ValidationRules/ServiceVersionAvailableRule.php b/app/ValidationRules/ServiceVersionAvailableRule.php new file mode 100644 index 000000000..e9a05e52d --- /dev/null +++ b/app/ValidationRules/ServiceVersionAvailableRule.php @@ -0,0 +1,53 @@ + + */ + private array $data = []; + + public function __construct(private ?OperatingSystem $os) {} + + /** + * @param array $data + */ + public function setData(array $data): static + { + $this->data = $data; + + return $this; + } + + public function validate(string $attribute, mixed $value, Closure $fail): void + { + $service = data_get($this->data, Str::replaceLast('version', 'name', $attribute)); + + if (! $this->os || ! is_scalar($value) || ! is_string($service) || ! array_key_exists($service, config('service.services'))) { + return; + } + + $version = (string) $value; + + if (in_array($version, (array) config("service.services.$service.unavailable_versions.{$this->os->value}"), true)) { + $fail(':service :version is not available on Ubuntu :os.')->translate([ + 'service' => config("service.services.$service.label"), + 'version' => $version, + 'os' => $this->os->getVersion(), + ]); + } + } +} diff --git a/config/core.php b/config/core.php index d1c8b38ee..a66f79e53 100755 --- a/config/core.php +++ b/config/core.php @@ -59,6 +59,7 @@ OperatingSystem::UBUNTU20->value, OperatingSystem::UBUNTU22->value, OperatingSystem::UBUNTU24->value, + OperatingSystem::UBUNTU26->value, ], /* diff --git a/config/serverproviders.php b/config/serverproviders.php index b81df1f69..71a21160a 100644 --- a/config/serverproviders.php +++ b/config/serverproviders.php @@ -488,6 +488,7 @@ 'ubuntu_20' => 'linode/ubuntu20.04', 'ubuntu_22' => 'linode/ubuntu22.04', 'ubuntu_24' => 'linode/ubuntu24.04', + 'ubuntu_26' => 'linode/ubuntu26.04', ], ], 'digitalocean' => [ @@ -945,6 +946,7 @@ 'ubuntu_20' => 'ubuntu-20.04', 'ubuntu_22' => 'ubuntu-22.04', 'ubuntu_24' => 'ubuntu-24.04', + 'ubuntu_26' => 'ubuntu-26.04', ], ], ]; diff --git a/docs/4.x/plugins.md b/docs/4.x/plugins.md index 9a0dc647d..767cd8b16 100644 --- a/docs/4.x/plugins.md +++ b/docs/4.x/plugins.md @@ -333,6 +333,15 @@ RegisterServiceType::make(Nginx::id()) ->register(); ``` +If some versions can't be installed on an operating system, list them with `unavailableVersions()`, keyed by the +operating system. Vito then rejects those versions on servers running it: + +```php +->unavailableVersions([ + \App\Enums\OperatingSystem::UBUNTU26->value => ['11.4', '10.11'], +]) +``` + **Service Types:** Vito already supports multiple service types, and you can create alternatives for them. @@ -409,7 +418,12 @@ You can register your own server provider using the `boot` method in your `Plugi ``` The handler must implement the `App\ServerProviders\ServerProvider` interface or extend the -`App\ServerProviders\AbstractServerProvider` class. +`App\ServerProviders\AbstractProvider` class. + +To let users change some credentials after connecting, register an edit form with `->editForm(DynamicForm::make([...]))` +and implement the opt-in `App\ServerProviders\HasEditableCredentials` interface in the handler. Vito merges the changes +into the stored credentials and calls `connect()` again before saving. Never put secrets in the edit form. To add a +step-by-step guide button inside a field, use `->withGuide($steps)`. :::info You can find plenty of examples in diff --git a/docs/4.x/servers/create.md b/docs/4.x/servers/create.md index 36fa8da69..5ad11f988 100644 --- a/docs/4.x/servers/create.md +++ b/docs/4.x/servers/create.md @@ -40,7 +40,7 @@ If you've selected a cloud server provider, you will need to fill in the form wi The rest of the fields are: - **Server Name**: The name of your server (must be unique among your current project). -- **Operating System**: The operating system of your server (Ubuntu 20.04, 22.04, or 24.04). +- **Operating System**: The operating system of your server (Ubuntu 20.04, 22.04, 24.04, or 26.04). - **SSH IP**: The IP address of your server (if you are using a custom server provider). - **SSH Port**: The SSH port of your server (if you are using a custom server provider). diff --git a/docs/4.x/servers/database.md b/docs/4.x/servers/database.md index f35e4f9f4..0f3f5d829 100644 --- a/docs/4.x/servers/database.md +++ b/docs/4.x/servers/database.md @@ -19,6 +19,10 @@ link users to databases. - PostgreSQL 17 - PostgreSQL 18 +:::info +MariaDB 10.11 and 11.4 are not available on Ubuntu 26.04. +::: + ## Install database service To install a database, you can select the database type and version during the server creation. diff --git a/docs/4.x/servers/php.md b/docs/4.x/servers/php.md index 2bb957a67..d40ff7053 100644 --- a/docs/4.x/servers/php.md +++ b/docs/4.x/servers/php.md @@ -20,6 +20,11 @@ server creation in the `PHP` menu in the server page or in the [Services](./serv - PHP 8.4 - PHP 8.5 +:::info +Vito installs PHP from the `ondrej/php` Launchpad PPA on Ubuntu 20.04 to 24.04, and from +[packages.sury.org](https://packages.sury.org/php/) on Ubuntu 26.04, where the PPA has no packages. +::: + ## Install and Uninstall Vito gives you the option to easily install and uninstall different PHP versions. @@ -76,14 +81,14 @@ error like this: E: Repository 'https://ppa.launchpadcontent.net/ondrej/php/ubuntu noble InRelease' changed its 'Label' value from 'PPA for PHP' to 'Use packages.sury.org/php instead' ``` -Vito installs PHP from the `ondrej/php` Launchpad PPA. That PPA is being merged into +On Ubuntu 20.04 to 24.04, Vito installs PHP from the `ondrej/php` Launchpad PPA. That PPA is being merged into [packages.sury.org](https://packages.sury.org/php/), and as part of the move it changed the `Label` field in its release metadata to announce this. APT treats a changed `Label` (along with `Origin`, `Suite`, or `Version`) as a potential security concern and refuses to refresh that repository's package list unless you explicitly allow it — which aborts the whole `apt-get update`, and with it the PHP install or server update that triggered it. -Vito keeps installing PHP from the `ondrej/php` PPA (the `packages.sury.org` mirror doesn't yet +On those releases, Vito keeps installing PHP from the `ondrej/php` PPA (the `packages.sury.org` mirror doesn't yet carry every extension package Vito needs, such as `php-redis`, for all PHP versions), and now passes `-o Acquire::AllowReleaseInfoChange::Label=true` to `apt-get update` so this specific, expected label change no longer blocks installs or updates. diff --git a/docs/4.x/settings/server-providers.md b/docs/4.x/settings/server-providers.md index fcbe14861..77c422e55 100644 --- a/docs/4.x/settings/server-providers.md +++ b/docs/4.x/settings/server-providers.md @@ -123,7 +123,7 @@ qm set 9000 --ide2 local-lvm:cloudinit --boot order=scsi0 --serial0 socket --vga 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). +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 `resolute` (26.04), `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`. @@ -146,7 +146,9 @@ Build templates from Ubuntu's **cloud images**, as above. A VM installed from an | **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. +When you connect, Vito checks that each mapped VM ID exists, is a QEMU template and has a cloud-init drive. Each **Ubuntu … Template** field has a guide button with the commands for that Ubuntu version, ready to copy. + +To add or change templates later, choose **Edit** on the connection. When a template changes, Vito checks all the mapped templates again before saving. #### Creating servers on Proxmox @@ -185,7 +187,7 @@ If your server provider is not listed here, you can use the `Custom` provider wh Your server must have the following requirements so Vito can provision it: -- The server must be running a fresh installation of Ubuntu 20.04, 22.04, or 24.04 x64. +- The server must be running a fresh installation of Ubuntu 20.04, 22.04, 24.04, or 26.04 x64. - The server must be accessible externally over the Internet. - The server must have root SSH access enabled. - The server requirements should meet the following criteria or more: 1 CPU Core with 1GHz, 1GB RAM, and 10GB Disk space. diff --git a/public/api-docs/openapi/schemas/Server.yaml b/public/api-docs/openapi/schemas/Server.yaml index 1385e238b..a77b88f01 100644 --- a/public/api-docs/openapi/schemas/Server.yaml +++ b/public/api-docs/openapi/schemas/Server.yaml @@ -43,7 +43,7 @@ properties: example: 22 os: type: string - enum: ['ubuntu_18', 'ubuntu_20', 'ubuntu_22', 'ubuntu_24'] + enum: ['ubuntu_18', 'ubuntu_20', 'ubuntu_22', 'ubuntu_24', 'ubuntu_26'] example: 'ubuntu_22' provider: type: string diff --git a/public/api-docs/openapi/schemas/ServerProvider.yaml b/public/api-docs/openapi/schemas/ServerProvider.yaml index a6b9dd5c3..4faee6d41 100644 --- a/public/api-docs/openapi/schemas/ServerProvider.yaml +++ b/public/api-docs/openapi/schemas/ServerProvider.yaml @@ -20,6 +20,15 @@ properties: provider: type: string example: 'digitalocean' + editable_data: + type: object + additionalProperties: true + description: 'Non-sensitive provider credentials that can be edited (currently the Proxmox template VM IDs). Secrets are never included. The object is empty unless the provider supports editing and the caller owns it and, for API tokens, the token also carries the write ability.' + example: + template_ubuntu_20: null + template_ubuntu_22: null + template_ubuntu_24: 9024 + template_ubuntu_26: 9026 created_at: type: string format: date-time diff --git a/public/api-docs/openapi/server-providers.yaml b/public/api-docs/openapi/server-providers.yaml index 4b754f053..0838f1caa 100644 --- a/public/api-docs/openapi/server-providers.yaml +++ b/public/api-docs/openapi/server-providers.yaml @@ -87,7 +87,7 @@ paths: example: 'digitalocean' credentials: type: object - 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).' + 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`, `template_ubuntu_26` (cloud-init template VMIDs).' example: token: 'dop_v1_xxxxxxxxxxxx' global: @@ -178,7 +178,7 @@ paths: put: summary: Update server provider - description: "DEPRECATED: Use /api/server-providers/{serverProvider} instead. This endpoint will be removed in a future version." + description: "DEPRECATED: Use /api/server-providers/{serverProvider} instead. This endpoint will be removed in a future version. Update a server provider. Proxmox connections also accept `template_ubuntu_20`, `template_ubuntu_22`, `template_ubuntu_24` and `template_ubuntu_26` (cloud-init template VM IDs); omitted fields keep their current value, and when a template changes all mapped templates are re-verified against Proxmox before saving. Other credentials, including the API token, can't be changed." deprecated: true tags: - Server Providers @@ -216,6 +216,11 @@ paths: type: boolean description: Whether this provider should be available globally (not tied to current project) example: false + additionalProperties: true + example: + name: 'Proxmox' + global: false + template_ubuntu_26: 9026 responses: '200': description: Server provider updated successfully diff --git a/public/api-docs/openapi/servers.yaml b/public/api-docs/openapi/servers.yaml index 6a45e395a..7fa6410fc 100644 --- a/public/api-docs/openapi/servers.yaml +++ b/public/api-docs/openapi/servers.yaml @@ -85,7 +85,7 @@ paths: example: 'production-server' os: type: string - enum: ['ubuntu_20', 'ubuntu_22', 'ubuntu_24'] + enum: ['ubuntu_20', 'ubuntu_22', 'ubuntu_24', 'ubuntu_26'] description: Operating system example: 'ubuntu_22' server_provider: @@ -136,7 +136,7 @@ paths: example: 'php' version: type: string - description: Service version + description: Service version. Some versions are not available on every operating system (MariaDB 10.11 and 11.4 on ubuntu_26). example: '8.2' example: - type: 'php' diff --git a/public/api-docs/openapi/user-server-providers.yaml b/public/api-docs/openapi/user-server-providers.yaml index ecf7ebd48..cbe0944c9 100644 --- a/public/api-docs/openapi/user-server-providers.yaml +++ b/public/api-docs/openapi/user-server-providers.yaml @@ -63,7 +63,7 @@ paths: example: 'digitalocean' credentials: type: object - 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).' + 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`, `template_ubuntu_26` (cloud-init template VMIDs).' example: token: 'dop_v1_xxxxxxxxxxxx' global: @@ -140,7 +140,7 @@ paths: put: summary: Update server provider - description: Update a server provider + description: 'Update a server provider. Proxmox connections also accept `template_ubuntu_20`, `template_ubuntu_22`, `template_ubuntu_24` and `template_ubuntu_26` (cloud-init template VM IDs); omitted fields keep their current value, and when a template changes all mapped templates are re-verified against Proxmox before saving. Other credentials, including the API token, can''t be changed.' tags: - User Server Providers security: @@ -168,6 +168,11 @@ paths: type: boolean description: Whether this provider should be available globally (not tied to current project) example: false + additionalProperties: true + example: + name: 'Proxmox' + global: false + template_ubuntu_26: 9026 responses: '200': description: Server provider updated successfully diff --git a/resources/js/components/dialogs/dialog-host.tsx b/resources/js/components/dialogs/dialog-host.tsx index dd3bf682c..8b35f7bc0 100644 --- a/resources/js/components/dialogs/dialog-host.tsx +++ b/resources/js/components/dialogs/dialog-host.tsx @@ -1,28 +1,37 @@ import { useEffect } from 'react'; import type { ComponentType } from 'react'; import { router } from '@inertiajs/react'; -import { useDialogStore } from '@/stores/dialog-store'; +import { useDialogStore, type ActiveDialog } from '@/stores/dialog-store'; import { dialogs, type DialogControlProps } from './registry'; -export default function DialogHost() { - const active = useDialogStore((s) => s.active); - const instanceId = useDialogStore((s) => s.instanceId); - - useEffect(() => { - return router.on('navigate', () => useDialogStore.getState().close()); - }, []); - - if (!active) { +function HostedDialog({ dialog, id, onClose }: { dialog: ActiveDialog | null; id: number; onClose: () => void }) { + if (!dialog) { return null; } - const Component = dialogs[active.key] as ComponentType | undefined; + const Component = dialogs[dialog.key] as ComponentType | undefined; if (!Component) { return null; } + return !o && onClose()} {...dialog.props} />; +} + +export default function DialogHost() { + const active = useDialogStore((s) => s.active); + const instanceId = useDialogStore((s) => s.instanceId); + const nested = useDialogStore((s) => s.nested); + const nestedId = useDialogStore((s) => s.nestedId); + + useEffect(() => { + return router.on('navigate', () => useDialogStore.getState().close()); + }, []); + return ( - !o && useDialogStore.getState().close()} {...active.props} /> + <> + useDialogStore.getState().close()} /> + useDialogStore.getState().closeNested()} /> + ); } diff --git a/resources/js/components/ui/dynamic-field.tsx b/resources/js/components/ui/dynamic-field.tsx index d9058e3e4..81db55c63 100644 --- a/resources/js/components/ui/dynamic-field.tsx +++ b/resources/js/components/ui/dynamic-field.tsx @@ -34,6 +34,8 @@ export default function DynamicField({ value, onChange, config, error }: Dynamic const defaultLabel = config.name.replaceAll('_', ' '); const label = config?.label || defaultLabel; const [initialValue, setInitialValue] = useState(false); + const openGuide = (steps: SetupGuideStep[], description?: string) => + useDialogStore.getState().openNested('setupGuide', { title: label, description, steps }); if (value === undefined || value === null) { value = config?.default ?? ''; @@ -82,13 +84,7 @@ export default function DynamicField({ value, onChange, config, error }: Dynamic variant="outline" size="sm" className="shrink-0" - onClick={() => - useDialogStore.getState().open('setupGuide', { - title: label, - description: config.description, - steps: (config.componentProps?.steps ?? []) as SetupGuideStep[], - }) - } + onClick={() => openGuide((config.componentProps?.steps ?? []) as SetupGuideStep[], config.description)} > View guide @@ -225,20 +221,39 @@ export default function DynamicField({ value, onChange, config, error }: Dynamic if (config?.placeholder) { props.placeholder = config.placeholder; } + const guideSteps = config.componentProps?.steps as SetupGuideStep[] | undefined; + const input = ( + onChange(e.target.value)} + className={guideSteps ? 'pr-10' : undefined} + {...props} + /> + ); return ( - onChange(e.target.value)} - {...props} - /> + {guideSteps ? ( +
+ {input} + +
+ ) : ( + input + )} {config.description &&

{config.description}

}
diff --git a/resources/js/hooks/use-dialog.ts b/resources/js/hooks/use-dialog.ts index e09f65d3a..48346eeb6 100644 --- a/resources/js/hooks/use-dialog.ts +++ b/resources/js/hooks/use-dialog.ts @@ -5,6 +5,7 @@ import { dialogs, type DialogRegistry, type ConsumerProps } from '@/components/d type DialogAccessor = { -readonly [K in keyof DialogRegistry]: { open: (props: ConsumerProps) => void; + openNested: (props: ConsumerProps) => void; close: () => void; }; }; @@ -12,6 +13,7 @@ type DialogAccessor = { function entryFor(key: K) { return { open: (props: ConsumerProps) => useDialogStore.getState().open(key, props), + openNested: (props: ConsumerProps) => useDialogStore.getState().openNested(key, props), close: () => useDialogStore.getState().close(), }; } diff --git a/resources/js/pages/server-providers/components/edit-dialog.tsx b/resources/js/pages/server-providers/components/edit-dialog.tsx index 93f5e175d..c9cabd9f1 100644 --- a/resources/js/pages/server-providers/components/edit-dialog.tsx +++ b/resources/js/pages/server-providers/components/edit-dialog.tsx @@ -10,6 +10,9 @@ import { Label } from '@/components/ui/label'; import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; import { ServerProvider } from '@/types/server-provider'; +import DynamicField, { dynamicFieldSpan } from '@/components/ui/dynamic-field'; +import { DynamicFieldConfig, DynamicFieldValue } from '@/types/dynamic-field-config'; +import { useConfigs } from '@/stores/bootstrap-store'; export default function ServerProviderEditDialog({ open, @@ -20,7 +23,11 @@ export default function ServerProviderEditDialog({ onOpenChange: (open: boolean) => void; serverProvider: ServerProvider; }) { - const form = useForm({ + const configs = useConfigs()!; + const editFields: DynamicFieldConfig[] = configs.server_provider.providers[serverProvider.provider]?.edit_form ?? []; + + const form = useForm<{ name: string; global: boolean } & Record>({ + ...Object.fromEntries(editFields.map((field) => [field.name, serverProvider.editable_data?.[field.name] ?? ''])), name: serverProvider.name, global: serverProvider.global, }); @@ -34,19 +41,32 @@ export default function ServerProviderEditDialog({ return ( - e.preventDefault()}> + 0 ? 'max-h-screen overflow-y-auto sm:max-w-2xl' : undefined} + onCloseAutoFocus={(e) => e.preventDefault()} + > Edit {serverProvider.name} Edit server provider
- - + + form.setData('name', e.target.value)} /> - + {editFields.map((field) => ( +
+ form.setData(field.name, value)} + config={field} + error={form.errors[field.name]} + /> +
+ ))} +
+ + + diff --git a/resources/js/pages/server-providers/index.tsx b/resources/js/pages/server-providers/index.tsx index 8fcfcb8c4..c43b1ae3a 100644 --- a/resources/js/pages/server-providers/index.tsx +++ b/resources/js/pages/server-providers/index.tsx @@ -45,7 +45,7 @@ export default function ServerProviders() { { - const serverProvider = asRow(row, ['id', 'name', 'global']); + const serverProvider = asRow(row, ['id', 'name', 'global', 'provider', 'editable_data']); return (
diff --git a/resources/js/stores/dialog-store.ts b/resources/js/stores/dialog-store.ts index ebaf4bd04..9647a83c8 100644 --- a/resources/js/stores/dialog-store.ts +++ b/resources/js/stores/dialog-store.ts @@ -8,28 +8,56 @@ export type ActiveDialog = { type DialogStore = { active: ActiveDialog | null; instanceId: number; + nested: ActiveDialog | null; + nestedId: number; open: (key: K, props: ConsumerProps) => void; + openNested: (key: K, props: ConsumerProps) => void; close: () => void; + closeNested: () => void; }; let triggerElement: HTMLElement | null = null; +let nestedTriggerElement: HTMLElement | null = null; + +function focusedElement(): HTMLElement | null { + return document.activeElement instanceof HTMLElement ? document.activeElement : null; +} + +function restoreFocus(trigger: HTMLElement | null, isCurrent: () => boolean) { + requestAnimationFrame(() => { + if (isCurrent() && trigger?.isConnected) { + trigger.focus(); + } + }); +} export const useDialogStore = create((set, get) => ({ active: null, instanceId: 0, + nested: null, + nestedId: 0, open: (key, props) => { - triggerElement = document.activeElement instanceof HTMLElement ? document.activeElement : null; - set({ active: { key, props } as ActiveDialog, instanceId: get().instanceId + 1 }); + triggerElement = focusedElement(); + nestedTriggerElement = null; + set({ active: { key, props } as ActiveDialog, instanceId: get().instanceId + 1, nested: null }); + }, + openNested: (key, props) => { + nestedTriggerElement = focusedElement(); + set({ nested: { key, props } as ActiveDialog, nestedId: get().nestedId + 1 }); }, close: () => { const trigger = triggerElement; const generation = get().instanceId; triggerElement = null; - set({ active: null }); - requestAnimationFrame(() => { - if (get().instanceId === generation && trigger?.isConnected) { - trigger.focus(); - } - }); + nestedTriggerElement = null; + set({ active: null, nested: null }); + restoreFocus(trigger, () => get().instanceId === generation); + }, + closeNested: () => { + const trigger = nestedTriggerElement; + const generation = get().nestedId; + nestedTriggerElement = null; + set({ nested: null }); + restoreFocus(trigger, () => get().nestedId === generation); }, })); diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 362c30024..d25c15796 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -49,6 +49,7 @@ export interface Configs { handler: string; form?: DynamicFieldConfig[]; create_form?: DynamicFieldConfig[]; + edit_form?: DynamicFieldConfig[]; }; }; }; @@ -103,6 +104,7 @@ export interface Configs { handler: string; form?: DynamicFieldConfig[]; versions: string[]; + unavailable_versions: Record; data?: { extensions?: string[]; }; diff --git a/resources/js/types/server-provider.d.ts b/resources/js/types/server-provider.d.ts index 5e8f21729..ec5b0bafb 100644 --- a/resources/js/types/server-provider.d.ts +++ b/resources/js/types/server-provider.d.ts @@ -5,6 +5,7 @@ export interface ServerProvider { name: string; global: boolean; connected: boolean; + editable_data: Record; project_id?: number; created_at: string; updated_at: string; diff --git a/resources/views/ssh/mise/ensure-installed.blade.php b/resources/views/ssh/mise/ensure-installed.blade.php index 10f749e6a..6cdf6bb03 100644 --- a/resources/views/ssh/mise/ensure-installed.blade.php +++ b/resources/views/ssh/mise/ensure-installed.blade.php @@ -1,14 +1,15 @@ +set -o pipefail 2>/dev/null || true if command -v mise &> /dev/null; then echo "Mise is already installed" mise --version exit 0 fi -sudo apt update -y && sudo apt install -y curl +sudo apt update -y && sudo apt install -y curl gnupg sudo install -dm 755 /etc/apt/keyrings -curl -fsSL https://mise.jdx.dev/gpg-key.pub | sudo tee /etc/apt/keyrings/mise-archive-keyring.pub 1> /dev/null +curl -fsSL https://mise.jdx.dev/gpg-key.pub | sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/mise-archive-keyring.gpg ARCH=$(dpkg --print-architecture) -echo "deb [signed-by=/etc/apt/keyrings/mise-archive-keyring.pub arch=${ARCH}] https://mise.jdx.dev/deb stable main" | sudo tee /etc/apt/sources.list.d/mise.list +echo "deb [signed-by=/etc/apt/keyrings/mise-archive-keyring.gpg arch=${ARCH}] https://mise.jdx.dev/deb stable main" | sudo tee /etc/apt/sources.list.d/mise.list sudo apt update sudo apt install -y mise diff --git a/resources/views/ssh/os/install-dependencies.blade.php b/resources/views/ssh/os/install-dependencies.blade.php index b196a932c..982bd36a4 100755 --- a/resources/views/ssh/os/install-dependencies.blade.php +++ b/resources/views/ssh/os/install-dependencies.blade.php @@ -1,10 +1,11 @@ -sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" install -y software-properties-common curl zip unzip git gcc openssl ufw cron +set -o pipefail 2>/dev/null || true +sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" install -y software-properties-common curl zip unzip git gcc openssl ufw cron gnupg git config --global user.email "{{ $email }}" git config --global user.name "{{ $name }}" # Install Mise sudo install -dm 755 /etc/apt/keyrings -curl -fsSL https://mise.jdx.dev/gpg-key.pub | sudo tee /etc/apt/keyrings/mise-archive-keyring.pub 1> /dev/null -echo "deb [signed-by=/etc/apt/keyrings/mise-archive-keyring.pub arch=$(dpkg --print-architecture)] https://mise.jdx.dev/deb stable main" | sudo tee /etc/apt/sources.list.d/mise.list +curl -fsSL https://mise.jdx.dev/gpg-key.pub | sudo gpg --batch --yes --dearmor -o /etc/apt/keyrings/mise-archive-keyring.gpg +echo "deb [signed-by=/etc/apt/keyrings/mise-archive-keyring.gpg arch=$(dpkg --print-architecture)] https://mise.jdx.dev/deb stable main" | sudo tee /etc/apt/sources.list.d/mise.list sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update sudo DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" install -y mise diff --git a/resources/views/ssh/services/database/mariadb/install.blade.php b/resources/views/ssh/services/database/mariadb/install.blade.php index 5c4bedf58..29fe59c5b 100644 --- a/resources/views/ssh/services/database/mariadb/install.blade.php +++ b/resources/views/ssh/services/database/mariadb/install.blade.php @@ -2,8 +2,11 @@ chmod +x mariadb_repo_setup -sudo DEBIAN_FRONTEND=noninteractive ./mariadb_repo_setup \ - --mariadb-server-version="mariadb-{{ $version }}" +if ! sudo DEBIAN_FRONTEND=noninteractive ./mariadb_repo_setup \ + --mariadb-server-version="mariadb-{{ $version }}" \ + --skip-maxscale; then + echo 'VITO_SSH_ERROR' && exit 1 +fi sudo DEBIAN_FRONTEND=noninteractive apt-get update diff --git a/resources/views/ssh/services/php/install-php.blade.php b/resources/views/ssh/services/php/install-php.blade.php index bd9f1f195..d920258e6 100755 --- a/resources/views/ssh/services/php/install-php.blade.php +++ b/resources/views/ssh/services/php/install-php.blade.php @@ -1,4 +1,9 @@ +@if($useSury) +sudo curl -fsSLo /usr/share/keyrings/sury-php-archive-keyring.gpg https://packages.sury.org/php/apt.gpg +echo "deb [signed-by=/usr/share/keyrings/sury-php-archive-keyring.gpg] https://packages.sury.org/php/ $(lsb_release -sc) main" | sudo tee /etc/apt/sources.list.d/php.list +@else sudo add-apt-repository ppa:ondrej/php -y +@endif sudo DEBIAN_FRONTEND=noninteractive apt-get update -o Acquire::AllowReleaseInfoChange::Label=true diff --git a/tests/Feature/PHPTest.php b/tests/Feature/PHPTest.php index 0a3ab71d7..b0ad37b23 100644 --- a/tests/Feature/PHPTest.php +++ b/tests/Feature/PHPTest.php @@ -1,5 +1,6 @@ server->update(['os' => $os]); + + $php = Service::factory()->create([ + 'server_id' => $this->server->id, + 'type' => 'php', + 'type_data' => [ + 'extensions' => [], + ], + 'name' => 'php', + 'version' => '8.4', + 'status' => ServiceStatus::READY, + ]); + + $php->handler()->install(); + + SSH::assertExecutedContains($repository); + SSH::assertNotExecutedContains($otherRepository); +})->with([ + 'ubuntu 24' => [OperatingSystem::UBUNTU24, 'ppa:ondrej/php', 'packages.sury.org'], + 'ubuntu 26' => [OperatingSystem::UBUNTU26, 'https://packages.sury.org/php/', 'ppa:ondrej/php'], +]); + test('change default php cli', function () { SSH::fake(); diff --git a/tests/Feature/ProxmoxProviderTest.php b/tests/Feature/ProxmoxProviderTest.php index 9c2ab1471..04fec1d24 100644 --- a/tests/Feature/ProxmoxProviderTest.php +++ b/tests/Feature/ProxmoxProviderTest.php @@ -1,6 +1,7 @@ false, 'template_ubuntu_22' => null, 'template_ubuntu_24' => 9000, + 'template_ubuntu_26' => null, ]); Http::assertSent(fn (Request $request): bool => $request->hasHeader('Authorization', 'PVEAPIToken=vito@pve!vito=secret') @@ -137,7 +141,7 @@ 'api_url' => 'https://pve.test:8006', 'token_id' => 'vito@pve!vito', 'token_secret' => 'secret', - ], $input))->assertSessionHasErrors(['template_ubuntu_24' => $error]); + ], $input))->assertSessionHasErrors([array_key_first($input) ?? last(Proxmox::templateFields()) => $error]); $this->assertDatabaseMissing('server_providers', ['profile' => 'homelab']); })->with([ @@ -218,6 +222,36 @@ Queue::assertPushed(InstallJob::class); }); +test('create ubuntu 26 proxmox server clones its template', function () { + $this->actingAs($this->user); + + $this->proxmox->update([ + 'credentials' => array_merge($this->proxmox->credentials, ['template_ubuntu_26' => 9026]), + ]); + + Queue::fake(); + Http::fake([ + '*/api2/json/cluster/resources*' => Http::response(['data' => [ + ['vmid' => 9000, 'node' => 'pve', 'template' => 1, 'type' => 'qemu'], + ['vmid' => 9026, 'node' => 'pve', 'template' => 1, 'type' => 'qemu'], + ]]), + '*/api2/json/cluster/nextid' => Http::response(['data' => '102']), + '*/api2/json/nodes/pve/qemu/9026/clone' => Http::response(['data' => 'UPID:pve:0001:qmclone:9026:vito@pve!vito:']), + ]); + + $this->post(route('servers.store'), [ + 'provider' => Proxmox::id(), + 'server_provider' => $this->proxmox->id, + 'name' => 'resolute', + 'os' => OperatingSystem::UBUNTU26->value, + 'region' => 'pve', + 'plan' => 'medium', + ])->assertSessionDoesntHaveErrors(); + + Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), '/nodes/pve/qemu/9026/clone')); + Queue::assertPushed(InstallJob::class); +}); + test('cannot create proxmox server with invalid input', function (array $input, string $error) { $this->actingAs($this->user); @@ -397,3 +431,167 @@ Http::assertNotSent(fn (Request $request): bool => in_array($request->method(), ['POST', 'DELETE'], true)); }); + +test('proxmox template fields open a setup guide for their ubuntu version', function () { + $fields = collect(config('server-provider.providers.proxmox.edit_form'))->keyBy('name'); + + expect($fields->keys()->all())->toBe(Proxmox::templateFields()); + + $steps = $fields['template_ubuntu_26']['componentProps']['steps']; + + expect($steps[0]['code'])->toContain('https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img') + ->and($steps[2]['code'])->toContain('qm create 9026') + ->and(collect(config('server-provider.providers.proxmox.form'))->firstWhere('name', 'template_ubuntu_26')['componentProps']['steps'])->toBe($steps); +}); + +test('edit proxmox connection updates its templates', function () { + $this->actingAs($this->user); + + Http::fake([ + '*/api2/json/cluster/resources*' => Http::response(['data' => [ + ['vmid' => 9000, 'node' => 'pve', 'template' => 1, 'type' => 'qemu'], + ['vmid' => 9026, 'node' => 'pve', 'template' => 1, 'type' => 'qemu'], + ]]), + '*/api2/json/nodes/pve/qemu/*/config' => Http::response(['data' => [ + 'ide2' => 'local-lvm:vm-9026-cloudinit,media=cdrom', + ]]), + ]); + + $this->patch(route('server-providers.update', $this->proxmox), [ + 'name' => 'homelab', + 'template_ubuntu_24' => 9000, + 'template_ubuntu_26' => '9026', + ])->assertSessionDoesntHaveErrors(); + + expect($this->proxmox->refresh()) + ->profile->toBe('homelab') + ->credentials->toMatchArray([ + 'token_secret' => 'secret', + 'template_ubuntu_24' => 9000, + 'template_ubuntu_26' => 9026, + ]); +}); + +test('edit proxmox connection rejects an invalid template', function () { + $this->actingAs($this->user); + + Http::fake([ + '*/api2/json/cluster/resources*' => Http::response(['data' => [ + ['vmid' => 9000, 'node' => 'pve', 'template' => 1, 'type' => 'qemu'], + ['vmid' => 9026, 'node' => 'pve', 'template' => 0, 'type' => 'qemu'], + ]]), + '*/api2/json/nodes/pve/qemu/*/config' => Http::response(['data' => [ + 'ide2' => 'local-lvm:vm-9000-cloudinit,media=cdrom', + ]]), + ]); + + $this->patch(route('server-providers.update', $this->proxmox), [ + 'name' => 'renamed', + 'template_ubuntu_26' => '9026', + ])->assertSessionHasErrors(['template_ubuntu_26' => 'VM 9026 is not a QEMU template.']); + + expect($this->proxmox->refresh()) + ->profile->not->toBe('renamed') + ->credentials->not->toHaveKey('template_ubuntu_26'); +}); + +test('edit proxmox connection reports connection errors on the provider field', function (int $status, array $body) { + $this->actingAs($this->user); + + Http::fake(['*/api2/json/cluster/resources*' => Http::response($body, $status)]); + + $this->patch(route('server-providers.update', $this->proxmox), [ + 'name' => 'homelab', + 'template_ubuntu_26' => '9026', + ])->assertSessionHasErrors('provider'); +})->with([ + 'token sees no vms' => [200, ['data' => []]], + 'api unreachable' => [500, []], +]); + +test('renaming a proxmox connection does not contact proxmox', function () { + $this->actingAs($this->user); + + Http::fake(); + + $this->patch(route('server-providers.update', $this->proxmox), [ + 'name' => 'renamed', + 'template_ubuntu_20' => '', + 'template_ubuntu_22' => '', + 'template_ubuntu_24' => 9000, + 'template_ubuntu_26' => '', + ])->assertSessionDoesntHaveErrors(); + + Http::assertNotSent(fn (Request $request): bool => str_starts_with($request->url(), 'https://pve.test:8006')); + + expect($this->proxmox->refresh()) + ->profile->toBe('renamed') + ->credentials->not->toHaveKey('template_ubuntu_26'); +}); + +test('proxmox connection shows its templates only to write tokens', function (array $abilities, array $editableData) { + Sanctum::actingAs($this->user, $abilities); + + $this->json('GET', route('api.user.server-providers.show', ['serverProvider' => $this->proxmox->id])) + ->assertOk() + ->assertJsonPath('editable_data', $editableData); +})->with([ + 'write token' => [['read', 'write'], ['template_ubuntu_20' => null, 'template_ubuntu_22' => null, 'template_ubuntu_24' => 9000, 'template_ubuntu_26' => null]], + 'read-only token' => [['read'], []], +]); + +test('api update changes proxmox templates', function () { + Sanctum::actingAs($this->user, ['read', 'write']); + + Http::fake([ + '*/api2/json/cluster/resources*' => Http::response(['data' => [ + ['vmid' => 9000, 'node' => 'pve', 'template' => 1, 'type' => 'qemu'], + ['vmid' => 9026, 'node' => 'pve', 'template' => 1, 'type' => 'qemu'], + ]]), + '*/api2/json/nodes/pve/qemu/*/config' => Http::response(['data' => [ + 'ide2' => 'local-lvm:vm-9026-cloudinit,media=cdrom', + ]]), + ]); + + $this->json('PUT', route('api.user.server-providers.update', ['serverProvider' => $this->proxmox->id]), [ + 'name' => 'homelab', + 'template_ubuntu_26' => 9026, + ]) + ->assertOk() + ->assertJsonPath('editable_data.template_ubuntu_24', 9000) + ->assertJsonPath('editable_data.template_ubuntu_26', 9026); +}); + +test('api update rejects an invalid proxmox template', function () { + Sanctum::actingAs($this->user, ['read', 'write']); + + Http::fake([ + '*/api2/json/cluster/resources*' => Http::response(['data' => [ + ['vmid' => 9000, 'node' => 'pve', 'template' => 1, 'type' => 'qemu'], + ]]), + '*/api2/json/nodes/pve/qemu/*/config' => Http::response(['data' => [ + 'ide2' => 'local-lvm:vm-9000-cloudinit,media=cdrom', + ]]), + ]); + + $this->json('PUT', route('api.user.server-providers.update', ['serverProvider' => $this->proxmox->id]), [ + 'name' => 'homelab', + 'template_ubuntu_26' => 9026, + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors(['template_ubuntu_26' => 'VM 9026 was not found or the API token cannot access it.']); + + expect($this->proxmox->refresh()->credentials)->not->toHaveKey('template_ubuntu_26'); +}); + +test('edit proxmox connection broadcasts without its templates', function () { + Event::fake([SocketEvent::class]); + $this->actingAs($this->user); + + $this->patch(route('server-providers.update', $this->proxmox), [ + 'name' => 'renamed', + ])->assertSessionDoesntHaveErrors(); + + Event::assertDispatched(SocketEvent::class, fn (SocketEvent $event): bool => $event->data->type === 'server-provider.updated' + && (array) $event->data->data['editable_data'] === []); +}); diff --git a/tests/Feature/ServerProvidersTest.php b/tests/Feature/ServerProvidersTest.php index 695285d76..cb52709d9 100644 --- a/tests/Feature/ServerProvidersTest.php +++ b/tests/Feature/ServerProvidersTest.php @@ -11,6 +11,7 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\Http; use Inertia\Testing\AssertableInertia; +use Laravel\Sanctum\Sanctum; uses(RefreshDatabase::class); @@ -478,6 +479,38 @@ $this->assertStringNotContainsString('/mo', $plans['g7-premium-2']['label']); }); +test('server provider without a registered handler can still be renamed', function () { + $this->actingAs($this->user); + + $serverProvider = ServerProvider::factory()->create([ + 'user_id' => $this->user->id, + 'project_id' => $this->user->current_project_id, + 'provider' => 'removed-plugin-provider', + 'credentials' => ['token' => 'token'], + ]); + + $this->patch(route('server-providers.update', $serverProvider), [ + 'name' => 'renamed', + ])->assertSessionDoesntHaveErrors(); + + expect($serverProvider->refresh()->profile)->toBe('renamed'); +}); + +test('api show survives a server provider with no registered handler', function () { + Sanctum::actingAs($this->user, ['read', 'write']); + + $serverProvider = ServerProvider::factory()->create([ + 'user_id' => $this->user->id, + 'project_id' => $this->user->current_project_id, + 'provider' => 'removed-plugin-provider', + 'credentials' => ['token' => 'token'], + ]); + + $this->json('GET', route('api.user.server-providers.show', ['serverProvider' => $serverProvider->id])) + ->assertOk() + ->assertJsonPath('editable_data', []); +}); + dataset('data', /** @return array}> */ function (): array { return [ [ diff --git a/tests/Feature/ServerTest.php b/tests/Feature/ServerTest.php index 4142a5722..5085b4edf 100644 --- a/tests/Feature/ServerTest.php +++ b/tests/Feature/ServerTest.php @@ -7,6 +7,7 @@ use App\Enums\UserRole; use App\Facades\Notifier; use App\Facades\SSH; +use App\Jobs\Server\InstallJob; use App\Models\Project; use App\Models\Server; use App\Models\ServerProvider; @@ -14,10 +15,15 @@ use App\NotificationChannels\Email\NotificationMail; use App\Notifications\ServerAutoUpdateCompleted; use App\ServerProviders\Custom; +use App\ServerProviders\DigitalOcean; use App\ServerProviders\Hetzner; +use App\ServerProviders\Linode; +use App\ServerProviders\Vultr; use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Http\Client\Request; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Facades\Queue; use Illuminate\Support\Facades\Storage; use Laravel\Sanctum\Sanctum; @@ -62,6 +68,80 @@ 'version' => 'latest', 'status' => ServiceStatus::READY, ]); + + SSH::assertExecutedContains('gpg --batch --yes --dearmor -o /etc/apt/keyrings/mise-archive-keyring.gpg'); +}); + +test('create ubuntu 26 server uses the provider image', function (string $provider, string $endpoint, string $field, int|string $image) { + $this->actingAs($this->user); + + Queue::fake(); + Http::fake([ + 'api.hetzner.cloud/v1/ssh_keys' => Http::response(['ssh_key' => ['id' => 1]], 201), + 'api.hetzner.cloud/v1/servers' => Http::response(['server' => ['id' => 1, 'public_net' => ['ipv4' => ['ip' => '1.1.1.1']]]], 201), + 'api.linode.com/v4/linode/instances' => Http::response(['id' => 1, 'ipv4' => ['1.1.1.1']]), + 'api.digitalocean.com/v2/account/keys' => Http::response(['ssh_key' => ['id' => 1]], 201), + 'api.digitalocean.com/v2/images*' => Http::response(['images' => [ + ['id' => 24, 'name' => '24.04 (LTS) x64', 'distribution' => 'Ubuntu', 'status' => 'available', 'regions' => ['nyc1']], + ['id' => 26, 'name' => '26.04 (LTS) x64', 'distribution' => 'Ubuntu', 'status' => 'available', 'regions' => ['nyc1']], + ]]), + 'api.digitalocean.com/v2/droplets' => Http::response(['droplet' => ['id' => 1]], 202), + 'api.vultr.com/v2/ssh-keys' => Http::response(['ssh_key' => ['id' => 'key']], 201), + 'api.vultr.com/v2/os*' => Http::response(['os' => [ + ['id' => 2284, 'name' => 'Ubuntu 24.04 LTS x64', 'arch' => 'x64', 'family' => 'ubuntu'], + ['id' => 2760, 'name' => 'Ubuntu 26.04 LTS x64', 'arch' => 'x64', 'family' => 'ubuntu'], + ]]), + 'api.vultr.com/v2/instances' => Http::response(['instance' => ['id' => 'instance']], 202), + ]); + + $serverProvider = ServerProvider::factory()->create([ + 'user_id' => $this->user->id, + 'project_id' => $this->user->current_project_id, + 'provider' => $provider, + 'credentials' => ['token' => 'token'], + ]); + + $this->post(route('servers.store'), [ + 'provider' => $provider, + 'server_provider' => $serverProvider->id, + 'name' => 'resolute', + 'os' => OperatingSystem::UBUNTU26->value, + 'region' => 'nyc1', + 'plan' => 'small', + ])->assertSessionDoesntHaveErrors(); + + Http::assertSent(fn (Request $request): bool => str_ends_with($request->url(), $endpoint) && $request[$field] === $image); + Queue::assertPushed(InstallJob::class); +})->with([ + 'hetzner' => [Hetzner::id(), '/servers', 'image', 'ubuntu-26.04'], + 'linode' => [Linode::id(), '/linode/instances', 'image', 'linode/ubuntu26.04'], + 'digitalocean' => [DigitalOcean::id(), '/droplets', 'image', 26], + 'vultr' => [Vultr::id(), '/instances', 'os_id', 2760], +]); + +test('cannot create ubuntu 26 server with an unavailable service version', function () { + $this->actingAs($this->user); + + SSH::fake(); + + $this->post(route('servers.store'), [ + 'provider' => Custom::id(), + 'name' => 'resolute', + 'ip' => '8.8.8.8', + 'port' => '22', + 'os' => OperatingSystem::UBUNTU26->value, + 'services' => [ + [ + 'name' => 'mariadb', + 'type' => 'database', + 'version' => '11.4', + ], + ], + ])->assertSessionHasErrors(['services.0.version' => 'MariaDB 11.4 is not available on Ubuntu 26.04.']); + + $this->assertDatabaseMissing('servers', [ + 'name' => 'resolute', + ]); }); test('delete server', function () { diff --git a/tests/Feature/ServicesTest.php b/tests/Feature/ServicesTest.php index 3acd11c4d..bd8ea7035 100644 --- a/tests/Feature/ServicesTest.php +++ b/tests/Feature/ServicesTest.php @@ -1,5 +1,6 @@ assertArrayNotHasKey('networking', $service->type_data); }); +test('cannot install mariadb versions unavailable on ubuntu 26', function (string|float $version) { + SSH::fake(); + + $this->actingAs($this->user); + + $server = Server::factory()->create([ + 'user_id' => $this->user->id, + 'project_id' => $this->user->current_project_id, + 'os' => OperatingSystem::UBUNTU26, + ]); + + $this->post(route('services.store', [ + 'server' => $server, + ]), [ + 'name' => 'mariadb', + 'version' => $version, + ]) + ->assertSessionHasErrors(['version' => "MariaDB $version is not available on Ubuntu 26.04."]); + + $this->assertDatabaseMissing('services', [ + 'server_id' => $server->id, + 'name' => 'mariadb', + ]); +})->with(['10.11', '11.4', 11.4]); + +test('install mariadb on ubuntu 26 skips the maxscale repository', function () { + SSH::fake('Active: active'); + + $this->actingAs($this->user); + + $server = Server::factory()->create([ + 'user_id' => $this->user->id, + 'project_id' => $this->user->current_project_id, + 'os' => OperatingSystem::UBUNTU26, + ]); + + $keys = $server->sshKey(); + if (! File::exists($keys['public_key_path']) || ! File::exists($keys['private_key_path'])) { + $server->provider()->generateKeyPair(); + } + + $this->post(route('services.store', [ + 'server' => $server, + ]), [ + 'name' => 'mariadb', + 'version' => '11.8', + ]) + ->assertSessionDoesntHaveErrors(); + + $this->assertDatabaseHas('services', [ + 'server_id' => $server->id, + 'name' => 'mariadb', + 'version' => '11.8', + 'status' => ServiceStatus::READY, + ]); + + SSH::assertExecutedContains('--skip-maxscale'); +}); + test('parse php installed version', function (string $sshOutput, string $expectedVersion) { /** @var Service $service */ $service = $this->server->services()->where('name', 'php')->firstOrFail();