feat(digitalocean): add droplet provisioning support
This commit is contained in:
parent
5936874ffd
commit
c303c34cfd
32 changed files with 2796 additions and 19 deletions
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
382
app/Http/Controllers/Api/DigitalOceanController.php
Normal file
382
app/Http/Controllers/Api/DigitalOceanController.php
Normal 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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -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', [
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ public function mount(string $server_uuid)
|
|||
public function getListeners()
|
||||
{
|
||||
return [
|
||||
'tokenAdded' => 'handleTokenAdded',
|
||||
'tokenAdded.hetzner' => 'handleTokenAdded',
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
455
app/Livewire/Server/New/ByDigitalOcean.php
Normal file
455
app/Livewire/Server/New/ByDigitalOcean.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -114,7 +114,7 @@ public function loadSavedCloudInitScripts()
|
|||
public function getListeners()
|
||||
{
|
||||
return [
|
||||
'tokenAdded' => 'handleTokenAdded',
|
||||
'tokenAdded.hetzner' => 'handleTokenAdded',
|
||||
'privateKeyCreated' => 'handlePrivateKeyCreated',
|
||||
'modalClosed' => 'resetSelection',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
221
app/Services/DigitalOceanService.php
Normal file
221
app/Services/DigitalOceanService.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
208
openapi.json
208
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"
|
||||
|
|
|
|||
141
openapi.yaml
141
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'
|
||||
|
|
|
|||
6
resources/views/components/digital-ocean-icon.blade.php
Normal file
6
resources/views/components/digital-ocean-icon.blade.php
Normal 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 |
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
227
resources/views/livewire/server/new/by-digital-ocean.blade.php
Normal file
227
resources/views/livewire/server/new/by-digital-ocean.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
173
tests/Feature/DigitalOceanApiTest.php
Normal file
173
tests/Feature/DigitalOceanApiTest.php
Normal 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.']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
||||
|
|
|
|||
54
tests/Feature/Security/CloudProviderTokenFormTest.php
Normal file
54
tests/Feature/Security/CloudProviderTokenFormTest.php
Normal 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();
|
||||
});
|
||||
46
tests/Unit/DigitalOceanDeleteServerTest.php
Normal file
46
tests/Unit/DigitalOceanDeleteServerTest.php
Normal 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');
|
||||
});
|
||||
34
tests/Unit/DigitalOceanIconTest.php
Normal file
34
tests/Unit/DigitalOceanIconTest.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
});
|
||||
59
tests/Unit/DigitalOceanServerStateTest.php
Normal file
59
tests/Unit/DigitalOceanServerStateTest.php
Normal 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');
|
||||
});
|
||||
183
tests/Unit/DigitalOceanServiceTest.php
Normal file
183
tests/Unit/DigitalOceanServiceTest.php
Normal 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);
|
||||
});
|
||||
Loading…
Reference in a new issue