diff --git a/app/Actions/Server/DeleteServer.php b/app/Actions/Server/DeleteServer.php index 1ae919c28..aab889479 100644 --- a/app/Actions/Server/DeleteServer.php +++ b/app/Actions/Server/DeleteServer.php @@ -6,6 +6,7 @@ use App\Models\Server; use App\Models\Team; use App\Notifications\Server\HetznerDeletionFailed; +use App\Services\DigitalOceanService; use App\Services\HetznerService; use App\Services\VultrService; use Lorisleiva\Actions\Concerns\AsAction; @@ -14,7 +15,7 @@ class DeleteServer { use AsAction; - public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null) + public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $hetznerServerId = null, ?int $cloudProviderTokenId = null, ?int $teamId = null, bool $deleteFromVultr = false, ?string $vultrInstanceId = null, bool $deleteFromDigitalOcean = false, ?int $digitalOceanDropletId = null) { $server = Server::withTrashed()->find($serverId); @@ -35,6 +36,14 @@ public function handle(int $serverId, bool $deleteFromHetzner = false, ?int $het ); } + if ($deleteFromDigitalOcean && ($digitalOceanDropletId || ($server && $server->digitalocean_droplet_id))) { + $this->deleteFromDigitalOceanById( + $digitalOceanDropletId ?? $server->digitalocean_droplet_id, + $cloudProviderTokenId ?? $server->cloud_provider_token_id, + $teamId ?? $server->team_id + ); + } + logger()->debug($server ? 'Deleting server from Coolify' : 'Server already deleted from Coolify, skipping Coolify deletion'); // If server is already deleted from Coolify, skip this part @@ -136,4 +145,47 @@ private function deleteFromVultrById(string $vultrInstanceId, ?int $cloudProvide ]); } } + + private function deleteFromDigitalOceanById(int $digitalOceanDropletId, ?int $cloudProviderTokenId, int $teamId): void + { + try { + $token = null; + + if ($cloudProviderTokenId) { + $token = CloudProviderToken::where('id', $cloudProviderTokenId) + ->where('team_id', $teamId) + ->where('provider', 'digitalocean') + ->first(); + } + + if (! $token) { + $token = CloudProviderToken::where('team_id', $teamId) + ->where('provider', 'digitalocean') + ->first(); + } + + if (! $token) { + logger()->debug('No DigitalOcean token found for team, skipping droplet deletion', [ + 'team_id' => $teamId, + 'digitalocean_droplet_id' => $digitalOceanDropletId, + ]); + + return; + } + + $digitalOceanService = new DigitalOceanService($token->token); + $digitalOceanService->deleteDroplet($digitalOceanDropletId); + + logger()->debug('Deleted droplet from DigitalOcean', [ + 'digitalocean_droplet_id' => $digitalOceanDropletId, + 'team_id' => $teamId, + ]); + } catch (\Throwable $e) { + logger()->error('Failed to delete droplet from DigitalOcean', [ + 'error' => $e->getMessage(), + 'digitalocean_droplet_id' => $digitalOceanDropletId, + 'team_id' => $teamId, + ]); + } + } } diff --git a/app/Actions/Server/ValidateServer.php b/app/Actions/Server/ValidateServer.php index 9064c725c..378998fe7 100644 --- a/app/Actions/Server/ValidateServer.php +++ b/app/Actions/Server/ValidateServer.php @@ -41,6 +41,19 @@ public function handle(Server $server) } } + if ($server->digitalocean_droplet_id) { + $status = $server->refreshDigitalOceanState(); + if (in_array($status, ['off', 'archive', 'deleted'], true)) { + $this->error = $status === 'deleted' + ? 'DigitalOcean droplet is deleted or no longer accessible. Relink this server before validating.' + : 'DigitalOcean droplet is '.($status ?? 'not running').'. Power it on before validating.'; + $server->update([ + 'validation_logs' => $this->error, + ]); + throw new \Exception($this->error); + } + } + ['uptime' => $this->uptime, 'error' => $error] = $server->validateConnection(); if (! $this->uptime) { $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); diff --git a/app/Http/Controllers/Api/DigitalOceanController.php b/app/Http/Controllers/Api/DigitalOceanController.php new file mode 100644 index 000000000..fc51e7c01 --- /dev/null +++ b/app/Http/Controllers/Api/DigitalOceanController.php @@ -0,0 +1,382 @@ +cloud_provider_token_uuid ?? $request->cloud_provider_token_id; + } + + private function digitalOceanToken(Request $request, int $teamId): CloudProviderToken|JsonResponse + { + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $validator->errors(), + ], 422); + } + + $token = CloudProviderToken::whereTeamId($teamId) + ->whereUuid($this->getCloudProviderTokenUuid($request)) + ->where('provider', 'digitalocean') + ->first(); + + if (! $token) { + return response()->json(['message' => 'DigitalOcean cloud provider token not found.'], 404); + } + + $this->authorize('view', $token); + + return $token; + } + + #[OA\Get( + path: '/digitalocean/regions', + operationId: 'get-digitalocean-regions', + summary: 'Get DigitalOcean regions', + security: [['bearerAuth' => []]], + tags: ['DigitalOcean'], + parameters: [ + new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'List of DigitalOcean regions.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + ] + )] + public function regions(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = $this->digitalOceanToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new DigitalOceanService($token->token))->getRegions()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch DigitalOcean regions.'], 500); + } + } + + #[OA\Get( + path: '/digitalocean/sizes', + operationId: 'get-digitalocean-sizes', + summary: 'Get DigitalOcean sizes', + security: [['bearerAuth' => []]], + tags: ['DigitalOcean'], + parameters: [ + new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'List of DigitalOcean sizes.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + ] + )] + public function sizes(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = $this->digitalOceanToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new DigitalOceanService($token->token))->getSizes()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch DigitalOcean sizes.'], 500); + } + } + + #[OA\Get( + path: '/digitalocean/images', + operationId: 'get-digitalocean-images', + summary: 'Get DigitalOcean images', + security: [['bearerAuth' => []]], + tags: ['DigitalOcean'], + parameters: [ + new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'List of DigitalOcean images.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + ] + )] + public function images(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = $this->digitalOceanToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new DigitalOceanService($token->token))->getImages()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch DigitalOcean images.'], 500); + } + } + + #[OA\Get( + path: '/digitalocean/ssh-keys', + operationId: 'get-digitalocean-ssh-keys', + summary: 'Get DigitalOcean SSH keys', + security: [['bearerAuth' => []]], + tags: ['DigitalOcean'], + parameters: [ + new OA\Parameter(name: 'cloud_provider_token_uuid', in: 'query', required: false, schema: new OA\Schema(type: 'string')), + new OA\Parameter(name: 'cloud_provider_token_id', in: 'query', required: false, deprecated: true, schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response(response: 200, description: 'List of DigitalOcean SSH keys.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + ] + )] + public function sshKeys(Request $request): JsonResponse + { + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + + $token = $this->digitalOceanToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + try { + return response()->json((new DigitalOceanService($token->token))->getSshKeys()); + } catch (\Throwable) { + return response()->json(['message' => 'Failed to fetch DigitalOcean SSH keys.'], 500); + } + } + + #[OA\Post( + path: '/servers/digitalocean', + operationId: 'create-digitalocean-server', + summary: 'Create a server on DigitalOcean', + security: [['bearerAuth' => []]], + tags: ['DigitalOcean'], + responses: [ + new OA\Response(response: 201, description: 'DigitalOcean droplet created and linked to a Coolify server.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 422, description: 'Validation failed.'), + new OA\Response(response: 429, description: 'DigitalOcean rate limit exceeded.'), + ] + )] + public function createServer(Request $request): JsonResponse + { + $allowedFields = [ + 'cloud_provider_token_uuid', + 'cloud_provider_token_id', + 'region', + 'size', + 'image', + 'name', + 'private_key_uuid', + 'enable_ipv6', + 'monitoring', + 'digitalocean_ssh_key_ids', + 'cloud_init_script', + 'instant_validate', + ]; + + $teamId = getTeamIdFromToken(); + if (is_null($teamId)) { + return invalidTokenResponse(); + } + $this->authorize('create', [Server::class]); + + $return = validateIncomingRequest($request); + if ($return instanceof JsonResponse) { + return $return; + } + + $validator = customApiValidator($request->all(), [ + 'cloud_provider_token_uuid' => 'required_without:cloud_provider_token_id|string', + 'cloud_provider_token_id' => 'required_without:cloud_provider_token_uuid|string', + 'region' => 'required|string', + 'size' => 'required|string', + 'image' => 'required', + 'name' => ['nullable', 'string', 'max:253', new ValidHostname], + 'private_key_uuid' => 'required|string', + 'enable_ipv6' => 'nullable|boolean', + 'monitoring' => 'nullable|boolean', + 'digitalocean_ssh_key_ids' => 'nullable|array', + 'digitalocean_ssh_key_ids.*' => 'integer', + 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], + 'instant_validate' => 'nullable|boolean', + ]); + + $extraFields = array_diff(array_keys($request->all()), $allowedFields); + if ($validator->fails() || ! empty($extraFields)) { + $errors = $validator->errors(); + foreach ($extraFields as $field) { + $errors->add($field, 'This field is not allowed.'); + } + + return response()->json([ + 'message' => 'Validation failed.', + 'errors' => $errors, + ], 422); + } + + $team = Team::find($teamId); + if (Team::serverLimitReached($team)) { + return response()->json(['message' => 'Server limit reached for your subscription.'], 400); + } + + $request->offsetSet('name', $request->name ?: generate_random_name()); + $request->offsetSet('enable_ipv6', $request->boolean('enable_ipv6', true)); + $request->offsetSet('monitoring', $request->boolean('monitoring', true)); + $request->offsetSet('digitalocean_ssh_key_ids', $request->digitalocean_ssh_key_ids ?? []); + $request->offsetSet('instant_validate', $request->boolean('instant_validate', false)); + + $token = $this->digitalOceanToken($request, $teamId); + if ($token instanceof JsonResponse) { + return $token; + } + + $privateKey = PrivateKey::whereTeamId($teamId)->whereUuid($request->private_key_uuid)->first(); + if (! $privateKey) { + return response()->json(['message' => 'Private key not found.'], 404); + } + + try { + $digitalOceanService = new DigitalOceanService($token->token); + $sshKeyId = $this->getOrCreateSshKey($digitalOceanService, $privateKey); + + $sshKeys = array_values(array_unique(array_merge( + [$sshKeyId], + $request->digitalocean_ssh_key_ids + ))); + + $normalizedServerName = strtolower(trim($request->name)); + $params = [ + 'name' => $normalizedServerName, + 'region' => $request->region, + 'size' => $request->size, + 'image' => $request->image, + 'ssh_keys' => $sshKeys, + 'ipv6' => $request->enable_ipv6, + 'monitoring' => $request->monitoring, + ]; + + if (! empty($request->cloud_init_script)) { + $params['user_data'] = $request->cloud_init_script; + } + + $droplet = $digitalOceanService->createDroplet($params); + $dropletId = (int) $droplet['id']; + $droplet = $digitalOceanService->waitForPublicIp($droplet, true, $request->enable_ipv6); + $ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $request->enable_ipv6); + + if (! $ipAddress) { + throw new \Exception('No public IP address available for the new droplet.'); + } + + $server = Server::create([ + 'name' => $normalizedServerName, + 'ip' => $ipAddress, + 'user' => 'root', + 'port' => 22, + 'team_id' => $teamId, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'digitalocean_droplet_id' => $dropletId, + 'digitalocean_droplet_status' => $droplet['status'] ?? null, + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + if ($request->instant_validate) { + ValidateServer::dispatch($server); + } + + auditLog('api.digitalocean_droplet.created', [ + 'team_id' => $teamId, + 'server_uuid' => $server->uuid, + 'server_name' => $server->name, + 'digitalocean_droplet_id' => $dropletId, + 'ip' => $ipAddress, + ]); + + return response()->json([ + 'uuid' => $server->uuid, + 'digitalocean_droplet_id' => $dropletId, + 'ip' => $ipAddress, + ])->setStatusCode(201); + } catch (RateLimitException $e) { + $response = response()->json(['message' => $e->getMessage()], 429); + if ($e->retryAfter !== null) { + $response->header('Retry-After', $e->retryAfter); + } + + return $response; + } catch (\Throwable $e) { + logger()->error('Failed to create DigitalOcean server', [ + 'error' => $e->getMessage(), + ]); + + return response()->json(['message' => 'Failed to create DigitalOcean server.'], 500); + } + } + + private function getOrCreateSshKey(DigitalOceanService $digitalOceanService, PrivateKey $privateKey): int + { + $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); + + foreach ($digitalOceanService->getSshKeys() as $key) { + if (($key['fingerprint'] ?? null) === $md5Fingerprint) { + return (int) $key['id']; + } + } + + $uploadedKey = $digitalOceanService->uploadSshKey($privateKey->name, $privateKey->getPublicKey()); + + return (int) $uploadedKey['id']; + } +} diff --git a/app/Http/Controllers/Api/ServersController.php b/app/Http/Controllers/Api/ServersController.php index eaf89ea18..fa0017f87 100644 --- a/app/Http/Controllers/Api/ServersController.php +++ b/app/Http/Controllers/Api/ServersController.php @@ -864,7 +864,9 @@ public function delete_server(Request $request) $server->cloud_provider_token_id, $server->team_id, false, // Don't delete from Vultr via API - $server->vultr_instance_id + $server->vultr_instance_id, + false, // Don't delete from DigitalOcean via API + $server->digitalocean_droplet_id ); auditLog('api.server.deleted', [ diff --git a/app/Jobs/ServerConnectionCheckJob.php b/app/Jobs/ServerConnectionCheckJob.php index 60e003099..60adf8c18 100644 --- a/app/Jobs/ServerConnectionCheckJob.php +++ b/app/Jobs/ServerConnectionCheckJob.php @@ -71,6 +71,10 @@ public function handle() $this->checkVultrStatus(); } + if ($this->server->digitalocean_droplet_id && $this->server->cloudProviderToken) { + $this->checkDigitalOceanStatus(); + } + // Temporarily disable mux if requested if ($this->disableMux) { $this->disableSshMux(); @@ -204,6 +208,26 @@ private function checkVultrStatus(): void } } + private function checkDigitalOceanStatus(): void + { + try { + $status = $this->server->refreshDigitalOceanState(); + } catch (\Throwable $e) { + Log::debug('ServerConnectionCheck: DigitalOcean status check failed', [ + 'server_id' => $this->server->id, + 'error' => $e->getMessage(), + ]); + + return; + } + + $this->server->digitalocean_droplet_status = $status; + + if (in_array($status, ['off', 'archive', 'deleted'], true)) { + throw new \Exception('DigitalOcean droplet is not running'); + } + } + private function checkConnection(): bool { try { diff --git a/app/Livewire/Security/CloudProviderTokenForm.php b/app/Livewire/Security/CloudProviderTokenForm.php index ad1104b1d..0a7235447 100644 --- a/app/Livewire/Security/CloudProviderTokenForm.php +++ b/app/Livewire/Security/CloudProviderTokenForm.php @@ -2,6 +2,9 @@ namespace App\Livewire\Security; +use App\Livewire\Server\CloudProviderToken\Show as ServerCloudProviderTokenShow; +use App\Livewire\Server\New\ByDigitalOcean; +use App\Livewire\Server\New\ByHetzner; use App\Models\CloudProviderToken; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Support\Facades\Http; @@ -59,9 +62,10 @@ private function validateToken(string $provider, string $token): bool } if ($provider === 'digitalocean') { - $response = Http::withHeaders([ - 'Authorization' => 'Bearer '.$token, - ])->timeout(10)->get('https://api.digitalocean.com/v2/account'); + $response = Http::withToken($token) + ->acceptJson() + ->timeout(10) + ->get('https://api.digitalocean.com/v2/account'); return $response->successful(); } @@ -108,6 +112,20 @@ public function addToken() // Dispatch event with token ID so parent components can react $this->dispatch('tokenAdded', tokenId: $savedToken->id); + $this->dispatch('tokenAdded', tokenId: $savedToken->id)->to(CloudProviderTokens::class); + + if ($savedToken->provider === 'digitalocean') { + $this->dispatch('tokenAdded.digitalocean', tokenId: $savedToken->id)->to(ByDigitalOcean::class); + } + + if ($savedToken->provider === 'hetzner') { + $this->dispatch('tokenAdded.hetzner', tokenId: $savedToken->id)->to(ByHetzner::class); + $this->dispatch('tokenAdded.hetzner', tokenId: $savedToken->id)->to(ServerCloudProviderTokenShow::class); + } + + if ($this->modal_mode) { + $this->dispatch('close-modal'); + } $this->dispatch('success', 'Cloud provider token added successfully.'); } catch (\Throwable $e) { diff --git a/app/Livewire/Server/CloudProviderToken/Show.php b/app/Livewire/Server/CloudProviderToken/Show.php index fa6664776..a42a68804 100644 --- a/app/Livewire/Server/CloudProviderToken/Show.php +++ b/app/Livewire/Server/CloudProviderToken/Show.php @@ -35,7 +35,7 @@ public function mount(string $server_uuid) public function getListeners() { return [ - 'tokenAdded' => 'handleTokenAdded', + 'tokenAdded.hetzner' => 'handleTokenAdded', ]; } diff --git a/app/Livewire/Server/Delete.php b/app/Livewire/Server/Delete.php index 707677c79..f53339eed 100644 --- a/app/Livewire/Server/Delete.php +++ b/app/Livewire/Server/Delete.php @@ -18,6 +18,8 @@ class Delete extends Component public bool $delete_from_vultr = false; + public bool $delete_from_digitalocean = false; + public bool $force_delete_resources = false; public function mount(string $server_uuid) @@ -38,6 +40,7 @@ public function delete($password, $selectedActions = []) if (! empty($selectedActions)) { $this->delete_from_hetzner = in_array('delete_from_hetzner', $selectedActions); $this->delete_from_vultr = in_array('delete_from_vultr', $selectedActions); + $this->delete_from_digitalocean = in_array('delete_from_digitalocean', $selectedActions); $this->force_delete_resources = in_array('force_delete_resources', $selectedActions); } try { @@ -62,7 +65,9 @@ public function delete($password, $selectedActions = []) $this->server->cloud_provider_token_id, $this->server->team_id, $this->delete_from_vultr, - $this->server->vultr_instance_id + $this->server->vultr_instance_id, + $this->delete_from_digitalocean, + $this->server->digitalocean_droplet_id ); return redirectRoute($this, 'server.index'); @@ -100,6 +105,14 @@ public function render() ]; } + if ($this->server->digitalocean_droplet_id) { + $checkboxes[] = [ + 'id' => 'delete_from_digitalocean', + 'label' => 'Also delete droplet from DigitalOcean', + 'default_warning' => 'The actual droplet on DigitalOcean will NOT be deleted.', + ]; + } + return view('livewire.server.delete', [ 'checkboxes' => $checkboxes, ]); diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php new file mode 100644 index 000000000..6abb6759f --- /dev/null +++ b/app/Livewire/Server/New/ByDigitalOcean.php @@ -0,0 +1,455 @@ +authorize('viewAny', CloudProviderToken::class); + $this->loadTokens(); + $this->loadSavedCloudInitScripts(); + $this->server_name = generate_random_name(); + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + + if ($this->private_keys->count() > 0) { + $this->private_key_id = $this->private_keys->first()->id; + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function loadSavedCloudInitScripts(): void + { + $this->saved_cloud_init_scripts = CloudInitScript::ownedByCurrentTeam()->get(); + } + + public function getListeners(): array + { + return [ + 'tokenAdded.digitalocean' => 'handleTokenAdded', + 'privateKeyCreated' => 'handlePrivateKeyCreated', + 'modalClosed' => 'resetSelection', + ]; + } + + public function resetSelection(): void + { + $this->selected_token_id = null; + $this->current_step = 1; + $this->cloud_init_script = null; + $this->save_cloud_init_script = false; + $this->cloud_init_script_name = null; + $this->selected_cloud_init_script_id = null; + $this->show_cloud_init_script = false; + $this->selectedDigitalOceanSshKeyIds = []; + } + + public function loadTokens(): void + { + $this->available_tokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'digitalocean') + ->get(); + } + + public function handleTokenAdded($tokenId): void + { + $this->loadTokens(); + $this->selected_token_id = $tokenId; + $this->nextStep(); + } + + public function handlePrivateKeyCreated($keyId): void + { + $this->private_keys = PrivateKey::ownedAndOnlySShKeys()->where('id', '!=', 0)->get(); + $this->private_key_id = $keyId; + $this->resetErrorBag('private_key_id'); + } + + protected function rules(): array + { + $rules = [ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]; + + if ($this->current_step === 2) { + $rules = array_merge($rules, [ + 'server_name' => ['required', 'string', 'max:253', new ValidHostname], + 'selected_region' => 'required|string', + 'selected_image' => 'required', + 'selected_size' => 'required|string', + 'private_key_id' => 'required|integer|exists:private_keys,id,team_id,'.currentTeam()->id, + 'selectedDigitalOceanSshKeyIds' => 'nullable|array', + 'selectedDigitalOceanSshKeyIds.*' => 'integer', + 'enable_ipv6' => 'required|boolean', + 'monitoring' => 'required|boolean', + 'show_cloud_init_script' => 'boolean', + 'cloud_init_script' => ['nullable', 'string', new ValidCloudInitYaml], + 'save_cloud_init_script' => 'boolean', + 'cloud_init_script_name' => 'nullable|string|max:255', + 'selected_cloud_init_script_id' => 'nullable|integer|exists:cloud_init_scripts,id', + ]); + } + + return $rules; + } + + protected function messages(): array + { + return [ + 'selected_token_id.required' => 'Please select a DigitalOcean token.', + 'selected_token_id.exists' => 'Selected token not found.', + ]; + } + + public function selectToken(int $tokenId): void + { + $this->selected_token_id = $tokenId; + } + + private function getDigitalOceanToken(): string + { + if ($this->selected_token_id) { + $token = $this->available_tokens->firstWhere('id', $this->selected_token_id); + + return $token ? $token->token : ''; + } + + return ''; + } + + public function nextStep() + { + $this->validate([ + 'selected_token_id' => 'required|integer|exists:cloud_provider_tokens,id', + ]); + + try { + $digitalOceanToken = $this->getDigitalOceanToken(); + + if (! $digitalOceanToken) { + return $this->dispatch('error', 'Please select a valid DigitalOcean token.'); + } + + $this->loadDigitalOceanData($digitalOceanToken); + $this->current_step = 2; + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function previousStep(): void + { + $this->current_step = 1; + } + + private function loadDigitalOceanData(string $token): void + { + $this->loading_data = true; + + try { + $digitalOceanService = new DigitalOceanService($token); + + $this->regions = $digitalOceanService->getRegions(); + $this->sizes = $digitalOceanService->getSizes(); + $this->images = collect($digitalOceanService->getImages()) + ->sortBy(fn (array $image) => ($image['distribution'] ?? '').' '.($image['name'] ?? '')) + ->values() + ->toArray(); + $this->digitalOceanSshKeys = $digitalOceanService->getSshKeys(); + $this->loading_data = false; + } catch (\Throwable $e) { + $this->loading_data = false; + throw $e; + } + } + + public function getAvailableSizesProperty(): array + { + if (! $this->selected_region) { + return $this->sizes; + } + + return collect($this->sizes) + ->filter(fn (array $size) => in_array($this->selected_region, $size['regions'] ?? [])) + ->values() + ->toArray(); + } + + public function getAvailableImagesProperty(): array + { + if (! $this->selected_region) { + return $this->images; + } + + return collect($this->images) + ->filter(fn (array $image) => in_array($this->selected_region, $image['regions'] ?? [])) + ->values() + ->toArray(); + } + + public function getSelectedDropletPriceProperty(): ?string + { + if (! $this->selected_size) { + return null; + } + + $size = collect($this->sizes)->firstWhere('slug', $this->selected_size); + if (! $size || ! isset($size['price_monthly'])) { + return null; + } + + return '$'.number_format((float) $size['price_monthly'], 2); + } + + public function updatedSelectedRegion(): void + { + $this->selected_size = null; + $this->selected_image = null; + } + + public function updatedSelectedSize(): void + { + $this->selected_image = null; + } + + public function updatedSelectedCloudInitScriptId($value): void + { + if ($value) { + $script = CloudInitScript::ownedByCurrentTeam()->findOrFail($value); + $this->cloud_init_script = $script->script; + $this->cloud_init_script_name = $script->name; + $this->show_cloud_init_script = true; + } + } + + public function updatedSaveCloudInitScript(bool $value): void + { + if (! $value) { + $this->cloud_init_script_name = null; + } + } + + public function showCloudInitScript(): void + { + $this->show_cloud_init_script = true; + } + + public function getAdvancedDigitalOceanOptionsSummaryProperty(): array + { + $summary = []; + + if (count($this->selectedDigitalOceanSshKeyIds) > 0) { + $summary[] = count($this->selectedDigitalOceanSshKeyIds).' extra SSH '.str('key')->plural(count($this->selectedDigitalOceanSshKeyIds)); + } + + if (! $this->enable_ipv6) { + $summary[] = 'IPv4 only'; + } + + if (! $this->monitoring) { + $summary[] = 'Monitoring off'; + } + + if ($this->show_cloud_init_script || filled($this->cloud_init_script) || filled($this->selected_cloud_init_script_id)) { + $summary[] = 'Cloud-init'; + } + + return $summary; + } + + public function clearCloudInitScript(): void + { + $this->selected_cloud_init_script_id = null; + $this->cloud_init_script = ''; + $this->cloud_init_script_name = ''; + $this->save_cloud_init_script = false; + $this->show_cloud_init_script = false; + } + + /** + * @return array{droplet: array, ip: string|null} + */ + private function createDigitalOceanDroplet(string $token): array + { + $digitalOceanService = new DigitalOceanService($token); + $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); + $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); + + $sshKeyId = null; + foreach ($digitalOceanService->getSshKeys() as $key) { + if (($key['fingerprint'] ?? null) === $md5Fingerprint) { + $sshKeyId = (int) $key['id']; + break; + } + } + + if (! $sshKeyId) { + $uploadedKey = $digitalOceanService->uploadSshKey($privateKey->name, $privateKey->getPublicKey()); + $sshKeyId = (int) $uploadedKey['id']; + } + + $sshKeys = array_values(array_unique(array_merge( + [$sshKeyId], + $this->selectedDigitalOceanSshKeyIds + ))); + + $params = [ + 'name' => strtolower(trim($this->server_name)), + 'region' => $this->selected_region, + 'size' => $this->selected_size, + 'image' => $this->selected_image, + 'ssh_keys' => $sshKeys, + 'ipv6' => $this->enable_ipv6, + 'monitoring' => $this->monitoring, + ]; + + if (! empty($this->cloud_init_script)) { + $params['user_data'] = $this->cloud_init_script; + } + + $droplet = $digitalOceanService->createDroplet($params); + $droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6); + $ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $this->enable_ipv6); + + return [ + 'droplet' => $droplet, + 'ip' => $ipAddress, + ]; + } + + public function submit() + { + $this->validate(); + + try { + $this->authorize('create', Server::class); + + if (Team::serverLimitReached()) { + return $this->dispatch('error', 'You have reached the server limit for your subscription.'); + } + + if ($this->save_cloud_init_script && ! empty($this->cloud_init_script) && ! empty($this->cloud_init_script_name)) { + $this->authorize('create', CloudInitScript::class); + + CloudInitScript::create([ + 'team_id' => currentTeam()->id, + 'name' => $this->cloud_init_script_name, + 'script' => $this->cloud_init_script, + ]); + } + + $result = $this->createDigitalOceanDroplet($this->getDigitalOceanToken()); + $droplet = $result['droplet']; + $ipAddress = $result['ip']; + + if (! $ipAddress) { + throw new \Exception('No public IP address available for the new droplet.'); + } + + $server = Server::create([ + 'name' => strtolower(trim($this->server_name)), + 'ip' => $ipAddress, + 'user' => 'root', + 'port' => 22, + 'team_id' => currentTeam()->id, + 'private_key_id' => $this->private_key_id, + 'cloud_provider_token_id' => $this->selected_token_id, + 'digitalocean_droplet_id' => $droplet['id'], + 'digitalocean_droplet_status' => $droplet['status'] ?? null, + ]); + + $server->proxy->set('status', 'exited'); + $server->proxy->set('type', ProxyTypes::TRAEFIK->value); + $server->save(); + + if ($this->from_onboarding) { + currentTeam()->update([ + 'show_boarding' => false, + ]); + refreshSession(); + } + + return redirectRoute($this, 'server.show', [$server->uuid]); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + + public function render() + { + return view('livewire.server.new.by-digital-ocean'); + } +} diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 96740537d..e1191d79b 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -114,7 +114,7 @@ public function loadSavedCloudInitScripts() public function getListeners() { return [ - 'tokenAdded' => 'handleTokenAdded', + 'tokenAdded.hetzner' => 'handleTokenAdded', 'privateKeyCreated' => 'handlePrivateKeyCreated', 'modalClosed' => 'resetSelection', ]; diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index f85d628a0..46f562a9e 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -8,6 +8,7 @@ use App\Models\CloudProviderToken; use App\Models\Server; use App\Rules\ValidServerIp; +use App\Services\DigitalOceanService; use App\Services\HetznerService; use App\Services\VultrService; use App\Support\ValidationPatterns; @@ -78,10 +79,14 @@ class Show extends Component public ?string $vultrInstanceStatus = null; + public ?string $digitalOceanDropletStatus = null; + public bool $hetznerServerManuallyStarted = false; public bool $vultrInstanceManuallyStarted = false; + public bool $digitalOceanDropletManuallyStarted = false; + public bool $isValidating = false; // Hetzner linking properties @@ -109,6 +114,18 @@ class Show extends Component public bool $vultrNoMatchFound = false; + public Collection $availableDigitalOceanTokens; + + public ?int $selectedDigitalOceanTokenId = null; + + public ?string $manualDigitalOceanDropletId = null; + + public ?array $matchedDigitalOceanDroplet = null; + + public ?string $digitalOceanSearchError = null; + + public bool $digitalOceanNoMatchFound = false; + public function getListeners() { $teamId = $this->server->team_id ?? auth()->user()->currentTeam()->id; @@ -191,11 +208,13 @@ public function mount(string $server_uuid) // Load saved Hetzner status and validation state $this->hetznerServerStatus = $this->server->hetzner_server_status; $this->vultrInstanceStatus = $this->server->vultr_instance_status; + $this->digitalOceanDropletStatus = $this->server->digitalocean_droplet_status; $this->isValidating = $this->server->is_validating ?? false; // Load cloud provider tokens for linking $this->loadHetznerTokens(); $this->loadVultrTokens(); + $this->loadDigitalOceanTokens(); } catch (\Throwable $e) { return handleError($e, $this); @@ -514,6 +533,28 @@ public function checkVultrInstanceStatus(bool $manual = false) } } + public function checkDigitalOceanDropletStatus(bool $manual = false) + { + try { + $this->authorize('view', $this->server); + if (! $this->server->digitalocean_droplet_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a DigitalOcean droplet or token.'); + + return; + } + + $this->digitalOceanDropletStatus = $this->server->refreshDigitalOceanState(); + $this->server->refresh(); + $this->ip = $this->server->ip; + + if ($manual) { + $this->dispatch('success', 'Droplet status refreshed: '.ucfirst($this->digitalOceanDropletStatus ?? 'unknown')); + } + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function handleServerValidated($event = null) { // Check if event is for this server @@ -534,6 +575,7 @@ public function handleServerValidated($event = null) // Reload Hetzner tokens in case the linking section should now be shown $this->loadHetznerTokens(); $this->loadVultrTokens(); + $this->loadDigitalOceanTokens(); $this->dispatch('refreshServerShow'); $this->dispatch('refreshServer'); @@ -582,6 +624,28 @@ public function startVultrInstance() } } + public function startDigitalOceanDroplet() + { + try { + $this->authorize('update', $this->server); + if (! $this->server->digitalocean_droplet_id || ! $this->server->cloudProviderToken) { + $this->dispatch('error', 'This server is not associated with a DigitalOcean droplet or token.'); + + return; + } + + $digitalOceanService = new DigitalOceanService($this->server->cloudProviderToken->token); + $digitalOceanService->powerOnDroplet((int) $this->server->digitalocean_droplet_id); + + $this->digitalOceanDropletStatus = 'new'; + $this->server->update(['digitalocean_droplet_status' => 'new']); + $this->digitalOceanDropletManuallyStarted = true; + $this->dispatch('success', 'DigitalOcean droplet is starting...'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function refreshServerMetadata(): void { try { @@ -622,6 +686,13 @@ public function loadVultrTokens(): void ->get(); } + public function loadDigitalOceanTokens(): void + { + $this->availableDigitalOceanTokens = CloudProviderToken::ownedByCurrentTeam() + ->where('provider', 'digitalocean') + ->get(); + } + #[Computed] public function limaStartCommand(): ?string { @@ -763,6 +834,136 @@ public function linkToHetzner() } } + public function searchDigitalOceanDroplet(): void + { + $this->digitalOceanSearchError = null; + $this->digitalOceanNoMatchFound = false; + $this->matchedDigitalOceanDroplet = null; + + if (! $this->selectedDigitalOceanTokenId) { + $this->digitalOceanSearchError = 'Please select a DigitalOcean token.'; + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableDigitalOceanTokens->firstWhere('id', $this->selectedDigitalOceanTokenId); + if (! $token) { + $this->digitalOceanSearchError = 'Invalid token selected.'; + + return; + } + + $digitalOceanService = new DigitalOceanService($token->token); + $matched = $digitalOceanService->findDropletByIp($this->server->ip); + + if ($matched) { + $this->matchedDigitalOceanDroplet = $matched; + } else { + $this->digitalOceanNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->digitalOceanSearchError = 'Failed to search DigitalOcean droplets: '.$e->getMessage(); + } + } + + public function searchDigitalOceanDropletById(): void + { + $this->digitalOceanSearchError = null; + $this->digitalOceanNoMatchFound = false; + $this->matchedDigitalOceanDroplet = null; + + if (! $this->selectedDigitalOceanTokenId) { + $this->digitalOceanSearchError = 'Please select a DigitalOcean token first.'; + + return; + } + + if (! $this->manualDigitalOceanDropletId) { + $this->digitalOceanSearchError = 'Please enter a DigitalOcean Droplet ID.'; + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableDigitalOceanTokens->firstWhere('id', $this->selectedDigitalOceanTokenId); + if (! $token) { + $this->digitalOceanSearchError = 'Invalid token selected.'; + + return; + } + + $digitalOceanService = new DigitalOceanService($token->token); + $dropletData = $digitalOceanService->getDroplet((int) $this->manualDigitalOceanDropletId); + + if (! empty($dropletData)) { + $this->matchedDigitalOceanDroplet = $dropletData; + } else { + $this->digitalOceanNoMatchFound = true; + } + } catch (\Throwable $e) { + $this->digitalOceanSearchError = 'Failed to fetch DigitalOcean droplet: '.$e->getMessage(); + } + } + + public function linkToDigitalOcean() + { + if (! $this->matchedDigitalOceanDroplet) { + $this->dispatch('error', 'No DigitalOcean droplet selected.'); + + return; + } + + try { + $this->authorize('update', $this->server); + + $token = $this->availableDigitalOceanTokens->firstWhere('id', $this->selectedDigitalOceanTokenId); + if (! $token) { + $this->dispatch('error', 'Invalid token selected.'); + + return; + } + + $digitalOceanService = new DigitalOceanService($token->token); + $dropletData = $digitalOceanService->getDroplet((int) $this->matchedDigitalOceanDroplet['id']); + + if (empty($dropletData)) { + $this->dispatch('error', 'Could not find DigitalOcean droplet with ID: '.$this->matchedDigitalOceanDroplet['id']); + + return; + } + + $ip = $digitalOceanService->getPublicIpAddress($dropletData); + $updates = [ + 'cloud_provider_token_id' => $this->selectedDigitalOceanTokenId, + 'digitalocean_droplet_id' => $this->matchedDigitalOceanDroplet['id'], + 'digitalocean_droplet_status' => $dropletData['status'] ?? null, + ]; + + if ($ip) { + $updates['ip'] = $ip; + } + + $this->server->update($updates); + $this->digitalOceanDropletStatus = $dropletData['status'] ?? null; + + $this->matchedDigitalOceanDroplet = null; + $this->selectedDigitalOceanTokenId = null; + $this->manualDigitalOceanDropletId = null; + $this->digitalOceanNoMatchFound = false; + $this->digitalOceanSearchError = null; + + $this->dispatch('success', 'Server successfully linked to DigitalOcean!'); + $this->dispatch('refreshServerShow'); + } catch (\Throwable $e) { + return handleError($e, $this); + } + } + public function searchVultrInstance(): void { $this->vultrSearchError = null; diff --git a/app/Livewire/Server/ValidateAndInstall.php b/app/Livewire/Server/ValidateAndInstall.php index b3b946c15..c7181ebcf 100644 --- a/app/Livewire/Server/ValidateAndInstall.php +++ b/app/Livewire/Server/ValidateAndInstall.php @@ -108,6 +108,22 @@ public function validateConnection() } } + if ($this->server->digitalocean_droplet_id) { + $status = $this->server->refreshDigitalOceanState(); + $this->server->refresh(); + + if (in_array($status, ['off', 'archive', 'deleted'], true)) { + $this->error = $status === 'deleted' + ? 'DigitalOcean droplet is deleted or no longer accessible. Relink this server before validating.' + : 'DigitalOcean droplet is '.($status ?? 'not running').'. Power it on before validating.'; + $this->server->update([ + 'validation_logs' => $this->error, + ]); + + return; + } + } + ['uptime' => $this->uptime, 'error' => $error] = $this->server->validateConnection(); if (! $this->uptime) { $sanitizedError = htmlspecialchars($error ?? '', ENT_QUOTES, 'UTF-8'); diff --git a/app/Mcp/Concerns/BuildsResponse.php b/app/Mcp/Concerns/BuildsResponse.php index 10d87ae92..1473d8994 100644 --- a/app/Mcp/Concerns/BuildsResponse.php +++ b/app/Mcp/Concerns/BuildsResponse.php @@ -24,7 +24,7 @@ trait BuildsResponse // raw IDs / morph types (uuid is the public identifier) 'id', 'team_id', 'tokenable_id', 'tokenable_type', 'server_id', 'private_key_id', 'cloud_provider_token_id', - 'hetzner_server_id', 'environment_id', 'destination_id', + 'hetzner_server_id', 'digitalocean_droplet_id', 'environment_id', 'destination_id', 'source_id', 'repository_project_id', 'application_id', 'service_id', 'project_id', 'parent_id', 'resourceable', 'resourceable_id', 'resourceable_type', diff --git a/app/Models/Server.php b/app/Models/Server.php index e4b05d1ee..4bf57207f 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -17,6 +17,7 @@ use App\Notifications\Server\Reachable; use App\Notifications\Server\Unreachable; use App\Services\ConfigurationRepository; +use App\Services\DigitalOceanService; use App\Services\VultrService; use App\Support\ValidationPatterns; use App\Traits\ClearsGlobalSearchCache; @@ -282,6 +283,8 @@ public static function flushIdentityMap(): void 'hetzner_server_status', 'vultr_instance_id', 'vultr_instance_status', + 'digitalocean_droplet_id', + 'digitalocean_droplet_status', 'is_validating', 'validation_logs', 'detected_traefik_version', @@ -349,6 +352,51 @@ public function refreshVultrState(): ?string return $status; } + public function refreshDigitalOceanState(): ?string + { + if (! $this->digitalocean_droplet_id || ! $this->cloudProviderToken || $this->cloudProviderToken->provider !== 'digitalocean') { + return $this->digitalocean_droplet_status; + } + + $digitalOceanService = new DigitalOceanService($this->cloudProviderToken->token); + + try { + $droplet = $digitalOceanService->getDroplet((int) $this->digitalocean_droplet_id); + } catch (RequestException $e) { + if ($e->response?->status() === 404) { + $this->update(['digitalocean_droplet_status' => 'deleted']); + + return 'deleted'; + } + + throw $e; + } catch (\Throwable $e) { + if ((int) $e->getCode() === 404) { + $this->update(['digitalocean_droplet_status' => 'deleted']); + + return 'deleted'; + } + + throw $e; + } + + if (empty($droplet)) { + return $this->digitalocean_droplet_status; + } + + $status = $droplet['status'] ?? null; + $ip = $digitalOceanService->getPublicIpAddress($droplet); + + $updates = ['digitalocean_droplet_status' => $status]; + if ($ip && $ip !== $this->ip) { + $updates['ip'] = $ip; + } + + $this->update($updates); + + return $status; + } + protected function isCoolifyHost(): Attribute { return Attribute::make( diff --git a/app/Services/DigitalOceanService.php b/app/Services/DigitalOceanService.php new file mode 100644 index 000000000..c8292e864 --- /dev/null +++ b/app/Services/DigitalOceanService.php @@ -0,0 +1,221 @@ +token) + ->acceptJson() + ->timeout(30) + ->connectTimeout(10) + ->retry(3, function (int $attempt, \Exception $exception) { + if ($exception instanceof RequestException && $exception->response?->status() === 429) { + $resetTime = $exception->response->header('RateLimit-Reset'); + + if ($resetTime) { + return min(max(0, (int) $resetTime - time()), 60) * 1000; + } + } + + return $attempt * 100; + }) + ->{$method}($this->baseUrl.$endpoint, $data); + + if (! $response->successful()) { + if ($response->status() === 429) { + $retryAfter = $response->header('Retry-After'); + if ($retryAfter === null) { + $resetTime = $response->header('RateLimit-Reset'); + $retryAfter = $resetTime ? max(0, (int) $resetTime - time()) : null; + } + + throw new RateLimitException( + 'Rate limit exceeded. Please try again later.', + $retryAfter !== null ? (int) $retryAfter : null + ); + } + + throw new \Exception('DigitalOcean API error: '.$response->json('message', 'Unknown error'), $response->status()); + } + + return $response->json() ?? []; + } + + private function requestPaginated(string $endpoint, string $resourceKey, array $data = []): array + { + $allResults = []; + $page = 1; + + do { + $response = $this->request('get', $endpoint, array_merge($data, [ + 'page' => $page, + 'per_page' => 50, + ])); + + if (isset($response[$resourceKey])) { + $allResults = array_merge($allResults, $response[$resourceKey]); + } + + $hasNextPage = filled(data_get($response, 'links.pages.next')); + $page++; + } while ($hasNextPage); + + return $allResults; + } + + public function getAccount(): array + { + return $this->request('get', '/account')['account'] ?? []; + } + + public function getRegions(): array + { + return array_values(array_filter( + $this->requestPaginated('/regions', 'regions'), + fn (array $region) => ($region['available'] ?? true) === true + )); + } + + public function getSizes(): array + { + return array_values(array_filter( + $this->requestPaginated('/sizes', 'sizes'), + fn (array $size) => ($size['available'] ?? true) === true + )); + } + + public function getImages(): array + { + return array_values(array_filter( + $this->requestPaginated('/images', 'images', ['type' => 'distribution']), + fn (array $image) => ($image['public'] ?? true) === true + )); + } + + public function getSshKeys(): array + { + return $this->requestPaginated('/account/keys', 'ssh_keys'); + } + + public function uploadSshKey(string $name, string $publicKey): array + { + $response = $this->request('post', '/account/keys', [ + 'name' => $name, + 'public_key' => $publicKey, + ]); + + return $response['ssh_key'] ?? []; + } + + public function createDroplet(array $params): array + { + $response = $this->request('post', '/droplets', $params); + + return $response['droplet'] ?? []; + } + + public function getDroplet(int $dropletId): array + { + $response = $this->request('get', $this->dropletEndpoint($dropletId)); + + return $response['droplet'] ?? []; + } + + public function waitForPublicIp(array $droplet, bool $enableIpv4 = true, bool $enableIpv6 = true, int $attempts = 30, int $sleepMilliseconds = 1000): array + { + if ($this->getPublicIpAddress($droplet, $enableIpv4, $enableIpv6) || empty($droplet['id'])) { + return $droplet; + } + + for ($attempt = 0; $attempt < $attempts; $attempt++) { + usleep($sleepMilliseconds * 1000); + + $droplet = $this->getDroplet((int) $droplet['id']); + + if ($this->getPublicIpAddress($droplet, $enableIpv4, $enableIpv6)) { + return $droplet; + } + } + + return $droplet; + } + + public function powerOnDroplet(int $dropletId): array + { + $response = $this->request('post', $this->dropletEndpoint($dropletId).'/actions', [ + 'type' => 'power_on', + ]); + + return $response['action'] ?? []; + } + + public function deleteDroplet(int $dropletId): void + { + $this->request('delete', $this->dropletEndpoint($dropletId)); + } + + public function getDroplets(): array + { + return $this->requestPaginated('/droplets', 'droplets'); + } + + public function findDropletByIp(string $ip): ?array + { + foreach ($this->getDroplets() as $droplet) { + if ($this->dropletHasIp($droplet, $ip)) { + return $droplet; + } + } + + return null; + } + + public function getPublicIpAddress(array $droplet, bool $enableIpv4 = true, bool $enableIpv6 = true): ?string + { + if ($enableIpv4) { + foreach (data_get($droplet, 'networks.v4', []) as $network) { + if (($network['type'] ?? null) === 'public' && filled($network['ip_address'] ?? null)) { + return $network['ip_address']; + } + } + } + + if ($enableIpv6) { + foreach (data_get($droplet, 'networks.v6', []) as $network) { + if (($network['type'] ?? null) === 'public' && filled($network['ip_address'] ?? null)) { + return $network['ip_address']; + } + } + } + + return null; + } + + private function dropletEndpoint(int $dropletId): string + { + return '/droplets/'.$dropletId; + } + + private function dropletHasIp(array $droplet, string $ip): bool + { + foreach (['v4', 'v6'] as $version) { + foreach (data_get($droplet, "networks.{$version}", []) as $network) { + if (($network['ip_address'] ?? null) === $ip) { + return true; + } + } + } + + return false; + } +} diff --git a/database/migrations/2026_07_07_114840_add_digitalocean_droplet_fields_to_servers_table.php b/database/migrations/2026_07_07_114840_add_digitalocean_droplet_fields_to_servers_table.php new file mode 100644 index 000000000..d2845c047 --- /dev/null +++ b/database/migrations/2026_07_07_114840_add_digitalocean_droplet_fields_to_servers_table.php @@ -0,0 +1,40 @@ +bigInteger('digitalocean_droplet_id')->nullable()->after('hetzner_server_status'); + } + + if (! Schema::hasColumn('servers', 'digitalocean_droplet_status')) { + $table->string('digitalocean_droplet_status')->nullable()->after('digitalocean_droplet_id'); + } + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('servers', function (Blueprint $table) { + if (Schema::hasColumn('servers', 'digitalocean_droplet_status')) { + $table->dropColumn('digitalocean_droplet_status'); + } + + if (Schema::hasColumn('servers', 'digitalocean_droplet_id')) { + $table->dropColumn('digitalocean_droplet_id'); + } + }); + } +}; diff --git a/openapi.json b/openapi.json index c7aea585b..7ffe9ecca 100644 --- a/openapi.json +++ b/openapi.json @@ -8059,6 +8059,210 @@ ] } }, + "\/digitalocean\/regions": { + "get": { + "tags": [ + "DigitalOcean" + ], + "summary": "Get DigitalOcean regions", + "operationId": "get-digitalocean-regions", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of DigitalOcean regions." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/digitalocean\/sizes": { + "get": { + "tags": [ + "DigitalOcean" + ], + "summary": "Get DigitalOcean sizes", + "operationId": "get-digitalocean-sizes", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of DigitalOcean sizes." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/digitalocean\/images": { + "get": { + "tags": [ + "DigitalOcean" + ], + "summary": "Get DigitalOcean images", + "operationId": "get-digitalocean-images", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of DigitalOcean images." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/digitalocean\/ssh-keys": { + "get": { + "tags": [ + "DigitalOcean" + ], + "summary": "Get DigitalOcean SSH keys", + "operationId": "get-digitalocean-ssh-keys", + "parameters": [ + { + "name": "cloud_provider_token_uuid", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "cloud_provider_token_id", + "in": "query", + "required": false, + "deprecated": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "List of DigitalOcean SSH keys." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/digitalocean": { + "post": { + "tags": [ + "DigitalOcean" + ], + "summary": "Create a server on DigitalOcean", + "operationId": "create-digitalocean-server", + "responses": { + "201": { + "description": "DigitalOcean droplet created and linked to a Coolify server." + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "422": { + "description": "Validation failed." + }, + "429": { + "description": "DigitalOcean rate limit exceeded." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/github-apps": { "get": { "tags": [ @@ -15550,6 +15754,10 @@ "name": "Deployments", "description": "Deployments" }, + { + "name": "DigitalOcean", + "description": "DigitalOcean" + }, { "name": "GitHub Apps", "description": "GitHub Apps" diff --git a/openapi.yaml b/openapi.yaml index 8df2a7576..3b2f5c4d5 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5232,6 +5232,144 @@ paths: security: - bearerAuth: [] + /digitalocean/regions: + get: + tags: + - DigitalOcean + summary: 'Get DigitalOcean regions' + operationId: get-digitalocean-regions + parameters: + - + name: cloud_provider_token_uuid + in: query + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of DigitalOcean regions.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + security: + - + bearerAuth: [] + /digitalocean/sizes: + get: + tags: + - DigitalOcean + summary: 'Get DigitalOcean sizes' + operationId: get-digitalocean-sizes + parameters: + - + name: cloud_provider_token_uuid + in: query + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of DigitalOcean sizes.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + security: + - + bearerAuth: [] + /digitalocean/images: + get: + tags: + - DigitalOcean + summary: 'Get DigitalOcean images' + operationId: get-digitalocean-images + parameters: + - + name: cloud_provider_token_uuid + in: query + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of DigitalOcean images.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + security: + - + bearerAuth: [] + /digitalocean/ssh-keys: + get: + tags: + - DigitalOcean + summary: 'Get DigitalOcean SSH keys' + operationId: get-digitalocean-ssh-keys + parameters: + - + name: cloud_provider_token_uuid + in: query + required: false + schema: + type: string + - + name: cloud_provider_token_id + in: query + required: false + deprecated: true + schema: + type: string + responses: + '200': + description: 'List of DigitalOcean SSH keys.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + security: + - + bearerAuth: [] + /servers/digitalocean: + post: + tags: + - DigitalOcean + summary: 'Create a server on DigitalOcean' + operationId: create-digitalocean-server + responses: + '201': + description: 'DigitalOcean droplet created and linked to a Coolify server.' + '401': + $ref: '#/components/responses/401' + '422': + description: 'Validation failed.' + '429': + description: 'DigitalOcean rate limit exceeded.' + security: + - + bearerAuth: [] /github-apps: get: tags: @@ -9979,6 +10117,9 @@ tags: - name: Deployments description: Deployments + - + name: DigitalOcean + description: DigitalOcean - name: 'GitHub Apps' description: 'GitHub Apps' diff --git a/resources/views/components/digital-ocean-icon.blade.php b/resources/views/components/digital-ocean-icon.blade.php new file mode 100644 index 000000000..f3d15c6e2 --- /dev/null +++ b/resources/views/components/digital-ocean-icon.blade.php @@ -0,0 +1,6 @@ + diff --git a/resources/views/components/modal-input.blade.php b/resources/views/components/modal-input.blade.php index dc1191b44..0f7347e5c 100644 --- a/resources/views/components/modal-input.blade.php +++ b/resources/views/components/modal-input.blade.php @@ -17,7 +17,7 @@
Loading DigitalOcean data...
++ Link this server to a DigitalOcean droplet to enable power controls and status monitoring. +
+ +{{ $digitalOceanSearchError }}
++ @if ($manualDigitalOceanDropletId) + No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }} + @else + No DigitalOcean droplet found matching IP: {{ $server->ip }} + @endif +
+