feat: add Hetzner Cloud server linking for manually-added servers (#7592)
This commit is contained in:
commit
795c2997c8
4 changed files with 345 additions and 0 deletions
|
|
@ -5,9 +5,12 @@
|
|||
use App\Actions\Server\StartSentinel;
|
||||
use App\Actions\Server\StopSentinel;
|
||||
use App\Events\ServerReachabilityChanged;
|
||||
use App\Models\CloudProviderToken;
|
||||
use App\Models\Server;
|
||||
use App\Services\HetznerService;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Collection;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Locked;
|
||||
use Livewire\Component;
|
||||
|
|
@ -73,6 +76,17 @@ class Show extends Component
|
|||
|
||||
public bool $isValidating = false;
|
||||
|
||||
// Hetzner linking properties
|
||||
public Collection $availableHetznerTokens;
|
||||
|
||||
public ?int $selectedHetznerTokenId = null;
|
||||
|
||||
public ?array $matchedHetznerServer = null;
|
||||
|
||||
public ?string $hetznerSearchError = null;
|
||||
|
||||
public bool $hetznerNoMatchFound = false;
|
||||
|
||||
public function getListeners()
|
||||
{
|
||||
$teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id;
|
||||
|
|
@ -150,6 +164,9 @@ public function mount(string $server_uuid)
|
|||
$this->hetznerServerStatus = $this->server->hetzner_server_status;
|
||||
$this->isValidating = $this->server->is_validating ?? false;
|
||||
|
||||
// Load Hetzner tokens for linking
|
||||
$this->loadHetznerTokens();
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
|
|
@ -465,6 +482,98 @@ public function submit()
|
|||
}
|
||||
}
|
||||
|
||||
public function loadHetznerTokens(): void
|
||||
{
|
||||
$this->availableHetznerTokens = CloudProviderToken::ownedByCurrentTeam()
|
||||
->where('provider', 'hetzner')
|
||||
->get();
|
||||
}
|
||||
|
||||
public function searchHetznerServer(): void
|
||||
{
|
||||
$this->hetznerSearchError = null;
|
||||
$this->hetznerNoMatchFound = false;
|
||||
$this->matchedHetznerServer = null;
|
||||
|
||||
if (! $this->selectedHetznerTokenId) {
|
||||
$this->hetznerSearchError = 'Please select a Hetzner token.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
|
||||
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
|
||||
if (! $token) {
|
||||
$this->hetznerSearchError = 'Invalid token selected.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
$matched = $hetznerService->findServerByIp($this->server->ip);
|
||||
|
||||
if ($matched) {
|
||||
$this->matchedHetznerServer = $matched;
|
||||
} else {
|
||||
$this->hetznerNoMatchFound = true;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->hetznerSearchError = 'Failed to search Hetzner servers: '.$e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public function linkToHetzner()
|
||||
{
|
||||
if (! $this->matchedHetznerServer) {
|
||||
$this->dispatch('error', 'No Hetzner server selected.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->authorize('update', $this->server);
|
||||
|
||||
$token = $this->availableHetznerTokens->firstWhere('id', $this->selectedHetznerTokenId);
|
||||
if (! $token) {
|
||||
$this->dispatch('error', 'Invalid token selected.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify the server exists and is accessible with the token
|
||||
$hetznerService = new HetznerService($token->token);
|
||||
$serverData = $hetznerService->getServer($this->matchedHetznerServer['id']);
|
||||
|
||||
if (empty($serverData)) {
|
||||
$this->dispatch('error', 'Could not find Hetzner server with ID: '.$this->matchedHetznerServer['id']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the server with Hetzner details
|
||||
$this->server->update([
|
||||
'cloud_provider_token_id' => $this->selectedHetznerTokenId,
|
||||
'hetzner_server_id' => $this->matchedHetznerServer['id'],
|
||||
'hetzner_server_status' => $serverData['status'] ?? null,
|
||||
]);
|
||||
|
||||
$this->hetznerServerStatus = $serverData['status'] ?? null;
|
||||
|
||||
// Clear the linking state
|
||||
$this->matchedHetznerServer = null;
|
||||
$this->selectedHetznerTokenId = null;
|
||||
$this->hetznerNoMatchFound = false;
|
||||
$this->hetznerSearchError = null;
|
||||
|
||||
$this->dispatch('success', 'Server successfully linked to Hetzner Cloud!');
|
||||
$this->dispatch('refreshServerShow');
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.server.show');
|
||||
|
|
|
|||
|
|
@ -161,4 +161,30 @@ public function deleteServer(int $serverId): void
|
|||
{
|
||||
$this->request('delete', "/servers/{$serverId}");
|
||||
}
|
||||
|
||||
public function getServers(): array
|
||||
{
|
||||
return $this->requestPaginated('get', '/servers', 'servers');
|
||||
}
|
||||
|
||||
public function findServerByIp(string $ip): ?array
|
||||
{
|
||||
$servers = $this->getServers();
|
||||
|
||||
foreach ($servers as $server) {
|
||||
// Check IPv4
|
||||
$ipv4 = data_get($server, 'public_net.ipv4.ip');
|
||||
if ($ipv4 === $ip) {
|
||||
return $server;
|
||||
}
|
||||
|
||||
// Check IPv6 (Hetzner returns the full /64 block)
|
||||
$ipv6 = data_get($server, 'public_net.ipv6.ip');
|
||||
if ($ipv6 && str_starts_with($ip, rtrim($ipv6, '/'))) {
|
||||
return $server;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -320,6 +320,64 @@ class="w-full input opacity-50 cursor-not-allowed"
|
|||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@if (!$server->hetzner_server_id && $availableHetznerTokens->isNotEmpty())
|
||||
<div class="pt-6">
|
||||
<h3>Link to Hetzner Cloud</h3>
|
||||
<p class="pb-4 text-sm dark:text-neutral-400">
|
||||
Link this server to a Hetzner Cloud instance to enable power controls and status monitoring.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-4 items-end">
|
||||
<div class="w-72">
|
||||
<x-forms.select wire:model="selectedHetznerTokenId" label="Hetzner Token"
|
||||
canGate="update" :canResource="$server">
|
||||
<option value="">Select a token...</option>
|
||||
@foreach ($availableHetznerTokens as $token)
|
||||
<option value="{{ $token->id }}">{{ $token->name }}</option>
|
||||
@endforeach
|
||||
</x-forms.select>
|
||||
</div>
|
||||
<x-forms.button wire:click="searchHetznerServer"
|
||||
wire:loading.attr="disabled"
|
||||
canGate="update" :canResource="$server">
|
||||
<span wire:loading.remove wire:target="searchHetznerServer">Search by IP</span>
|
||||
<span wire:loading wire:target="searchHetznerServer">Searching...</span>
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
@if ($hetznerSearchError)
|
||||
<div class="mt-4 p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
|
||||
<p class="text-red-600 dark:text-red-400">{{ $hetznerSearchError }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($hetznerNoMatchFound)
|
||||
<div class="mt-4 p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
|
||||
<p class="text-yellow-600 dark:text-yellow-400">
|
||||
No Hetzner server found matching IP: {{ $server->ip }}
|
||||
</p>
|
||||
<p class="text-sm dark:text-neutral-400 mt-1">
|
||||
Try a different token or verify the server IP is correct.
|
||||
</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($matchedHetznerServer)
|
||||
<div class="mt-4 p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
|
||||
<h4 class="font-semibold text-green-700 dark:text-green-400 mb-2">Match Found!</h4>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
|
||||
<div><span class="font-medium">Name:</span> {{ $matchedHetznerServer['name'] }}</div>
|
||||
<div><span class="font-medium">ID:</span> {{ $matchedHetznerServer['id'] }}</div>
|
||||
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedHetznerServer['status']) }}</div>
|
||||
<div><span class="font-medium">Type:</span> {{ data_get($matchedHetznerServer, 'server_type.name', 'Unknown') }}</div>
|
||||
</div>
|
||||
<x-forms.button wire:click="linkToHetzner" isHighlighted canGate="update" :canResource="$server">
|
||||
Link This Server
|
||||
</x-forms.button>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
@endif
|
||||
@if ($server->isFunctional() && !$server->isSwarm() && !$server->isBuildServer())
|
||||
<form wire:submit.prevent='submit'>
|
||||
<div class="flex gap-2 items-center pt-4 pb-2">
|
||||
|
|
|
|||
152
tests/Unit/HetznerServiceTest.php
Normal file
152
tests/Unit/HetznerServiceTest.php
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
<?php
|
||||
|
||||
use App\Services\HetznerService;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
Http::preventStrayRequests();
|
||||
});
|
||||
|
||||
it('getServers returns list of servers from Hetzner API', function () {
|
||||
Http::fake([
|
||||
'api.hetzner.cloud/v1/servers*' => Http::response([
|
||||
'servers' => [
|
||||
[
|
||||
'id' => 12345,
|
||||
'name' => 'test-server-1',
|
||||
'status' => 'running',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '123.45.67.89'],
|
||||
'ipv6' => ['ip' => '2a01:4f8::/64'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 67890,
|
||||
'name' => 'test-server-2',
|
||||
'status' => 'off',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '98.76.54.32'],
|
||||
'ipv6' => ['ip' => '2a01:4f9::/64'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new HetznerService('fake-token');
|
||||
$servers = $service->getServers();
|
||||
|
||||
expect($servers)->toBeArray()
|
||||
->and(count($servers))->toBe(2)
|
||||
->and($servers[0]['id'])->toBe(12345)
|
||||
->and($servers[1]['id'])->toBe(67890);
|
||||
});
|
||||
|
||||
it('findServerByIp returns matching server by IPv4', function () {
|
||||
Http::fake([
|
||||
'api.hetzner.cloud/v1/servers*' => Http::response([
|
||||
'servers' => [
|
||||
[
|
||||
'id' => 12345,
|
||||
'name' => 'test-server',
|
||||
'status' => 'running',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '123.45.67.89'],
|
||||
'ipv6' => ['ip' => '2a01:4f8::/64'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new HetznerService('fake-token');
|
||||
$result = $service->findServerByIp('123.45.67.89');
|
||||
|
||||
expect($result)->not->toBeNull()
|
||||
->and($result['id'])->toBe(12345)
|
||||
->and($result['name'])->toBe('test-server');
|
||||
});
|
||||
|
||||
it('findServerByIp returns null when no match', function () {
|
||||
Http::fake([
|
||||
'api.hetzner.cloud/v1/servers*' => Http::response([
|
||||
'servers' => [
|
||||
[
|
||||
'id' => 12345,
|
||||
'name' => 'test-server',
|
||||
'status' => 'running',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '123.45.67.89'],
|
||||
'ipv6' => ['ip' => '2a01:4f8::/64'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new HetznerService('fake-token');
|
||||
$result = $service->findServerByIp('1.2.3.4');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('findServerByIp returns null when server list is empty', function () {
|
||||
Http::fake([
|
||||
'api.hetzner.cloud/v1/servers*' => Http::response([
|
||||
'servers' => [],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new HetznerService('fake-token');
|
||||
$result = $service->findServerByIp('123.45.67.89');
|
||||
|
||||
expect($result)->toBeNull();
|
||||
});
|
||||
|
||||
it('findServerByIp matches correct server among multiple', function () {
|
||||
Http::fake([
|
||||
'api.hetzner.cloud/v1/servers*' => Http::response([
|
||||
'servers' => [
|
||||
[
|
||||
'id' => 11111,
|
||||
'name' => 'server-a',
|
||||
'status' => 'running',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '10.0.0.1'],
|
||||
'ipv6' => ['ip' => '2a01:4f8::/64'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 22222,
|
||||
'name' => 'server-b',
|
||||
'status' => 'running',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '10.0.0.2'],
|
||||
'ipv6' => ['ip' => '2a01:4f9::/64'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'id' => 33333,
|
||||
'name' => 'server-c',
|
||||
'status' => 'off',
|
||||
'public_net' => [
|
||||
'ipv4' => ['ip' => '10.0.0.3'],
|
||||
'ipv6' => ['ip' => '2a01:4fa::/64'],
|
||||
],
|
||||
],
|
||||
],
|
||||
'meta' => ['pagination' => ['next_page' => null]],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$service = new HetznerService('fake-token');
|
||||
$result = $service->findServerByIp('10.0.0.2');
|
||||
|
||||
expect($result)->not->toBeNull()
|
||||
->and($result['id'])->toBe(22222)
|
||||
->and($result['name'])->toBe('server-b');
|
||||
});
|
||||
Loading…
Reference in a new issue