feat(digitalocean): add droplet provisioning support

This commit is contained in:
Andras Bacsai 2026-07-08 10:42:58 +02:00
parent 5936874ffd
commit c303c34cfd
32 changed files with 2796 additions and 19 deletions

View file

@ -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,
]);
}
}
}

View file

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

View file

@ -0,0 +1,382 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Controller;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\DigitalOceanService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class DigitalOceanController extends Controller
{
private function getCloudProviderTokenUuid(Request $request): ?string
{
return $request->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'];
}
}

View file

@ -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', [

View file

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

View file

@ -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) {

View file

@ -35,7 +35,7 @@ public function mount(string $server_uuid)
public function getListeners()
{
return [
'tokenAdded' => 'handleTokenAdded',
'tokenAdded.hetzner' => 'handleTokenAdded',
];
}

View file

@ -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,
]);

View file

@ -0,0 +1,455 @@
<?php
namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\DigitalOceanService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Component;
class ByDigitalOcean extends Component
{
use AuthorizesRequests;
public int $current_step = 1;
#[Locked]
public Collection $available_tokens;
#[Locked]
public $private_keys;
#[Locked]
public $limit_reached;
public ?int $selected_token_id = null;
public array $regions = [];
public array $images = [];
public array $sizes = [];
public array $digitalOceanSshKeys = [];
public ?string $selected_region = null;
public string|int|null $selected_image = null;
public ?string $selected_size = null;
public array $selectedDigitalOceanSshKeyIds = [];
public string $server_name = '';
public ?int $private_key_id = null;
public bool $loading_data = false;
public bool $enable_ipv6 = true;
public bool $monitoring = true;
public bool $show_cloud_init_script = false;
public ?string $cloud_init_script = null;
public bool $save_cloud_init_script = false;
public ?string $cloud_init_script_name = null;
public ?int $selected_cloud_init_script_id = null;
#[Locked]
public Collection $saved_cloud_init_scripts;
public bool $from_onboarding = false;
public function mount()
{
try {
$this->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');
}
}

View file

@ -114,7 +114,7 @@ public function loadSavedCloudInitScripts()
public function getListeners()
{
return [
'tokenAdded' => 'handleTokenAdded',
'tokenAdded.hetzner' => 'handleTokenAdded',
'privateKeyCreated' => 'handlePrivateKeyCreated',
'modalClosed' => 'resetSelection',
];

View file

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

View file

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

View file

@ -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',

View file

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

View file

@ -0,0 +1,221 @@
<?php
namespace App\Services;
use App\Exceptions\RateLimitException;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
class DigitalOceanService
{
private string $baseUrl = 'https://api.digitalocean.com/v2';
public function __construct(private string $token) {}
private function request(string $method, string $endpoint, array $data = []): array
{
$response = Http::withToken($this->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;
}
}

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('servers', function (Blueprint $table) {
if (! Schema::hasColumn('servers', 'digitalocean_droplet_id')) {
$table->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');
}
});
}
};

View file

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

View file

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

View file

@ -0,0 +1,6 @@
<svg {{ $attributes->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">
<title>DigitalOcean</title>
<path
d="M12.04 0C5.408-.02.005 5.37.005 11.992h4.638c0-4.923 4.882-8.731 10.064-6.855a6.95 6.95 0 014.147 4.148c1.889 5.177-1.924 10.055-6.84 10.064v-4.61H7.391v4.623h4.61V24c7.86 0 13.967-7.588 11.397-15.83-1.115-3.59-3.985-6.446-7.575-7.575A12.8 12.8 0 0012.039 0zM7.39 19.362H3.828v3.564H7.39zm-3.563 0v-2.978H.85v2.978z" />
</svg>

After

Width:  |  Height:  |  Size: 534 B

View file

@ -17,7 +17,7 @@
<div x-data="{ modalOpen: false }"
x-init="$watch('modalOpen', value => { if (!value) { $wire.dispatch('modalClosed') } })"
:class="{ 'z-40': modalOpen }" @keydown.window.escape="modalOpen=false"
class="relative w-auto h-auto" wire:ignore>
class="relative w-auto h-auto" @close-modal.window="modalOpen=false" wire:ignore>
@if ($content)
<div @click="modalOpen=true">
{{ $content }}

View file

@ -5,22 +5,23 @@
@if (!isset($provider) || empty($provider) || $provider === '')
<x-forms.select required id="provider" label="Provider" wire:model.live="provider">
<option value="hetzner">Hetzner</option>
<option disabled value="digitalocean">DigitalOcean</option>
<option value="digitalocean">DigitalOcean</option>
<option value="vultr">Vultr</option>
</x-forms.select>
@else
<input type="hidden" wire:model="provider" />
@endif
<x-forms.input required id="name" label="Token Name" placeholder="e.g., Production cloud token" />
<x-forms.input required id="name" label="Token Name"
placeholder="e.g., Production {{ $provider === 'digitalocean' ? 'DigitalOcean' : ucfirst($provider) }} token. tip: add project name to identify easier" />
<x-forms.input required type="password" id="token" label="API Token"
placeholder="Enter your API token" />
<div class="text-sm text-neutral-500 dark:text-neutral-400">
Create an API token in the <a
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : ($provider === 'vultr' ? 'https://console.vultr.com/user/apiaccess/' : '#') }}'
target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a>.
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</a>.
@if ($provider === 'hetzner')
Choose Project Security API Tokens.
<br><br>
@ -30,6 +31,9 @@ class='underline dark:text-white'>Sign up here</a>
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
and gives you €20)</span>
@endif
@if ($provider === 'digitalocean')
Generate New Token.
@endif
@if ($provider === 'vultr')
Open Account API Access.
<br><br>
@ -47,7 +51,7 @@ class='underline dark:text-white'>Sign up here</a>
<div class="w-64">
<x-forms.select required id="provider" label="Provider" wire:model.live="provider">
<option value="hetzner">Hetzner</option>
<option disabled value="digitalocean">DigitalOcean</option>
<option value="digitalocean">DigitalOcean</option>
<option value="vultr">Vultr</option>
</x-forms.select>
</div>
@ -60,8 +64,8 @@ class='underline dark:text-white'>Sign up here</a>
placeholder="Enter your API token" />
<div class="text-sm text-neutral-500 dark:text-neutral-400 mt-2">
Create an API token in the <a
href='{{ $provider === 'hetzner' ? 'https://console.hetzner.com/projects' : ($provider === 'vultr' ? 'https://console.vultr.com/user/apiaccess/' : '#') }}'
target='_blank' class='underline dark:text-white'>{{ ucfirst($provider) }} Console</a>.
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</a>.
@if ($provider === 'hetzner')
Choose Project Security API Tokens.
<br><br>
@ -71,6 +75,9 @@ class='underline dark:text-white'>Sign up here</a>
<span class="text-xs">(Coolify's affiliate link, only new accounts - supports us (€10)
and gives you €20)</span>
@endif
@if ($provider === 'digitalocean')
Generate New Token.
@endif
@if ($provider === 'vultr')
Open Account API Access.
<br><br>

