feat(hetzner): add support for enabling backups during server creation

This commit is contained in:
Jan Thiel 2026-04-28 15:37:26 +02:00
parent a4b27641c8
commit 6637159d7b
6 changed files with 148 additions and 10 deletions

View file

@ -2,6 +2,7 @@
namespace App\Http\Controllers\Api; namespace App\Http\Controllers\Api;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes; use App\Enums\ProxyTypes;
use App\Exceptions\RateLimitException; use App\Exceptions\RateLimitException;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
@ -12,6 +13,7 @@
use App\Rules\ValidCloudInitYaml; use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname; use App\Rules\ValidHostname;
use App\Services\HetznerService; use App\Services\HetznerService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use OpenApi\Attributes as OA; 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'], 'private_key_uuid' => ['type' => 'string', 'example' => 'xyz789', 'description' => 'Private key UUID'],
'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'], 'enable_ipv4' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv4 (default: true)'],
'enable_ipv6' => ['type' => 'boolean', 'example' => true, 'description' => 'Enable IPv6 (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_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_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'], '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', 'private_key_uuid',
'enable_ipv4', 'enable_ipv4',
'enable_ipv6', 'enable_ipv6',
'enable_backups',
'hetzner_ssh_key_ids', 'hetzner_ssh_key_ids',
'hetzner_firewall_ids', 'hetzner_firewall_ids',
'hetzner_network_ids', 'hetzner_network_ids',
@ -741,7 +745,7 @@ public function createServer(Request $request)
} }
$return = validateIncomingRequest($request); $return = validateIncomingRequest($request);
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof JsonResponse) {
return $return; return $return;
} }
@ -755,6 +759,7 @@ public function createServer(Request $request)
'private_key_uuid' => 'required|string', 'private_key_uuid' => 'required|string',
'enable_ipv4' => 'nullable|boolean', 'enable_ipv4' => 'nullable|boolean',
'enable_ipv6' => 'nullable|boolean', 'enable_ipv6' => 'nullable|boolean',
'enable_backups' => 'nullable|boolean',
'hetzner_ssh_key_ids' => 'nullable|array', 'hetzner_ssh_key_ids' => 'nullable|array',
'hetzner_ssh_key_ids.*' => 'integer', 'hetzner_ssh_key_ids.*' => 'integer',
'hetzner_firewall_ids' => 'nullable|array', 'hetzner_firewall_ids' => 'nullable|array',
@ -796,6 +801,9 @@ public function createServer(Request $request)
if (is_null($request->enable_ipv6)) { if (is_null($request->enable_ipv6)) {
$request->offsetSet('enable_ipv6', true); $request->offsetSet('enable_ipv6', true);
} }
if (is_null($request->enable_backups)) {
$request->offsetSet('enable_backups', false);
}
if (is_null($request->hetzner_ssh_key_ids)) { if (is_null($request->hetzner_ssh_key_ids)) {
$request->offsetSet('hetzner_ssh_key_ids', []); $request->offsetSet('hetzner_ssh_key_ids', []);
} }
@ -900,6 +908,10 @@ public function createServer(Request $request)
// Create server on Hetzner // Create server on Hetzner
$hetznerServer = $hetznerService->createServer($params); $hetznerServer = $hetznerService->createServer($params);
if ($request->enable_backups) {
$hetznerService->enableServerBackup((int) $hetznerServer['id']);
}
// Determine IP address to use (prefer IPv4, fallback to IPv6) // Determine IP address to use (prefer IPv4, fallback to IPv6)
$ipAddress = null; $ipAddress = null;
if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) { if ($request->enable_ipv4 && isset($hetznerServer['public_net']['ipv4']['ip'])) {
@ -930,7 +942,7 @@ public function createServer(Request $request)
// Validate server if requested // Validate server if requested
if ($request->instant_validate) { if ($request->instant_validate) {
\App\Actions\Server\ValidateServer::dispatch($server); ValidateServer::dispatch($server);
} }
return response()->json([ return response()->json([

View file

@ -72,6 +72,8 @@ class ByHetzner extends Component
public bool $enable_ipv6 = true; public bool $enable_ipv6 = true;
public bool $enable_backups = false;
public ?string $cloud_init_script = null; public ?string $cloud_init_script = null;
public bool $save_cloud_init_script = false; public bool $save_cloud_init_script = false;
@ -116,6 +118,7 @@ public function resetSelection()
{ {
$this->selected_token_id = null; $this->selected_token_id = null;
$this->current_step = 1; $this->current_step = 1;
$this->enable_backups = false;
$this->cloud_init_script = null; $this->cloud_init_script = null;
$this->save_cloud_init_script = false; $this->save_cloud_init_script = false;
$this->cloud_init_script_name = null; $this->cloud_init_script_name = null;
@ -177,6 +180,7 @@ protected function rules(): array
'selectedHetznerNetworkIds.*' => 'integer', 'selectedHetznerNetworkIds.*' => 'integer',
'enable_ipv4' => 'required|boolean', 'enable_ipv4' => 'required|boolean',
'enable_ipv6' => 'required|boolean', 'enable_ipv6' => 'required|boolean',
'enable_backups' => 'required|boolean',
'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml],
'save_cloud_init_script' => 'boolean', 'save_cloud_init_script' => 'boolean',
'cloud_init_script_name' => 'nullable|string|max:255', 'cloud_init_script_name' => 'nullable|string|max:255',
@ -419,6 +423,23 @@ public function getSelectedServerPriceProperty(): ?string
return '€'.number_format($price, 2); 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) public function updatedSelectedLocation($value)
{ {
// Reset server type and image when location changes // Reset server type and image when location changes
@ -461,10 +482,8 @@ public function clearCloudInitScript()
$this->save_cloud_init_script = false; $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 // Get the private key and extract public key
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
@ -564,9 +583,14 @@ public function submit()
} }
$hetznerToken = $this->getHetznerToken(); $hetznerToken = $this->getHetznerToken();
$hetznerService = new HetznerService($hetznerToken);
// Create server on Hetzner // 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) // Determine IP address to use (prefer IPv4, fallback to IPv6)
$ipAddress = null; $ipAddress = null;

View file

@ -3,6 +3,7 @@
namespace App\Services; namespace App\Services;
use App\Exceptions\RateLimitException; use App\Exceptions\RateLimitException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
class HetznerService class HetznerService
@ -24,7 +25,7 @@ private function request(string $method, string $endpoint, array $data = [])
->timeout(30) ->timeout(30)
->retry(3, function (int $attempt, \Exception $exception) { ->retry(3, function (int $attempt, \Exception $exception) {
// Handle rate limiting (429 Too Many Requests) // Handle rate limiting (429 Too Many Requests)
if ($exception instanceof \Illuminate\Http\Client\RequestException) { if ($exception instanceof RequestException) {
$response = $exception->response; $response = $exception->response;
if ($response && $response->status() === 429) { if ($response && $response->status() === 429) {
@ -153,6 +154,13 @@ public function createServer(array $params): array
return $response['server'] ?? []; 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 public function getServer(int $serverId): array
{ {
$response = $this->request('get', "/servers/{$serverId}"); $response = $this->request('get', "/servers/{$serverId}");

View file

@ -73,7 +73,7 @@
@if (isset($serverType['cpu_vendor_info']) && $serverType['cpu_vendor_info']) @if (isset($serverType['cpu_vendor_info']) && $serverType['cpu_vendor_info'])
({{ $serverType['cpu_vendor_info'] }}) ({{ $serverType['cpu_vendor_info'] }})
@endif @endif
, {{ $serverType['memory'] }}GB RAM, , {{ $serverType['memory'] }}GB RAM,
{{ $serverType['disk'] }}GB {{ $serverType['disk'] }}GB
@if (isset($serverType['architecture'])) @if (isset($serverType['architecture']))
[{{ $serverType['architecture'] }}] [{{ $serverType['architecture'] }}]
@ -190,6 +190,12 @@ class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-
</div> </div>
</div> </div>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium">Backups</label>
<x-forms.checkbox id="enable_backups" label="Enable Hetzner Backups"
helper="Hetzner bills backups at an additional 20% of the server monthly fee{{ $this->selectedServerBackupSurcharge ? ' (about ' . $this->selectedServerBackupSurcharge . '/mo for the selected server type)' : '' }}." />
</div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex justify-between items-center gap-2"> <div class="flex justify-between items-center gap-2">
<label class="text-sm font-medium w-32">Cloud-Init Script</label> <label class="text-sm font-medium w-32">Cloud-Init Script</label>

View file

@ -1,6 +1,7 @@
<?php <?php
use App\Models\CloudProviderToken; use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey; use App\Models\PrivateKey;
use App\Models\Team; use App\Models\Team;
use App\Models\User; use App\Models\User;
@ -11,6 +12,17 @@
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
beforeEach(function () { beforeEach(function () {
config()->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 // Create a team with owner
$this->team = Team::factory()->create(); $this->team = Team::factory()->create();
$this->user = User::factory()->create(); $this->user = User::factory()->create();
@ -22,15 +34,25 @@
$this->bearerToken = $this->token->plainTextToken; $this->bearerToken = $this->token->plainTextToken;
// Create a Hetzner cloud provider token // Create a Hetzner cloud provider token
$this->hetznerToken = CloudProviderToken::factory()->create([ $this->hetznerToken = CloudProviderToken::create([
'team_id' => $this->team->id, 'team_id' => $this->team->id,
'provider' => 'hetzner', 'provider' => 'hetzner',
'name' => 'Test Hetzner Token',
'token' => 'test-hetzner-api-token', 'token' => 'test-hetzner-api-token',
]); ]);
// Create a private key // Create a private key
$this->privateKey = PrivateKey::factory()->create([ $this->privateKey = PrivateKey::create([
'team_id' => $this->team->id, '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 () { test('generates server name if not provided', function () {
Http::fake([ Http::fake([
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([ 'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([

View file

@ -197,6 +197,14 @@
// Boarding should still be enabled since it wasn't created from onboarding // Boarding should still be enabled since it wasn't created from onboarding
expect($this->team->fresh()->show_boarding)->toBeTrue(); 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 () { describe('Hetzner data loading', function () {