feat: add Hetzner Cloud server linking for manually-added servers (#7592)

This commit is contained in:
Andras Bacsai 2025-12-11 22:24:04 +01:00 committed by GitHub
commit 795c2997c8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 345 additions and 0 deletions

View file

@ -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');

View file

@ -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;
}
}

View file

@ -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">

View 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');
});