View file

@ -47,6 +47,27 @@
</div>
<div class="border-t dark:border-coolgray-300 my-4"></div>
<div>
<x-modal-input title="Connect a DigitalOcean Droplet">
<x-slot:content>
<div class="relative gap-2 cursor-pointer coolbox group">
<div class="flex items-center gap-4 mx-6">
<x-digital-ocean-icon class="w-10 h-10 flex-shrink-0" />
<div class="flex flex-col justify-center flex-1">
<div class="box-title">Connect a DigitalOcean Droplet</div>
<div class="box-description">
Deploy servers directly from your DigitalOcean account
</div>
</div>
</div>
</div>
</x-slot:content>
<livewire:server.new.by-digital-ocean :private_keys="$private_keys" :limit_reached="$limit_reached" />
</x-modal-input>
</div>
<div class="border-t dark:border-coolgray-300 my-4"></div>
@endcan
<div>

View file

@ -0,0 +1,227 @@
<div class="w-full">
@if ($limit_reached)
<x-limit-reached name="servers" />
@else
@if ($current_step === 1)
<div class="flex flex-col w-full gap-4">
@if ($available_tokens->count() > 0)
<div class="flex gap-2">
<div class="flex-1">
<x-forms.select label="Select DigitalOcean Token" id="selected_token_id"
wire:change="selectToken($event.target.value)" required>
<option value="">Select a saved token...</option>
@foreach ($available_tokens as $token)
<option value="{{ $token->id }}">
{{ $token->name ?? 'DigitalOcean Token' }}
</option>
@endforeach
</x-forms.select>
</div>
<div class="flex items-end">
<x-forms.button canGate="create" :canResource="App\Models\Server::class" wire:click="nextStep"
:disabled="!$selected_token_id">
Continue
</x-forms.button>
</div>
</div>
<div class="text-center text-sm dark:text-neutral-500">OR</div>
@endif
<x-modal-input isFullWidth
buttonTitle="{{ $available_tokens->count() > 0 ? '+ Add New Token' : 'Add DigitalOcean Token' }}"
title="Add DigitalOcean Token">
<livewire:security.cloud-provider-token-form :modal_mode="true" provider="digitalocean" />
</x-modal-input>
</div>
@elseif ($current_step === 2)
@if ($loading_data)
<div class="flex items-center justify-center py-8">
<div class="text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto"></div>
<p class="mt-4 text-sm dark:text-neutral-400">Loading DigitalOcean data...</p>
</div>
</div>
@else
<form class="flex flex-col w-full gap-2" wire:submit='submit'>
<div>
<x-forms.input id="server_name" label="Server Name" helper="A friendly name for your server." />
</div>
<div>
<x-forms.select label="Region" id="selected_region" wire:model.live="selected_region" required>
<option value="">Select a region...</option>
@foreach ($regions as $region)
<option value="{{ $region['slug'] }}">
{{ $region['name'] ?? $region['slug'] }}
</option>
@endforeach
</x-forms.select>
</div>
<div>
<x-forms.select label="Size" id="selected_size" wire:model.live="selected_size" required
:disabled="!$selected_region">
<option value="">
{{ $selected_region ? 'Select a size...' : 'Select a region first' }}
</option>
@foreach ($this->availableSizes as $size)
<option value="{{ $size['slug'] }}">
{{ $size['slug'] }} - {{ $size['memory'] ?? '?' }}MB RAM,
{{ $size['vcpus'] ?? '?' }} vCPU
@if (isset($size['disk']))
, {{ $size['disk'] }}GB
@endif
@if (isset($size['price_monthly']))
- ${{ number_format((float) $size['price_monthly'], 2) }}/mo
@endif
</option>
@endforeach
</x-forms.select>
</div>
<div>
<x-forms.select label="Image" id="selected_image" required :disabled="!$selected_size">
<option value="">
{{ $selected_size ? 'Select an image...' : 'Select a size first' }}
</option>
@foreach ($this->availableImages as $image)
<option value="{{ $image['slug'] ?? $image['id'] }}">
{{ trim(($image['distribution'] ?? '') . ' ' . ($image['name'] ?? $image['slug'] ?? $image['id'])) }}
</option>
@endforeach
</x-forms.select>
</div>
<div>
@if ($private_keys->count() === 0)
<div class="flex flex-col gap-2">
<label class="flex gap-1 items-center mb-1 text-sm font-medium">
Private Key
<x-highlighted text="*" />
</label>
<div
class="p-4 border border-warning-500 dark:border-warning-600 rounded bg-warning-50 dark:bg-warning-900/10">
<p class="text-sm mb-3 text-neutral-700 dark:text-neutral-300">
No private keys found. You need to create a private key to continue.
</p>
<x-modal-input buttonTitle="Create New Private Key" title="New Private Key" isHighlightedButton>
<livewire:security.private-key.create :modal_mode="true" from="server" />
</x-modal-input>
</div>
</div>
@else
<x-forms.select label="Private Key" id="private_key_id" required>
<option value="">Select a private key...</option>
@foreach ($private_keys as $key)
<option value="{{ $key->id }}">
{{ $key->name }}
</option>
@endforeach
</x-forms.select>
<p class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
This SSH key will be automatically added to your DigitalOcean account and used to access
the server.
</p>
@endif
</div>
<x-dropdown inline panelClass="max-h-[55vh] overflow-y-auto scrollbar">
<x-slot:title>
<span class="text-sm font-medium">Advanced DigitalOcean options</span>
<span class="mt-1 text-xs text-neutral-500 dark:text-neutral-400">SSH keys, networking, monitoring, and cloud-init.</span>
@if (count($this->advancedDigitalOceanOptionsSummary) > 0)
<span class="mt-1 flex flex-wrap gap-1.5">
@foreach ($this->advancedDigitalOceanOptionsSummary as $summaryItem)
<span class="rounded bg-neutral-200 px-2 py-0.5 text-xs text-neutral-700 dark:bg-coolgray-100 dark:text-neutral-300">
{{ $summaryItem }}
</span>
@endforeach
</span>
@endif
</x-slot>
<div class="flex w-full flex-col gap-4 p-3">
<div>
<x-forms.datalist label="Extra SSH Keys" id="selectedDigitalOceanSshKeyIds"
helper="Select existing SSH keys from your DigitalOcean account to add to this Droplet. The Coolify SSH key will be automatically added."
:multiple="true" :disabled="count($digitalOceanSshKeys) === 0" :placeholder="count($digitalOceanSshKeys) > 0
? 'Search and select SSH keys...'
: 'No SSH keys found in DigitalOcean account'">
@foreach ($digitalOceanSshKeys as $sshKey)
<option value="{{ $sshKey['id'] }}">
{{ $sshKey['name'] ?? $sshKey['fingerprint'] }}
@if (isset($sshKey['fingerprint']))
- {{ substr($sshKey['fingerprint'], 0, 20) }}...
@endif
</option>
@endforeach
</x-forms.datalist>
</div>
<div class="grid grid-cols-1 gap-2 md:grid-cols-2">
<x-forms.checkbox id="enable_ipv6" label="Enable IPv6"
helper="Enable public IPv6 address for this Droplet" fullWidth />
<x-forms.checkbox id="monitoring" label="Enable DigitalOcean Monitoring" fullWidth
helper="Enable DigitalOcean metrics collection for this Droplet." />
</div>
<div class="flex flex-col gap-2">
@if (! $show_cloud_init_script && empty($cloud_init_script) && empty($selected_cloud_init_script_id))
<div>
<x-forms.button type="button" wire:click="showCloudInitScript">
Add cloud-init script
</x-forms.button>
</div>
@else
<div class="flex justify-between items-center gap-2">
<label class="text-sm font-medium w-32">Cloud-Init Script</label>
@if ($saved_cloud_init_scripts->count() > 0)
<div class="flex items-center gap-2 flex-1">
<x-forms.select wire:model.live="selected_cloud_init_script_id" label="" helper="">
<option value="">Load saved script...</option>
@foreach ($saved_cloud_init_scripts as $script)
<option value="{{ $script->id }}">{{ $script->name }}</option>
@endforeach
</x-forms.select>
<x-forms.button type="button" wire:click="clearCloudInitScript">
Clear
</x-forms.button>
</div>
@else
<x-forms.button type="button" wire:click="clearCloudInitScript">
Remove
</x-forms.button>
@endif
</div>
<x-forms.textarea id="cloud_init_script" label=""
helper="Add a cloud-init script to run when the Droplet is created. See DigitalOcean's cloud-init documentation for details."
rows="8" />
<div class="flex items-center gap-2">
<x-forms.checkbox id="save_cloud_init_script" label="Save this script for later use" />
@if ($save_cloud_init_script)
<div class="flex-1">
<x-forms.input id="cloud_init_script_name" label="" placeholder="Script name..." />
</div>
@endif
</div>
@endif
</div>
</div>
</x-dropdown>
<div class="flex gap-2 justify-between">
<x-forms.button type="button" wire:click="previousStep">
Back
</x-forms.button>
<x-forms.button isHighlighted canGate="create" :canResource="App\Models\Server::class" type="submit"
:disabled="!$private_key_id">
Buy & Create Server{{ $this->selectedDropletPrice ? ' (' . $this->selectedDropletPrice . '/mo)' : '' }}
</x-forms.button>
</div>
</form>
@endif
@endif
@endif
</div>

View file

@ -1,5 +1,5 @@
<div x-data
x-init="@if ($server->hetzner_server_id && $server->cloudProviderToken && !$hetznerServerStatus) $wire.checkHetznerServerStatus(); @endif @if ($server->vultr_instance_id && $server->cloudProviderToken) $wire.checkVultrInstanceStatus(); @endif">
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">
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > General | Coolify
</x-slot>
@ -147,6 +147,57 @@ class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
</x-forms.button>
@endif
@endif
@if ($server->digitalocean_droplet_id)
<div class="flex items-center">
<div @class([
'flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded transition-all',
'bg-white dark:bg-coolgray-100 dark:text-white',
])
@if ($digitalOceanDropletStatus === 'new') wire:poll.5s="checkDigitalOceanDropletStatus" @endif>
<x-digital-ocean-icon class="w-4 h-4" />
<span class="pl-1.5">
@if ($digitalOceanDropletStatus === 'new')
<svg class="inline animate-spin h-3 w-3 mr-1 text-coollabs dark:text-warning-500"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z">
</path>
</svg>
@endif
<span @class([
'text-green-500' => $digitalOceanDropletStatus === 'active',
'text-red-500' => in_array($digitalOceanDropletStatus, ['off', 'archive', 'deleted']),
])>
{{ ucfirst($digitalOceanDropletStatus ?? 'checking') }}
</span>
</span>
</div>
<button wire:loading.remove wire:target="checkDigitalOceanDropletStatus"
title="Refresh Status" wire:click.prevent='checkDigitalOceanDropletStatus(true)'
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
<button wire:loading wire:target="checkDigitalOceanDropletStatus" title="Refreshing Status"
class="mx-1 dark:hover:fill-white fill-black dark:fill-warning">
<svg class="w-4 h-4 animate-spin" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg">
<path
d="M12 2a10.016 10.016 0 0 0-7 2.877V3a1 1 0 1 0-2 0v4.5a1 1 0 0 0 1 1h4.5a1 1 0 0 0 0-2H6.218A7.98 7.98 0 0 1 20 12a1 1 0 0 0 2 0A10.012 10.012 0 0 0 12 2zm7.989 13.5h-4.5a1 1 0 0 0 0 2h2.293A7.98 7.98 0 0 1 4 12a1 1 0 0 0-2 0a9.986 9.986 0 0 0 16.989 7.133V21a1 1 0 0 0 2 0v-4.5a1 1 0 0 0-1-1z" />
</svg>
</button>
</div>
@if ($server->cloudProviderToken && $digitalOceanDropletStatus === 'off')
<x-forms.button wire:click.prevent='startDigitalOceanDroplet' isHighlighted
canGate="update" :canResource="$server">
Power On
</x-forms.button>
@endif
@endif
@if ($isValidating)
<div
class="flex items-center gap-1.5 px-2 py-1 text-xs font-semibold rounded bg-warning-100 dark:bg-warning-900/30 text-warning-700 dark:text-warning-400">
@ -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']))
<x-slide-over closeWithX fullScreen>
<x-slot:title>Validate & configure</x-slot:title>
<x-slot:content>
@ -582,6 +634,76 @@ class="dark:hover:fill-white fill-black dark:fill-warning">
@endif
</div>
@endif
@if (!$server->digitalocean_droplet_id && $availableDigitalOceanTokens->isNotEmpty())
<div class="pt-6">
<h3>Link to DigitalOcean</h3>
<p class="pb-4 text-sm dark:text-neutral-400">
Link this server to a DigitalOcean droplet to enable power controls and status monitoring.
</p>
<div class="flex flex-wrap gap-4 items-end">
<div class="w-72">
<x-forms.select wire:model="selectedDigitalOceanTokenId" label="DigitalOcean Token"
canGate="update" :canResource="$server">
<option value="">Select a token...</option>
@foreach ($availableDigitalOceanTokens as $token)
<option value="{{ $token->id }}">{{ $token->name }}</option>
@endforeach
</x-forms.select>
</div>
<div class="w-72">
<x-forms.input wire:model="manualDigitalOceanDropletId" label="Droplet ID"
placeholder="e.g., 123456789"
helper="Enter the Droplet ID from your DigitalOcean dashboard"
canGate="update" :canResource="$server" />
</div>
<x-forms.button wire:click="searchDigitalOceanDropletById" wire:loading.attr="disabled"
canGate="update" :canResource="$server">
<span wire:loading.remove wire:target="searchDigitalOceanDropletById">Search by ID</span>
<span wire:loading wire:target="searchDigitalOceanDropletById">Searching...</span>
</x-forms.button>
<div class="self-end pb-2 text-sm dark:text-neutral-500">OR</div>
<x-forms.button wire:click="searchDigitalOceanDroplet" wire:loading.attr="disabled"
canGate="update" :canResource="$server">
<span wire:loading.remove wire:target="searchDigitalOceanDroplet">Search by IP</span>
<span wire:loading wire:target="searchDigitalOceanDroplet">Searching...</span>
</x-forms.button>
</div>
@if ($digitalOceanSearchError)
<div class="mt-4 p-4 border border-red-500 rounded-md bg-red-50 dark:bg-red-900/20">
<p class="text-red-600 dark:text-red-400">{{ $digitalOceanSearchError }}</p>
</div>
@endif
@if ($digitalOceanNoMatchFound)
<div class="mt-4 p-4 border border-yellow-500 rounded-md bg-yellow-50 dark:bg-yellow-900/20">
<p class="text-yellow-600 dark:text-yellow-400">
@if ($manualDigitalOceanDropletId)
No DigitalOcean droplet found with ID: {{ $manualDigitalOceanDropletId }}
@else
No DigitalOcean droplet found matching IP: {{ $server->ip }}
@endif
</p>
</div>
@endif
@if ($matchedDigitalOceanDroplet)
<div class="mt-4 p-4 border border-green-500 rounded-md bg-green-50 dark:bg-green-900/20">
<h4 class="font-semibold text-green-700 dark:text-green-400 mb-2">Match Found!</h4>
<div class="grid grid-cols-2 gap-2 text-sm mb-4">
<div><span class="font-medium">Name:</span> {{ $matchedDigitalOceanDroplet['name'] ?? 'Unknown' }}</div>
<div><span class="font-medium">ID:</span> {{ $matchedDigitalOceanDroplet['id'] }}</div>
<div><span class="font-medium">Status:</span> {{ ucfirst($matchedDigitalOceanDroplet['status'] ?? 'unknown') }}</div>
<div><span class="font-medium">Size:</span> {{ data_get($matchedDigitalOceanDroplet, 'size.slug', 'Unknown') }}</div>
</div>
<x-forms.button wire:click="linkToDigitalOcean" isHighlighted canGate="update" :canResource="$server">
Link This Server
</x-forms.button>
</div>
@endif
</div>
@endif
</div>
</div>
</div>

View file

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

View file

@ -0,0 +1,173 @@
<?php
use App\Models\CloudProviderToken;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Once;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::query()->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.']);
});
});

View file

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

View file

@ -0,0 +1,54 @@
<?php
use App\Livewire\Security\CloudProviderTokenForm;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Once;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
if (! InstanceSettings::query()->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();
});

View file

@ -0,0 +1,46 @@
<?php
use App\Actions\Server\DeleteServer;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
it('deletes the DigitalOcean droplet when requested', function () {
Http::fake([
'https://api.digitalocean.com/v2/droplets/987' => 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');
});

View file

@ -0,0 +1,34 @@
<?php
it('renders the official DigitalOcean logo path', function () {
$contents = file_get_contents(__DIR__.'/../../resources/views/components/digital-ocean-icon.blade.php');
expect($contents)
->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.');
}
}
});

View file

@ -0,0 +1,59 @@
<?php
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
use App\Models\Team;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
function createDigitalOceanServerForStateTest(string $status = 'active'): Server
{
$team = Team::create([
'name' => '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');
});

View file

@ -0,0 +1,183 @@
<?php
use App\Services\DigitalOceanService;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
uses(TestCase::class);
it('fetches paginated regions from DigitalOcean', function () {
Http::fake([
'https://api.digitalocean.com/v2/regions?page=1&per_page=50' => 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);
});