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;
|
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([
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}");
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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([
|
||||||
|
|
|
||||||
|
|
@ -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 () {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue