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 @@ +merge(['class' => 'w-6 h-6 flex-shrink-0']) }} fill="#0080FF" role="img" viewBox="0 0 24 24" + xmlns="http://www.w3.org/2000/svg"> + DigitalOcean + + 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 @@
+ class="relative w-auto h-auto" @close-modal.window="modalOpen=false" wire:ignore> @if ($content)
{{ $content }} diff --git a/resources/views/livewire/security/cloud-provider-token-form.blade.php b/resources/views/livewire/security/cloud-provider-token-form.blade.php index fed391463..376cbe2fd 100644 --- a/resources/views/livewire/security/cloud-provider-token-form.blade.php +++ b/resources/views/livewire/security/cloud-provider-token-form.blade.php @@ -5,22 +5,23 @@ @if (!isset($provider) || empty($provider) || $provider === '') - + @else @endif - +
Create an API token in the {{ ucfirst($provider) }} Console. + href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : ($provider === 'vultr' ? 'https://console.vultr.com/user/apiaccess/' : 'https://cloud.digitalocean.com/account/api/tokens') }}' + target='_blank' class='underline dark:text-white'>{{ $provider === 'digitalocean' ? 'DigitalOcean' : ucfirst($provider) }} Console. @if ($provider === 'hetzner') Choose Project → Security → API Tokens.

@@ -30,6 +31,9 @@ class='underline dark:text-white'>Sign up here (Coolify's affiliate link, only new accounts - supports us (€10) and gives you €20) @endif + @if ($provider === 'digitalocean') + Generate New Token. + @endif @if ($provider === 'vultr') Open Account → API Access.

@@ -47,7 +51,7 @@ class='underline dark:text-white'>Sign up here
- +
@@ -60,8 +64,8 @@ class='underline dark:text-white'>Sign up here placeholder="Enter your API token" />
Create an API token in the {{ ucfirst($provider) }} Console. + href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : ($provider === 'vultr' ? 'https://console.vultr.com/user/apiaccess/' : 'https://cloud.digitalocean.com/account/api/tokens') }}' + target='_blank' class='underline dark:text-white'>{{ $provider === 'digitalocean' ? 'DigitalOcean' : ucfirst($provider) }} Console. @if ($provider === 'hetzner') Choose Project → Security → API Tokens.

@@ -71,6 +75,9 @@ class='underline dark:text-white'>Sign up here (Coolify's affiliate link, only new accounts - supports us (€10) and gives you €20) @endif + @if ($provider === 'digitalocean') + Generate New Token. + @endif @if ($provider === 'vultr') Open Account → API Access.

diff --git a/resources/views/livewire/server/create.blade.php b/resources/views/livewire/server/create.blade.php index 6b69d0ace..0fc972038 100644 --- a/resources/views/livewire/server/create.blade.php +++ b/resources/views/livewire/server/create.blade.php @@ -47,6 +47,27 @@
+ +
+ + +
+
+ +
+
Connect a DigitalOcean Droplet
+
+ Deploy servers directly from your DigitalOcean account +
+
+
+
+
+ +
+
+ +
@endcan
diff --git a/resources/views/livewire/server/new/by-digital-ocean.blade.php b/resources/views/livewire/server/new/by-digital-ocean.blade.php new file mode 100644 index 000000000..4523b1492 --- /dev/null +++ b/resources/views/livewire/server/new/by-digital-ocean.blade.php @@ -0,0 +1,227 @@ +
+ @if ($limit_reached) + + @else + @if ($current_step === 1) +
+ @if ($available_tokens->count() > 0) +
+
+ + + @foreach ($available_tokens as $token) + + @endforeach + +
+
+ + Continue + +
+
+ +
OR
+ @endif + + + + +
+ @elseif ($current_step === 2) + @if ($loading_data) +
+
+
+

Loading DigitalOcean data...

+
+
+ @else +
+
+ +
+ +
+ + + @foreach ($regions as $region) + + @endforeach + +
+ +
+ + + @foreach ($this->availableSizes as $size) + + @endforeach + +
+ +
+ + + @foreach ($this->availableImages as $image) + + @endforeach + +
+ +
+ @if ($private_keys->count() === 0) +
+ +
+

+ No private keys found. You need to create a private key to continue. +

+ + + +
+
+ @else + + + @foreach ($private_keys as $key) + + @endforeach + +

+ This SSH key will be automatically added to your DigitalOcean account and used to access + the server. +

+ @endif +
+ + + + Advanced DigitalOcean options + SSH keys, networking, monitoring, and cloud-init. + @if (count($this->advancedDigitalOceanOptionsSummary) > 0) + + @foreach ($this->advancedDigitalOceanOptionsSummary as $summaryItem) + + {{ $summaryItem }} + + @endforeach + + @endif + + +
+
+ + @foreach ($digitalOceanSshKeys as $sshKey) + + @endforeach + +
+ +
+ + +
+ +
+ @if (! $show_cloud_init_script && empty($cloud_init_script) && empty($selected_cloud_init_script_id)) +
+ + Add cloud-init script + +
+ @else +
+ + @if ($saved_cloud_init_scripts->count() > 0) +
+ + + @foreach ($saved_cloud_init_scripts as $script) + + @endforeach + + + Clear + +
+ @else + + Remove + + @endif +
+ + +
+ + @if ($save_cloud_init_script) +
+ +
+ @endif +
+ @endif +
+
+
+ +
+ + Back + + + Buy & Create Server{{ $this->selectedDropletPrice ? ' (' . $this->selectedDropletPrice . '/mo)' : '' }} + +
+
+ @endif + @endif + @endif +
diff --git a/resources/views/livewire/server/show.blade.php b/resources/views/livewire/server/show.blade.php index 3111e83f0..d03d10dfb 100644 --- a/resources/views/livewire/server/show.blade.php +++ b/resources/views/livewire/server/show.blade.php @@ -1,5 +1,5 @@
+ x-init="@if ($server->hetzner_server_id && $server->cloudProviderToken && !$hetznerServerStatus) $wire.checkHetznerServerStatus(); @endif @if ($server->vultr_instance_id && $server->cloudProviderToken) $wire.checkVultrInstanceStatus(); @endif @if ($server->digitalocean_droplet_id && $server->cloudProviderToken && !$digitalOceanDropletStatus) $wire.checkDigitalOceanDropletStatus(); @endif"> {{ data_get_str($server, 'name')->limit(10) }} > General | Coolify @@ -147,6 +147,57 @@ class="mx-1 dark:hover:fill-white fill-black dark:fill-warning"> @endif @endif + @if ($server->digitalocean_droplet_id) +
+
+ + + @if ($digitalOceanDropletStatus === 'new') + + + + + + @endif + $digitalOceanDropletStatus === 'active', + 'text-red-500' => in_array($digitalOceanDropletStatus, ['off', 'archive', 'deleted']), + ])> + {{ ucfirst($digitalOceanDropletStatus ?? 'checking') }} + + +
+ + +
+ @if ($server->cloudProviderToken && $digitalOceanDropletStatus === 'off') + + Power On + + @endif + @endif @if ($isValidating)
@@ -217,7 +268,8 @@ class="mt-2 block overflow-x-auto rounded bg-black px-3 py-2 font-mono text-xs t $server->id !== 0 && !$isValidating && !in_array($hetznerServerStatus, ['initializing', 'starting', 'stopping', 'off']) && - !in_array($vultrInstanceStatus, ['pending', 'starting', 'stopped', 'suspended', 'deleted'])) + !in_array($vultrInstanceStatus, ['pending', 'starting', 'stopped', 'suspended', 'deleted']) && + !in_array($digitalOceanDropletStatus, ['new', 'off', 'archive', 'deleted'])) Validate & configure @@ -582,6 +634,76 @@ class="dark:hover:fill-white fill-black dark:fill-warning"> @endif
@endif + @if (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty()) +
+

Link to DigitalOcean

+

+ Link this server to a DigitalOcean droplet to enable power controls and status monitoring. +

+ +
+
+ + + @foreach ($availableDigitalOceanTokens as $token) + + @endforeach + +
+
+ +
+ + Search by ID + Searching... + +
OR
+ + Search by IP + Searching... + +
+ + @if ($digitalOceanSearchError) +
+

{{ $digitalOceanSearchError }}

+
+ @endif + + @if ($digitalOceanNoMatchFound) +
+

+ @if ($manualDigitalOceanDropletId) + No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }} + @else + No DigitalOcean droplet found matching IP: {{ $server->ip }} + @endif +

+
+ @endif + + @if ($matchedDigitalOceanDroplet) +
+

Match Found!

+
+
Name: {{ $matchedDigitalOceanDroplet['name'] ?? 'Unknown' }}
+
ID: {{ $matchedDigitalOceanDroplet['id'] }}
+
Status: {{ ucfirst($matchedDigitalOceanDroplet['status'] ?? 'unknown') }}
+
Size: {{ data_get($matchedDigitalOceanDroplet, 'size.slug', 'Unknown') }}
+
+ + Link This Server + +
+ @endif +
+ @endif
diff --git a/routes/api.php b/routes/api.php index 45cddaaff..e9fee3e55 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Api\DatabasesController; use App\Http\Controllers\Api\DeployController; use App\Http\Controllers\Api\DestinationsController; +use App\Http\Controllers\Api\DigitalOceanController; use App\Http\Controllers\Api\GithubController; use App\Http\Controllers\Api\HetznerController; use App\Http\Controllers\Api\OtherController; @@ -117,6 +118,12 @@ Route::get('/vultr/ssh-keys', [VultrController::class, 'sshKeys'])->middleware(['api.ability:read']); Route::post('/servers/vultr', [VultrController::class, 'createServer'])->middleware(['api.ability:write']); + Route::get('/digitalocean/regions', [DigitalOceanController::class, 'regions'])->middleware(['api.ability:read']); + Route::get('/digitalocean/sizes', [DigitalOceanController::class, 'sizes'])->middleware(['api.ability:read']); + Route::get('/digitalocean/images', [DigitalOceanController::class, 'images'])->middleware(['api.ability:read']); + Route::get('/digitalocean/ssh-keys', [DigitalOceanController::class, 'sshKeys'])->middleware(['api.ability:read']); + Route::post('/servers/digitalocean', [DigitalOceanController::class, 'createServer'])->middleware(['api.ability:write']); + Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']); Route::get('/tags', [TagsController::class, 'tags'])->middleware(['api.ability:read']); diff --git a/tests/Feature/DigitalOceanApiTest.php b/tests/Feature/DigitalOceanApiTest.php new file mode 100644 index 000000000..04c1e16be --- /dev/null +++ b/tests/Feature/DigitalOceanApiTest.php @@ -0,0 +1,173 @@ +whereKey(0)->delete(); + $settings = new InstanceSettings(['is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + $this->token = $this->user->createToken('test-token', ['*']); + $this->bearerToken = $this->token->plainTextToken; + + $this->digitalOceanToken = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'digitalocean', + 'token' => 'test-digitalocean-api-token', + ]); + + $this->privateKey = PrivateKey::factory()->create([ + 'team_id' => $this->team->id, + ]); +}); + +describe('GET /api/v1/digitalocean/regions', function () { + test('gets DigitalOcean regions', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/regions*' => Http::response([ + 'regions' => [ + ['slug' => 'nyc1', 'name' => 'New York 1', 'available' => true], + ['slug' => 'ams3', 'name' => 'Amsterdam 3', 'available' => true], + ], + 'links' => ['pages' => []], + ], 200), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/digitalocean/regions?cloud_provider_token_id='.$this->digitalOceanToken->uuid); + + $response->assertSuccessful(); + $response->assertJsonCount(2); + $response->assertJsonFragment(['slug' => 'nyc1']); + }); + + test('requires cloud_provider_token_id parameter', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->getJson('/api/v1/digitalocean/regions'); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors(['cloud_provider_token_id']); + }); +}); + +describe('POST /api/v1/servers/digitalocean', function () { + test('creates a DigitalOcean droplet server', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/account/keys' => Http::response([ + 'ssh_key' => ['id' => 123, 'fingerprint' => 'aa:bb:cc:dd'], + ], 201), + 'https://api.digitalocean.com/v2/account/keys*' => Http::response([ + 'ssh_keys' => [], + 'links' => ['pages' => []], + ], 200), + 'https://api.digitalocean.com/v2/droplets' => Http::response([ + 'droplet' => [ + 'id' => 987, + 'name' => 'test-server', + 'status' => 'new', + 'networks' => [ + 'v4' => [ + ['ip_address' => '203.0.113.10', 'type' => 'public'], + ], + 'v6' => [ + ['ip_address' => '2001:db8::10', 'type' => 'public'], + ], + ], + ], + ], 202), + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/digitalocean', [ + 'cloud_provider_token_id' => $this->digitalOceanToken->uuid, + 'region' => 'nyc1', + 'size' => 's-1vcpu-1gb', + 'image' => 'ubuntu-24-04-x64', + 'name' => 'test-server', + 'private_key_uuid' => $this->privateKey->uuid, + 'enable_ipv6' => true, + 'monitoring' => true, + 'cloud_init_script' => '#cloud-config', + ]); + + $response->assertStatus(201); + $response->assertJsonStructure(['uuid', 'digitalocean_droplet_id', 'ip']); + $response->assertJsonFragment(['digitalocean_droplet_id' => 987, 'ip' => '203.0.113.10']); + + $this->assertDatabaseHas('servers', [ + 'name' => 'test-server', + 'ip' => '203.0.113.10', + 'team_id' => $this->team->id, + 'digitalocean_droplet_id' => 987, + ]); + + Http::assertSent(fn ($request) => $request->url() === 'https://api.digitalocean.com/v2/droplets' + && $request['region'] === 'nyc1' + && $request['size'] === 's-1vcpu-1gb' + && $request['image'] === 'ubuntu-24-04-x64' + && $request['ssh_keys'] === [123] + && $request['ipv6'] === true + && $request['monitoring'] === true + && $request['user_data'] === '#cloud-config'); + }); + + test('validates required fields', function () { + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/digitalocean', ['name' => 'missing-required-fields']); + + $response->assertUnprocessable(); + $response->assertJsonValidationErrors([ + 'cloud_provider_token_id', + 'region', + 'size', + 'image', + 'private_key_uuid', + ]); + }); + + test('returns 404 for a non-DigitalOcean token', function () { + $hetznerToken = CloudProviderToken::factory()->create([ + 'team_id' => $this->team->id, + 'provider' => 'hetzner', + 'token' => 'test-hetzner-api-token', + ]); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$this->bearerToken, + 'Content-Type' => 'application/json', + ])->postJson('/api/v1/servers/digitalocean', [ + 'cloud_provider_token_id' => $hetznerToken->uuid, + 'region' => 'nyc1', + 'size' => 's-1vcpu-1gb', + 'image' => 'ubuntu-24-04-x64', + 'private_key_uuid' => $this->privateKey->uuid, + ]); + + $response->assertNotFound(); + $response->assertJson(['message' => 'DigitalOcean cloud provider token not found.']); + }); +}); diff --git a/tests/Feature/ModelFillableCreationTest.php b/tests/Feature/ModelFillableCreationTest.php index e6d340a4f..50b6e02a9 100644 --- a/tests/Feature/ModelFillableCreationTest.php +++ b/tests/Feature/ModelFillableCreationTest.php @@ -84,6 +84,8 @@ 'team_id' => $this->team->id, 'hetzner_server_id' => 'htz-12345', 'hetzner_server_status' => 'running', + 'digitalocean_droplet_id' => 987654, + 'digitalocean_droplet_status' => 'active', 'is_validating' => false, 'detected_traefik_version' => 'v2.10.0', 'traefik_outdated_info' => 'Up to date', @@ -101,6 +103,8 @@ expect($server->cloud_provider_token_id)->toBe($cloudToken->id); expect($server->hetzner_server_id)->toBe('htz-12345'); expect($server->hetzner_server_status)->toBe('running'); + expect($server->digitalocean_droplet_id)->toBe(987654); + expect($server->digitalocean_droplet_status)->toBe('active'); expect($server->ip_previous)->toBe('10.0.0.1'); }); diff --git a/tests/Feature/Security/CloudProviderTokenFormTest.php b/tests/Feature/Security/CloudProviderTokenFormTest.php new file mode 100644 index 000000000..3ac91bbb1 --- /dev/null +++ b/tests/Feature/Security/CloudProviderTokenFormTest.php @@ -0,0 +1,54 @@ +whereKey(0)->exists()) { + $settings = new InstanceSettings; + $settings->id = 0; + $settings->save(); + } + + Once::flush(); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + + session(['currentTeam' => $this->team]); + $this->actingAs($this->user); + + Log::spy(); +}); + +test('adding a digitalocean token from a modal closes the modal and refreshes digitalocean token selectors', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/account' => Http::response([], 200), + ]); + + $component = Livewire::test(CloudProviderTokenForm::class, [ + 'modal_mode' => true, + 'provider' => 'digitalocean', + ]) + ->set('name', 'Production DigitalOcean') + ->set('token', 'digitalocean-token') + ->call('addToken') + ->assertDispatched('close-modal'); + + $dispatches = collect(data_get($component->effects, 'dispatches', [])); + + expect($dispatches->contains(fn (array $dispatch) => $dispatch['name'] === 'tokenAdded.digitalocean' + && data_get($dispatch, 'to') === 'server.new.by-digital-ocean'))->toBeTrue() + ->and($dispatches->contains(fn (array $dispatch) => $dispatch['name'] === 'tokenAdded' + && data_get($dispatch, 'to') === 'security.cloud-provider-tokens'))->toBeTrue(); +}); diff --git a/tests/Unit/DigitalOceanDeleteServerTest.php b/tests/Unit/DigitalOceanDeleteServerTest.php new file mode 100644 index 000000000..5b0989607 --- /dev/null +++ b/tests/Unit/DigitalOceanDeleteServerTest.php @@ -0,0 +1,46 @@ + Http::response(null, 204), + ]); + + $team = Team::create([ + 'name' => 'Test Team', + 'personal_team' => false, + ]); + $token = CloudProviderToken::factory()->create([ + 'team_id' => $team->id, + 'provider' => 'digitalocean', + 'token' => 'test-digitalocean-token', + ]); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + $server = Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'digitalocean_droplet_id' => 987, + ]); + + DeleteServer::run( + serverId: $server->id, + deleteFromDigitalOcean: true, + digitalOceanDropletId: 987, + cloudProviderTokenId: $token->id, + teamId: $team->id, + ); + + Http::assertSent(fn ($request) => $request->method() === 'DELETE' + && $request->url() === 'https://api.digitalocean.com/v2/droplets/987'); +}); diff --git a/tests/Unit/DigitalOceanIconTest.php b/tests/Unit/DigitalOceanIconTest.php new file mode 100644 index 000000000..ed809c699 --- /dev/null +++ b/tests/Unit/DigitalOceanIconTest.php @@ -0,0 +1,34 @@ +toContain('viewBox="0 0 24 24"') + ->toContain('M12.04 0C5.408-.02.005 5.37.005 11.992h4.638') + ->toContain('DigitalOcean'); +}); + +it('does not use the old custom DigitalOcean icon in Blade views', function () { + $oldIconPaths = [ + 'M100 42c31.5 0 57 25.5 57 57s-25.5 57-57 57H72v-36h28c11.6 0 21-9.4 21-21s-9.4-21-21-21-21 9.4-21 21H43c0-31.5 25.5-57 57-57z', + 'M43 120h36v36H43zM79 156h28v28H79zM43 156h28v28H43z', + ]; + + $bladeFiles = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator(__DIR__.'/../../resources/views') + ); + + foreach ($bladeFiles as $bladeFile) { + if (! $bladeFile->isFile() || $bladeFile->getExtension() !== 'php') { + continue; + } + + $contents = file_get_contents($bladeFile->getPathname()); + + foreach ($oldIconPaths as $oldIconPath) { + expect($contents) + ->not->toContain($oldIconPath, $bladeFile->getPathname().' contains the old DigitalOcean icon.'); + } + } +}); diff --git a/tests/Unit/DigitalOceanServerStateTest.php b/tests/Unit/DigitalOceanServerStateTest.php new file mode 100644 index 000000000..7a6a123de --- /dev/null +++ b/tests/Unit/DigitalOceanServerStateTest.php @@ -0,0 +1,59 @@ + 'Test Team', + 'personal_team' => false, + ]); + $token = CloudProviderToken::create([ + 'team_id' => $team->id, + 'provider' => 'digitalocean', + 'token' => 'test-digitalocean-token', + 'name' => 'DigitalOcean', + ]); + $privateKey = PrivateKey::factory()->create(['team_id' => $team->id]); + + return Server::factory()->create([ + 'team_id' => $team->id, + 'private_key_id' => $privateKey->id, + 'cloud_provider_token_id' => $token->id, + 'digitalocean_droplet_id' => 987, + 'digitalocean_droplet_status' => $status, + ]); +} + +it('marks a DigitalOcean droplet as deleted when the provider returns 404', function () { + $server = createDigitalOceanServerForStateTest(); + + Http::fake([ + 'https://api.digitalocean.com/v2/droplets/987' => Http::response(['message' => 'not found'], 404), + ]); + + expect($server->refreshDigitalOceanState())->toBe('deleted'); + + expect($server->fresh()->digitalocean_droplet_status)->toBe('deleted'); +}); + +it('does not mark a DigitalOcean droplet as deleted on transient provider errors', function () { + $server = createDigitalOceanServerForStateTest(); + + Http::fake([ + 'https://api.digitalocean.com/v2/droplets/987' => Http::response(['message' => 'server error'], 500), + ]); + + expect(fn () => $server->refreshDigitalOceanState()) + ->toThrow(Exception::class); + + expect($server->fresh()->digitalocean_droplet_status)->toBe('active'); +}); diff --git a/tests/Unit/DigitalOceanServiceTest.php b/tests/Unit/DigitalOceanServiceTest.php new file mode 100644 index 000000000..784926838 --- /dev/null +++ b/tests/Unit/DigitalOceanServiceTest.php @@ -0,0 +1,183 @@ + Http::response([ + 'regions' => [ + ['slug' => 'nyc1', 'name' => 'New York 1', 'available' => true], + ], + 'links' => ['pages' => ['next' => 'https://api.digitalocean.com/v2/regions?page=2&per_page=50']], + ]), + 'https://api.digitalocean.com/v2/regions?page=2&per_page=50' => Http::response([ + 'regions' => [ + ['slug' => 'ams3', 'name' => 'Amsterdam 3', 'available' => true], + ], + 'links' => ['pages' => []], + ]), + ]); + + $regions = (new DigitalOceanService('test-token'))->getRegions(); + + expect($regions)->toHaveCount(2) + ->and($regions[0]['slug'])->toBe('nyc1') + ->and($regions[1]['slug'])->toBe('ams3'); +}); + +it('creates a droplet with the selected SSH keys and options', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/droplets' => Http::response([ + 'droplet' => [ + 'id' => 987, + 'name' => 'coolify-test', + 'status' => 'new', + 'networks' => [ + 'v4' => [ + ['ip_address' => '203.0.113.10', 'type' => 'public'], + ], + ], + ], + ], 202), + ]); + + $droplet = (new DigitalOceanService('test-token'))->createDroplet([ + 'name' => 'coolify-test', + 'region' => 'nyc1', + 'size' => 's-1vcpu-1gb', + 'image' => 'ubuntu-24-04-x64', + 'ssh_keys' => [123], + 'ipv6' => true, + 'monitoring' => true, + 'user_data' => '#cloud-config', + ]); + + expect($droplet['id'])->toBe(987); + + Http::assertSent(fn ($request) => $request->url() === 'https://api.digitalocean.com/v2/droplets' + && $request['ssh_keys'] === [123] + && $request['ipv6'] === true + && $request['monitoring'] === true + && $request['user_data'] === '#cloud-config'); +}); + +it('extracts a public IPv4 address before falling back to IPv6', function () { + $droplet = [ + 'networks' => [ + 'v4' => [ + ['ip_address' => '10.0.0.2', 'type' => 'private'], + ['ip_address' => '203.0.113.10', 'type' => 'public'], + ], + 'v6' => [ + ['ip_address' => '2001:db8::10', 'type' => 'public'], + ], + ], + ]; + + $service = new DigitalOceanService('test-token'); + + expect($service->getPublicIpAddress($droplet, true, true))->toBe('203.0.113.10') + ->and($service->getPublicIpAddress($droplet, false, true))->toBe('2001:db8::10'); +}); + +it('waits for DigitalOcean to assign a public IP', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/droplets/987' => Http::response([ + 'droplet' => [ + 'id' => 987, + 'status' => 'active', + 'networks' => [ + 'v4' => [ + ['ip_address' => '203.0.113.10', 'type' => 'public'], + ], + ], + ], + ]), + ]); + + $service = new DigitalOceanService('test-token'); + $droplet = $service->waitForPublicIp([ + 'id' => 987, + 'status' => 'new', + 'networks' => ['v4' => []], + ], sleepMilliseconds: 0); + + expect($service->getPublicIpAddress($droplet))->toBe('203.0.113.10'); +}); + +it('keeps polling when DigitalOcean assigns a public IP slowly', function () { + $polls = 0; + + Http::fake(function () use (&$polls) { + $polls++; + + return Http::response([ + 'droplet' => [ + 'id' => 987, + 'status' => $polls < 10 ? 'new' : 'active', + 'networks' => [ + 'v4' => $polls < 10 ? [] : [ + ['ip_address' => '203.0.113.10', 'type' => 'public'], + ], + ], + ], + ]); + }); + + $service = new DigitalOceanService('test-token'); + $droplet = $service->waitForPublicIp([ + 'id' => 987, + 'status' => 'new', + 'networks' => ['v4' => []], + ], sleepMilliseconds: 0); + + expect($service->getPublicIpAddress($droplet))->toBe('203.0.113.10') + ->and($polls)->toBe(10); +}); + +it('finds a droplet by public IP', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/droplets*' => Http::response([ + 'droplets' => [ + [ + 'id' => 987, + 'networks' => [ + 'v4' => [ + ['ip_address' => '203.0.113.10', 'type' => 'public'], + ], + 'v6' => [ + ['ip_address' => '2001:db8::10', 'type' => 'public'], + ], + ], + ], + ], + 'links' => ['pages' => []], + ]), + ]); + + $service = new DigitalOceanService('test-token'); + + expect($service->findDropletByIp('203.0.113.10')['id'])->toBe(987) + ->and($service->findDropletByIp('2001:db8::10')['id'])->toBe(987) + ->and($service->findDropletByIp('198.51.100.1'))->toBeNull(); +}); + +it('powers on and deletes a DigitalOcean droplet', function () { + Http::fake([ + 'https://api.digitalocean.com/v2/droplets/987/actions' => Http::response([ + 'action' => ['id' => 111, 'type' => 'power_on'], + ], 201), + 'https://api.digitalocean.com/v2/droplets/987' => Http::response(null, 204), + ]); + + $service = new DigitalOceanService('test-token'); + + expect($service->powerOnDroplet(987)['type'])->toBe('power_on'); + $service->deleteDroplet(987); + + Http::assertSentCount(2); +});