From 6637159d7bd8d996e9b73695f11b445ebdc50fe2 Mon Sep 17 00:00:00 2001 From: Jan Thiel Date: Tue, 28 Apr 2026 15:37:26 +0200 Subject: [PATCH] feat(hetzner): add support for enabling backups during server creation --- .../Controllers/Api/HetznerController.php | 16 +++- app/Livewire/Server/New/ByHetzner.php | 32 ++++++- app/Services/HetznerService.php | 10 ++- .../livewire/server/new/by-hetzner.blade.php | 8 +- tests/Feature/HetznerApiTest.php | 84 ++++++++++++++++++- tests/Feature/HetznerServerCreationTest.php | 8 ++ 6 files changed, 148 insertions(+), 10 deletions(-) diff --git a/app/Http/Controllers/Api/HetznerController.php b/app/Http/Controllers/Api/HetznerController.php index 55bd4197f..d729bca54 100644 --- a/app/Http/Controllers/Api/HetznerController.php +++ b/app/Http/Controllers/Api/HetznerController.php @@ -2,6 +2,7 @@ namespace App\Http\Controllers\Api; +use App\Actions\Server\ValidateServer; use App\Enums\ProxyTypes; use App\Exceptions\RateLimitException; use App\Http\Controllers\Controller; @@ -12,6 +13,7 @@ use App\Rules\ValidCloudInitYaml; use App\Rules\ValidHostname; use App\Services\HetznerService; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use OpenApi\Attributes as OA; @@ -668,6 +670,7 @@ public function networks(Request $request) 'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'], 'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'], 'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (default: true)'], + 'enable_backups' => ['type' => 'boolean', 'example' => false, 'description' => 'Enable Hetzner server backups after creation (adds 20% to the monthly server fee)'], 'hetzner_ssh_key_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Additional Hetzner SSH key IDs'], 'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'], 'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'], @@ -728,6 +731,7 @@ public function createServer(Request $request) 'private_key_uuid', 'enable_ipv4', 'enable_ipv6', + 'enable_backups', 'hetzner_ssh_key_ids', 'hetzner_firewall_ids', 'hetzner_network_ids', @@ -741,7 +745,7 @@ public function createServer(Request $request) } $return = validateIncomingRequest($request); - if ($return instanceof \Illuminate\Http\JsonResponse) { + if ($return instanceof JsonResponse) { return $return; } @@ -755,6 +759,7 @@ public function createServer(Request $request) 'private_key_uuid' => 'required|string', 'enable_ipv4' => 'nullable|boolean', 'enable_ipv6' => 'nullable|boolean', + 'enable_backups' => 'nullable|boolean', 'hetzner_ssh_key_ids' => 'nullable|array', 'hetzner_ssh_key_ids.*' => 'integer', 'hetzner_firewall_ids' => 'nullable|array', @@ -796,6 +801,9 @@ public function createServer(Request $request) if (is_null($request->enable_ipv6)) { $request->offsetSet('enable_ipv6', true); } + if (is_null($request->enable_backups)) { + $request->offsetSet('enable_backups', false); + } if (is_null($request->hetzner_ssh_key_ids)) { $request->offsetSet('hetzner_ssh_key_ids', []); } @@ -900,6 +908,10 @@ public function createServer(Request $request) // Create server on Hetzner $hetznerServer = $hetznerService->createServer($params); + if ($request->enable_backups) { + $hetznerService->enableServerBackup((int) $hetznerServer['id']); + } + // Determine IP address to use (prefer IPv4, fallback to IPv6) $ipAddress = null; if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { @@ -930,7 +942,7 @@ public function createServer(Request $request) // Validate server if requested if ($request->instant_validate) { - \App\Actions\Server\ValidateServer::dispatch($server); + ValidateServer::dispatch($server); } return response()->json([ diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 8fb12e714..9b021b2c3 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -72,6 +72,8 @@ class ByHetzner extends Component public bool $enable_ipv6 = true; + public bool $enable_backups = false; + public ?string $cloud_init_script = null; public bool $save_cloud_init_script = false; @@ -116,6 +118,7 @@ public function resetSelection() { $this->selected_token_id = null; $this->current_step = 1; + $this->enable_backups = false; $this->cloud_init_script = null; $this->save_cloud_init_script = false; $this->cloud_init_script_name = null; @@ -177,6 +180,7 @@ protected function rules(): array 'selectedHetznerNetworkIds.*' => 'integer', 'enable_ipv4' => 'required|boolean', 'enable_ipv6' => 'required|boolean', + 'enable_backups' => 'required|boolean', 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'save_cloud_init_script' => 'boolean', 'cloud_init_script_name' => 'nullable|string|max:255', @@ -419,6 +423,23 @@ public function getSelectedServerPriceProperty(): ?string return '€'.number_format($price, 2); } + public function getSelectedServerBackupSurchargeProperty(): ?string + { + if (! $this->selected_server_type) { + return null; + } + + $serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type); + + if (! $serverType || ! isset($serverType['prices'][0]['price_monthly']['gross'])) { + return null; + } + + $price = (float) $serverType['prices'][0]['price_monthly']['gross']; + + return '€'.number_format($price * 0.2, 2); + } + public function updatedSelectedLocation($value) { // Reset server type and image when location changes @@ -461,10 +482,8 @@ public function clearCloudInitScript() $this->save_cloud_init_script = false; } - private function createHetznerServer(string $token): array + private function createHetznerServer(HetznerService $hetznerService): array { - $hetznerService = new HetznerService($token); - // Get the private key and extract public key $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); @@ -564,9 +583,14 @@ public function submit() } $hetznerToken = $this->getHetznerToken(); + $hetznerService = new HetznerService($hetznerToken); // Create server on Hetzner - $hetznerServer = $this->createHetznerServer($hetznerToken); + $hetznerServer = $this->createHetznerServer($hetznerService); + + if ($this->enable_backups) { + $hetznerService->enableServerBackup((int) $hetznerServer['id']); + } // Determine IP address to use (prefer IPv4, fallback to IPv6) $ipAddress = null; diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php index ce6ce6497..3deb23c33 100644 --- a/app/Services/HetznerService.php +++ b/app/Services/HetznerService.php @@ -3,6 +3,7 @@ namespace App\Services; use App\Exceptions\RateLimitException; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; class HetznerService @@ -24,7 +25,7 @@ private function request(string $method, string $endpoint, array $data = []) ->timeout(30) ->retry(3, function (int $attempt, \Exception $exception) { // Handle rate limiting (429 Too Many Requests) - if ($exception instanceof \Illuminate\Http\Client\RequestException) { + if ($exception instanceof RequestException) { $response = $exception->response; if ($response && $response->status() === 429) { @@ -153,6 +154,13 @@ public function createServer(array $params): array return $response['server'] ?? []; } + public function enableServerBackup(int $serverId): array + { + $response = $this->request('post', "/servers/{$serverId}/actions/enable_backup"); + + return $response['action'] ?? []; + } + public function getServer(int $serverId): array { $response = $this->request('get', "/servers/{$serverId}"); diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php index 2efb9baeb..2a25e9958 100644 --- a/resources/views/livewire/server/new/by-hetzner.blade.php +++ b/resources/views/livewire/server/new/by-hetzner.blade.php @@ -73,7 +73,7 @@ @if (isset($serverType['cpu_vendor_info']) && $serverType['cpu_vendor_info']) ({{ $serverType['cpu_vendor_info'] }}) @endif - , {{ $serverType['memory'] }}GB RAM, + , {{ $serverType['memory'] }}GB RAM, {{ $serverType['disk'] }}GB @if (isset($serverType['architecture'])) [{{ $serverType['architecture'] }}] @@ -190,6 +190,12 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning- +
+ + +
+
diff --git a/tests/Feature/HetznerApiTest.php b/tests/Feature/HetznerApiTest.php index c8546e36b..e0c7e35ff 100644 --- a/tests/Feature/HetznerApiTest.php +++ b/tests/Feature/HetznerApiTest.php @@ -1,6 +1,7 @@ set('cache.default', 'array'); + config()->set('app.maintenance.driver', 'file'); + config()->set('app.maintenance.store', 'array'); + + InstanceSettings::unguarded(function () { + InstanceSettings::query()->create([ + 'id' => 0, + 'is_registration_enabled' => true, + ]); + }); + // Create a team with owner $this->team = Team::factory()->create(); $this->user = User::factory()->create(); @@ -22,15 +34,25 @@ $this->bearerToken = $this->token->plainTextToken; // Create a Hetzner cloud provider token - $this->hetznerToken = CloudProviderToken::factory()->create([ + $this->hetznerToken = CloudProviderToken::create([ 'team_id' => $this->team->id, 'provider' => 'hetzner', + 'name' => 'Test Hetzner Token', 'token' => 'test-hetzner-api-token', ]); // Create a private key - $this->privateKey = PrivateKey::factory()->create([ + $this->privateKey = PrivateKey::create([ 'team_id' => $this->team->id, + 'name' => 'Test Key', + 'description' => 'Test SSH key', + 'private_key' => '-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----', ]); }); @@ -307,6 +329,64 @@ ]); }); + test('enables backups after creating a Hetzner server when requested', function () { + Http::fake(function (HttpRequest $request) { + if ($request->method() === 'GET' && str_starts_with($request->url(), 'https://api.hetzner.cloud/v1/ssh_keys')) { + return Http::response([ + 'ssh_keys' => [], + 'meta' => ['pagination' => ['next_page' => null]], + ], 200); + } + + if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/ssh_keys') { + return Http::response([ + 'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'], + ], 201); + } + + if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers') { + return Http::response([ + 'server' => [ + 'id' => 456, + 'name' => 'test-server', + 'public_net' => [ + 'ipv4' => ['ip' => '1.2.3.4'], + 'ipv6' => ['ip' => '2001:db8::1'], + ], + ], + ], 201); + } + + if ($request->method() === 'POST' && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup') { + return Http::response([ + 'action' => ['id' => 789, 'command' => 'enable_backup', 'status' => 'running'], + ], 201); + } + + return Http::response([], 404); + }); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/hetzner', [ + 'cloud_provider_token_id' => $this->hetznerToken->uuid, + 'location' => 'nbg1', + 'server_type' => 'cx11', + 'image' => 15512617, + 'name' => 'test-server', + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_ipv4' => true, + 'enable_ipv6' => true, + 'enable_backups' => true, + ]); + + $response->assertStatus(201); + + Http::assertSent(fn (HttpRequest $request) => $request->method() === 'POST' + && $request->url() === 'https://api.hetzner.cloud/v1/servers/456/actions/enable_backup'); + }); + test('generates server name if not provided', function () { Http::fake([ 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ diff --git a/tests/Feature/HetznerServerCreationTest.php b/tests/Feature/HetznerServerCreationTest.php index 9493ec031..0e4a228db 100644 --- a/tests/Feature/HetznerServerCreationTest.php +++ b/tests/Feature/HetznerServerCreationTest.php @@ -197,6 +197,14 @@ // Boarding should still be enabled since it wasn't created from onboarding expect($this->team->fresh()->show_boarding)->toBeTrue(); }); + + test('shows the backups option with the pricing note in the create dialog', function () { + Livewire::test(ByHetzner::class) + ->set('current_step', 2) + ->assertSet('enable_backups', false) + ->assertSee('Enable Hetzner Backups') + ->assertSee('additional 20% of the server monthly fee'); + }); }); describe('Hetzner data loading', function () {