diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index 15c892e75..db197a019 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -2,16 +2,63 @@ namespace App\Actions\Server; +use App\Models\CloudProviderToken; use App\Models\Server; +use App\Services\HetznerService; use Lorisleiva\Actions\Concerns\AsAction; class DeleteServer { use AsAction; - public function handle(Server $server) + public function handle(Server $server, bool $deleteFromHetzner = false) { + // Delete from Hetzner Cloud if requested and server has hetzner_server_id + if ($deleteFromHetzner && $server->hetzner_server_id) { + $this->deleteFromHetzner($server); + } + StopSentinel::run($server); $server->forceDelete(); } + + private function deleteFromHetzner(Server $server): void + { + try { + // Get the cloud provider token for Hetzner + $token = CloudProviderToken::where('team_id', $server->team_id) + ->where('provider', 'hetzner') + ->first(); + + if (! $token) { + ray('No Hetzner token found for team, skipping Hetzner deletion', [ + 'team_id' => $server->team_id, + 'server_id' => $server->id, + ]); + + return; + } + + $hetznerService = new HetznerService($token->token); + $hetznerService->deleteServer($server->hetzner_server_id); + + ray('Deleted server from Hetzner', [ + 'hetzner_server_id' => $server->hetzner_server_id, + 'server_id' => $server->id, + ]); + } catch (\Throwable $e) { + ray('Failed to delete server from Hetzner', [ + 'error' => $e->getMessage(), + 'hetzner_server_id' => $server->hetzner_server_id, + 'server_id' => $server->id, + ]); + + // Log the error but don't prevent the server from being deleted from Coolify + logger()->error('Failed to delete server from Hetzner', [ + 'error' => $e->getMessage(), + 'hetzner_server_id' => $server->hetzner_server_id, + 'server_id' => $server->id, + ]); + } + } } diff --git a/app/Livewire/Security/CloudProviderTokens.php b/app/Livewire/Security/CloudProviderTokens.php index f35a3a806..f5726e424 100644 --- a/app/Livewire/Security/CloudProviderTokens.php +++ b/app/Livewire/Security/CloudProviderTokens.php @@ -100,7 +100,7 @@ public function addNewToken() public function deleteToken(int $tokenId) { try { - $token = CloudProviderToken::findOrFail($tokenId); + $token = CloudProviderToken::ownedByCurrentTeam()->findOrFail($tokenId); $this->authorize('delete', $token); $token->delete(); diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index b9e3944b5..6d12895eb 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -16,6 +16,8 @@ class Delete extends Component public Server $server; + public bool $delete_from_hetzner = false; + public function mount(string $server_uuid) { try { @@ -41,8 +43,9 @@ public function delete($password) return; } + $this->server->delete(); - DeleteServer::dispatch($this->server); + DeleteServer::dispatch($this->server, $this->delete_from_hetzner); return redirect()->route('server.index'); } catch (\Throwable $e) { @@ -52,6 +55,18 @@ public function delete($password) public function render() { - return view('livewire.server.delete'); + $checkboxes = []; + + if ($this->server->hetzner_server_id) { + $checkboxes[] = [ + 'id' => 'delete_from_hetzner', + 'label' => 'Also delete server from Hetzner Cloud', + 'default_warning' => 'The actual server on Hetzner Cloud will NOT be deleted.', + ]; + } + + return view('livewire.server.delete', [ + 'checkboxes' => $checkboxes, + ]); } } diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index d509adcb6..b67411e17 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -2,9 +2,12 @@ namespace App\Livewire\Server\New; +use App\Enums\ProxyTypes; use App\Models\CloudProviderToken; +use App\Models\PrivateKey; use App\Models\Server; use App\Models\Team; +use App\Services\HetznerService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Http; @@ -15,6 +18,10 @@ class ByHetzner extends Component { use AuthorizesRequests; + // Step tracking + public int $current_step = 1; + + // Locked data #[Locked] public Collection $available_tokens; @@ -24,6 +31,7 @@ class ByHetzner extends Component #[Locked] public $limit_reached; + // Step 1: Token selection public ?int $selected_token_id = null; public string $hetzner_token = ''; @@ -32,22 +40,69 @@ class ByHetzner extends Component public ?string $token_name = null; + // Step 2: Server configuration + public array $locations = []; + + public array $images = []; + + public array $serverTypes = []; + + public ?string $selected_location = null; + + public ?int $selected_image = null; + + public ?string $selected_server_type = null; + + public string $server_name = ''; + + public bool $start_after_create = true; + + public ?int $private_key_id = null; + + public bool $loading_data = false; + public function mount() { $this->authorize('viewAny', CloudProviderToken::class); $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() ->where('provider', 'hetzner') ->get(); + $this->server_name = generate_random_name(); + if ($this->private_keys->count() > 0) { + $this->private_key_id = $this->private_keys->first()->id; + } } protected function rules(): array { - return [ + $rules = [ 'selected_token_id' => 'nullable|integer', 'hetzner_token' => 'required_without:selected_token_id|string', 'save_token' => 'boolean', - 'token_name' => 'required_if:save_token,true|nullable|string|max:255', + 'token_name' => [ + 'nullable', + 'string', + 'max:255', + function ($attribute, $value, $fail) { + if ($this->save_token && ! empty($this->hetzner_token) && empty($value)) { + $fail('Please provide a name for the token.'); + } + }, + ], ]; + + if ($this->current_step === 2) { + $rules = array_merge($rules, [ + 'server_name' => 'required|string|max:255', + 'selected_location' => 'required|string', + 'selected_image' => 'required|integer', + 'selected_server_type' => 'required|string', + 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, + 'start_after_create' => 'boolean', + ]); + } + + return $rules; } protected function messages(): array @@ -76,6 +131,275 @@ private function validateHetznerToken(string $token): bool } } + private function getHetznerToken(): string + { + if ($this->selected_token_id) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + return $token ? $token->token : ''; + } + + return $this->hetzner_token; + } + + public function nextStep() + { + // Validate step 1 + $this->validate([ + 'selected_token_id' => 'nullable|integer', + 'hetzner_token' => 'required_without:selected_token_id|string', + 'save_token' => 'boolean', + 'token_name' => [ + 'nullable', + 'string', + 'max:255', + function ($attribute, $value, $fail) { + if ($this->save_token && ! empty($this->hetzner_token) && empty($value)) { + $fail('Please provide a name for the token.'); + } + }, + ], + ]); + + try { + $hetznerToken = $this->getHetznerToken(); + + if (! $hetznerToken) { + return $this->dispatch('error', 'Please provide a valid Hetzner API token.'); + } + + // Validate token if it's a new one + if (! $this->selected_token_id) { + if (! $this->validateHetznerToken($hetznerToken)) { + return $this->dispatch('error', 'Invalid Hetzner API token. Please check your token and try again.'); + } + + // Save token if requested + if ($this->save_token) { + CloudProviderToken::create([ + 'team_id' => currentTeam()->id, + 'provider' => 'hetzner', + 'token' => $this->hetzner_token, + 'name' => $this->token_name, + ]); + } + } + + // Load Hetzner data + $this->loadHetznerData($hetznerToken); + + // Move to step 2 + $this->current_step = 2; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function previousStep() + { + $this->current_step = 1; + } + + private function loadHetznerData(string $token) + { + $this->loading_data = true; + + try { + $hetznerService = new HetznerService($token); + + $this->locations = $hetznerService->getLocations(); + $this->serverTypes = $hetznerService->getServerTypes(); + + // Get images and sort by name + $images = $hetznerService->getImages(); + + ray('Raw images from Hetzner API', [ + 'total_count' => count($images), + 'types' => collect($images)->pluck('type')->unique()->values(), + 'sample' => array_slice($images, 0, 3), + ]); + + $this->images = collect($images) + ->filter(function ($image) { + // Only system images + if (! isset($image['type']) || $image['type'] !== 'system') { + return false; + } + + // Filter out deprecated images + if (isset($image['deprecated']) && $image['deprecated'] === true) { + return false; + } + + return true; + }) + ->sortBy('name') + ->values() + ->toArray(); + + ray('Filtered images', [ + 'filtered_count' => count($this->images), + 'debian_images' => collect($this->images)->filter(fn ($img) => str_contains($img['name'] ?? '', 'debian'))->values(), + ]); + + $this->loading_data = false; + } catch (\Throwable $e) { + $this->loading_data = false; + throw $e; + } + } + + public function getAvailableServerTypesProperty() + { + ray('Getting available server types', [ + 'selected_location' => $this->selected_location, + 'total_server_types' => count($this->serverTypes), + ]); + + if (! $this->selected_location) { + return $this->serverTypes; + } + + $filtered = collect($this->serverTypes) + ->filter(function ($type) { + if (! isset($type['locations'])) { + return false; + } + + $locationNames = collect($type['locations'])->pluck('name')->toArray(); + + return in_array($this->selected_location, $locationNames); + }) + ->values() + ->toArray(); + + ray('Filtered server types', [ + 'selected_location' => $this->selected_location, + 'filtered_count' => count($filtered), + ]); + + return $filtered; + } + + public function getAvailableImagesProperty() + { + ray('Getting available images', [ + 'selected_server_type' => $this->selected_server_type, + 'total_images' => count($this->images), + 'images' => $this->images, + ]); + + if (! $this->selected_server_type) { + return $this->images; + } + + $serverType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type); + + ray('Server type data', $serverType); + + if (! $serverType || ! isset($serverType['architecture'])) { + ray('No architecture in server type, returning all'); + + return $this->images; + } + + $architecture = $serverType['architecture']; + + $filtered = collect($this->images) + ->filter(fn ($image) => ($image['architecture'] ?? null) === $architecture) + ->values() + ->toArray(); + + ray('Filtered images', [ + 'architecture' => $architecture, + 'filtered_count' => count($filtered), + ]); + + return $filtered; + } + + public function updatedSelectedLocation($value) + { + ray('Location selected', $value); + + // Reset server type and image when location changes + $this->selected_server_type = null; + $this->selected_image = null; + } + + public function updatedSelectedServerType($value) + { + ray('Server type selected', $value); + + // Reset image when server type changes + $this->selected_image = null; + } + + public function updatedSelectedImage($value) + { + ray('Image selected', $value); + } + + private function createHetznerServer(string $token): array + { + $hetznerService = new HetznerService($token); + + // Get the private key and extract public key + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + + $publicKey = $privateKey->getPublicKey(); + $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); + + ray('Private Key Info', [ + 'private_key_id' => $this->private_key_id, + 'sha256_fingerprint' => $privateKey->fingerprint, + 'md5_fingerprint' => $md5Fingerprint, + ]); + + // Check if SSH key already exists on Hetzner by comparing MD5 fingerprints + $existingSshKeys = $hetznerService->getSshKeys(); + $existingKey = null; + + ray('Existing SSH Keys on Hetzner', $existingSshKeys); + + foreach ($existingSshKeys as $key) { + if ($key['fingerprint'] === $md5Fingerprint) { + $existingKey = $key; + break; + } + } + + // Upload SSH key if it doesn't exist + if ($existingKey) { + $sshKeyId = $existingKey['id']; + ray('Using existing SSH key', ['ssh_key_id' => $sshKeyId]); + } else { + $sshKeyName = $privateKey->name; + $uploadedKey = $hetznerService->uploadSshKey($sshKeyName, $publicKey); + $sshKeyId = $uploadedKey['id']; + ray('Uploaded new SSH key', ['ssh_key_id' => $sshKeyId, 'name' => $sshKeyName]); + } + + // Prepare server creation parameters + $params = [ + 'name' => $this->server_name, + 'server_type' => $this->selected_server_type, + 'image' => $this->selected_image, + 'location' => $this->selected_location, + 'start_after_create' => $this->start_after_create, + 'ssh_keys' => [$sshKeyId], + ]; + + ray('Server creation parameters', $params); + + // Create server on Hetzner + $hetznerServer = $hetznerService->createServer($params); + + ray('Hetzner server created', $hetznerServer); + + return $hetznerServer; + } + public function submit() { $this->validate(); @@ -87,35 +411,27 @@ public function submit() return $this->dispatch('error', 'You have reached the server limit for your subscription.'); } - // Determine which token to use - if ($this->selected_token_id) { - $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); - if (! $token) { - return $this->dispatch('error', 'Selected token not found.'); - } - $hetznerToken = $token->token; - } else { - $hetznerToken = $this->hetzner_token; + $hetznerToken = $this->getHetznerToken(); - // Validate the new token before saving - if (! $this->validateHetznerToken($hetznerToken)) { - return $this->dispatch('error', 'Invalid Hetzner API token. Please check your token and try again.'); - } + // Create server on Hetzner + $hetznerServer = $this->createHetznerServer($hetznerToken); - // If saving the new token - if ($this->save_token) { - CloudProviderToken::create([ - 'team_id' => currentTeam()->id, - 'provider' => 'hetzner', - 'token' => $this->hetzner_token, - 'name' => $this->token_name, - ]); - } - } + // Create server in Coolify database + $server = Server::create([ + 'name' => $this->server_name, + 'ip' => $hetznerServer['public_net']['ipv4']['ip'], + 'user' => 'root', + 'port' => 22, + 'team_id' => currentTeam()->id, + 'private_key_id' => $this->private_key_id, + 'hetzner_server_id' => $hetznerServer['id'], + ]); - // TODO: Actual Hetzner server provisioning will be implemented in future phase - // The $hetznerToken variable contains the token to use - return $this->dispatch('success', 'Hetzner token validated successfully! Server provisioning coming soon.'); + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + return redirect()->route('server.show', $server->uuid); } catch (\Throwable $e) { return handleError($e, $this); } diff --git a/app/Models/PrivateKey.php b/app/Models/PrivateKey.php index c210f3c5b..08f3f1ebd 100644 --- a/app/Models/PrivateKey.php +++ b/app/Models/PrivateKey.php @@ -289,6 +289,17 @@ public static function generateFingerprint($privateKey) } } + public static function generateMd5Fingerprint($privateKey) + { + try { + $key = PublicKeyLoader::load($privateKey); + + return $key->getPublicKey()->getFingerprint('md5'); + } catch (\Throwable $e) { + return null; + } + } + public static function fingerprintExists($fingerprint, $excludeId = null) { $query = self::query() diff --git a/app/Models/Server.php b/app/Models/Server.php index 829a4b5aa..e30b10043 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -162,6 +162,7 @@ protected static function booted() 'description', 'private_key_id', 'team_id', + 'hetzner_server_id', ]; protected $guarded = []; diff --git a/app/Services/HetznerService.php b/app/Services/HetznerService.php new file mode 100644 index 000000000..039eb81a9 --- /dev/null +++ b/app/Services/HetznerService.php @@ -0,0 +1,96 @@ +token = $token; + } + + private function request(string $method, string $endpoint, array $data = []) + { + $response = Http::withHeaders([ + 'Authorization' => 'Bearer '.$this->token, + ])->timeout(30)->{$method}($this->baseUrl.$endpoint, $data); + + if (! $response->successful()) { + throw new \Exception('Hetzner API error: '.$response->json('error.message', 'Unknown error')); + } + + return $response->json(); + } + + private function requestPaginated(string $method, string $endpoint, string $resourceKey, array $data = []): array + { + $allResults = []; + $page = 1; + + do { + $data['page'] = $page; + $data['per_page'] = 50; + + $response = $this->request($method, $endpoint, $data); + + if (isset($response[$resourceKey])) { + $allResults = array_merge($allResults, $response[$resourceKey]); + } + + $nextPage = $response['meta']['pagination']['next_page'] ?? null; + $page = $nextPage; + } while ($nextPage !== null); + + return $allResults; + } + + public function getLocations(): array + { + return $this->requestPaginated('get', '/locations', 'locations'); + } + + public function getImages(): array + { + return $this->requestPaginated('get', '/images', 'images', [ + 'type' => 'system', + ]); + } + + public function getServerTypes(): array + { + return $this->requestPaginated('get', '/server_types', 'server_types'); + } + + public function getSshKeys(): array + { + return $this->requestPaginated('get', '/ssh_keys', 'ssh_keys'); + } + + public function uploadSshKey(string $name, string $publicKey): array + { + $response = $this->request('post', '/ssh_keys', [ + 'name' => $name, + 'public_key' => $publicKey, + ]); + + return $response['ssh_key'] ?? []; + } + + public function createServer(array $params): array + { + $response = $this->request('post', '/servers', $params); + + return $response['server'] ?? []; + } + + public function deleteServer(int $serverId): void + { + $this->request('delete', "/servers/{$serverId}"); + } +} diff --git a/database/migrations/2025_10_03_154100_update_clickhouse_image.php b/database/migrations/2025_10_03_154100_update_clickhouse_image.php index e52bbcc16..e57354037 100644 --- a/database/migrations/2025_10_03_154100_update_clickhouse_image.php +++ b/database/migrations/2025_10_03_154100_update_clickhouse_image.php @@ -1,32 +1,32 @@ -string('image')->default('bitnamilegacy/clickhouse')->change(); - }); - // Optionally, update any existing rows with the old default to the new one - DB::table('standalone_clickhouses') - ->where('image', 'bitnami/clickhouse') - ->update(['image' => 'bitnamilegacy/clickhouse']); - } - - public function down() - { - Schema::table('standalone_clickhouses', function (Blueprint $table) { - $table->string('image')->default('bitnami/clickhouse')->change(); - }); - // Optionally, revert any changed values - DB::table('standalone_clickhouses') - ->where('image', 'bitnamilegacy/clickhouse') - ->update(['image' => 'bitnami/clickhouse']); - } -}; \ No newline at end of file +string('image')->default('bitnamilegacy/clickhouse')->change(); + }); + // Optionally, update any existing rows with the old default to the new one + DB::table('standalone_clickhouses') + ->where('image', 'bitnami/clickhouse') + ->update(['image' => 'bitnamilegacy/clickhouse']); + } + + public function down() + { + Schema::table('standalone_clickhouses', function (Blueprint $table) { + $table->string('image')->default('bitnami/clickhouse')->change(); + }); + // Optionally, revert any changed values + DB::table('standalone_clickhouses') + ->where('image', 'bitnamilegacy/clickhouse') + ->update(['image' => 'bitnami/clickhouse']); + } +}; diff --git a/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php new file mode 100644 index 000000000..b1c9ec48b --- /dev/null +++ b/database/migrations/2025_10_08_185203_add_hetzner_server_id_to_servers_table.php @@ -0,0 +1,28 @@ +bigInteger('hetzner_server_id')->nullable()->after('id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + $table->dropColumn('hetzner_server_id'); + }); + } +}; diff --git a/public/svgs/hetzner.svg b/public/svgs/hetzner.svg new file mode 100644 index 000000000..68b1b868d --- /dev/null +++ b/public/svgs/hetzner.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/resources/views/components/modal-confirmation.blade.php b/resources/views/components/modal-confirmation.blade.php index 103f18316..46164840d 100644 --- a/resources/views/components/modal-confirmation.blade.php +++ b/resources/views/components/modal-confirmation.blade.php @@ -250,6 +250,18 @@ class="w-24 dark:bg-coolgray-200 dark:hover:bg-coolgray-300"> {{ $checkbox['label'] }} + @if (isset($checkbox['default_warning'])) + + @endif @endforeach @if (!$disableTwoStepConfirmation) diff --git a/resources/views/livewire/server/create.blade.php b/resources/views/livewire/server/create.blade.php index 619a827e7..a941b7ee2 100644 --- a/resources/views/livewire/server/create.blade.php +++ b/resources/views/livewire/server/create.blade.php @@ -2,11 +2,17 @@
@can('viewAny', App\Models\CloudProviderToken::class)
-

Add Server from Cloud Provider

- + + +
+ + + + + Hetzner +
+
diff --git a/resources/views/livewire/server/delete.blade.php b/resources/views/livewire/server/delete.blade.php index c61775ee8..073849452 100644 --- a/resources/views/livewire/server/delete.blade.php +++ b/resources/views/livewire/server/delete.blade.php @@ -15,16 +15,15 @@
@if ($server->definedResources()->count() > 0)
You need to delete all resources before deleting this server.
- - @else - @endif + + @endif
diff --git a/resources/views/livewire/server/new/by-hetzner.blade.php b/resources/views/livewire/server/new/by-hetzner.blade.php index 83de355e3..7ed5b1495 100644 --- a/resources/views/livewire/server/new/by-hetzner.blade.php +++ b/resources/views/livewire/server/new/by-hetzner.blade.php @@ -2,51 +2,128 @@ @if ($limit_reached) @else -
- @if ($available_tokens->count() > 0) + @if ($current_step === 1) + + @if ($available_tokens->count() > 0) +
+ + + @foreach ($available_tokens as $token) + + @endforeach + +
+
OR
+ @endif +
- - - @foreach ($available_tokens as $token) - - @endforeach - +
-
OR
- @endif -
- -
- -
- -
- - @if ($save_token)
- +
- @endif - - Continue - -
+
+ +
+ + + Continue + + + @elseif ($current_step === 2) + @if ($loading_data) +
+
+
+

Loading Hetzner data...

+
+
+ @else +
+
+ +
+ +
+ + + @foreach ($locations as $location) + + @endforeach + +
+ +
+ + + @foreach ($this->availableServerTypes as $serverType) + + @endforeach + +
+ +
+ + + @foreach ($this->availableImages as $image) + + @endforeach + +
+ +
+ + + @foreach ($private_keys as $key) + + @endforeach + +
+ +
+ +
+ +
+ + Back + + + Create Server + +
+
+ @endif + @endif @endif diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index a25e245e9..f1f1180e8 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -9,6 +9,16 @@

General

+ @if ($server->hetzner_server_id) +
+ + + + + Hetzner +
+ @endif @if ($server->id === 0) - +