feat(hetzner): add support for enabling backups during server creation
This commit is contained in:
parent
a4b27641c8
commit
6637159d7b
6 changed files with 148 additions and 10 deletions
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
|
|
|
|||
|
|
@ -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-
|
|||
</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 justify-between items-center gap-2">
|
||||
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?php
|
||||
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
|
|
@ -11,6 +12,17 @@
|
|||
uses(RefreshDatabase::class);
|
||||
|
||||
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
|
||||
$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([
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue