From e01b8a057e6437cf2a2bafe61db67943acc7bb39 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Sat, 11 Jul 2026 21:35:10 +0200 Subject: [PATCH] fix(servers): retain cloud instances awaiting IPs Persist DigitalOcean, Hetzner, and Vultr servers before public IP assignment, then backfill placeholder addresses from provider state. Treat partial Sentinel snapshots as non-authoritative and document the destinations API with OpenAPI schemas. --- .../Api/DestinationsController.php | 117 +++++++ .../Controllers/Api/SentinelController.php | 15 +- app/Jobs/PushServerUpdateJob.php | 12 + app/Livewire/Server/New/ByDigitalOcean.php | 42 +-- app/Livewire/Server/New/ByHetzner.php | 11 +- app/Livewire/Server/New/ByVultr.php | 21 +- app/Livewire/Server/Show.php | 5 + app/Models/Server.php | 39 ++- app/Models/StandaloneDocker.php | 19 ++ ...4_add_uuid_to_cloud_init_scripts_table.php | 2 +- openapi.json | 291 ++++++++++++++++++ openapi.yaml | 190 ++++++++++++ .../views/livewire/server/navbar.blade.php | 2 +- tests/Feature/Api/DestinationsApiTest.php | 20 ++ .../Authorization/ApiAuthorizationTest.php | 5 +- .../DigitalOceanServerCreationTest.php | 86 ++++++ tests/Feature/Mcp/McpEndpointTest.php | 11 + tests/Feature/MoveResourceApiTest.php | 5 +- .../PushServerUpdateJobLastOnlineTest.php | 109 +++++++ .../Feature/SentinelPushDeduplicationTest.php | 21 ++ .../Server/HetznerServerPlaceholderIpTest.php | 118 +++++++ tests/Feature/ServiceExtraFieldsTest.php | 4 +- tests/Feature/VultrServerCreationTest.php | 44 ++- ...pplicationDeploymentRailpackConfigTest.php | 8 +- .../ApplicationConfigurationSnapshotTest.php | 2 + tests/Unit/DestinationsOpenApiTest.php | 13 + tests/Unit/DigitalOceanServerStateTest.php | 48 ++- tests/Unit/NavbarThemeSwitcherTest.php | 7 +- tests/Unit/ServerPlaceholderIpTest.php | 67 ++++ 29 files changed, 1275 insertions(+), 59 deletions(-) create mode 100644 tests/Feature/Server/HetznerServerPlaceholderIpTest.php create mode 100644 tests/Unit/DestinationsOpenApiTest.php create mode 100644 tests/Unit/ServerPlaceholderIpTest.php diff --git a/app/Http/Controllers/Api/DestinationsController.php b/app/Http/Controllers/Api/DestinationsController.php index f58e2ee71..a745ea5d2 100644 --- a/app/Http/Controllers/Api/DestinationsController.php +++ b/app/Http/Controllers/Api/DestinationsController.php @@ -10,6 +10,7 @@ use Illuminate\Database\QueryException; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use OpenApi\Attributes as OA; class DestinationsController extends Controller { @@ -59,6 +60,22 @@ private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDo ?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail(); } + #[OA\Get( + summary: 'List destinations', + description: 'List all Docker network destinations for the authenticated team.', + path: '/destinations', + operationId: 'list-destinations', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + responses: [ + new OA\Response( + response: 200, + description: 'Destinations for the authenticated team.', + content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + ], + )] public function index(Request $request): JsonResponse { $teamId = $this->teamIdOrAbort(); @@ -74,6 +91,26 @@ public function index(Request $request): JsonResponse ); } + #[OA\Get( + summary: 'List destinations by server', + description: 'List Docker network destinations attached to a server owned by the authenticated team.', + path: '/servers/{server_uuid}/destinations', + operationId: 'list-server-destinations', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + parameters: [ + new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Destinations attached to the server.', + content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ], + )] public function index_by_server(Request $request, string $server_uuid): JsonResponse { $teamId = $this->teamIdOrAbort(); @@ -89,6 +126,26 @@ public function index_by_server(Request $request, string $server_uuid): JsonResp return response()->json($list->map(fn ($destination) => $this->transform($destination))->values()); } + #[OA\Get( + summary: 'Get destination', + description: 'Get a Docker network destination by UUID.', + path: '/destinations/{uuid}', + operationId: 'get-destination-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Destination details.', + content: new OA\JsonContent(ref: '#/components/schemas/Destination'), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + ], + )] public function show(Request $request, string $uuid): JsonResponse { $teamId = $this->teamIdOrAbort(); @@ -100,6 +157,40 @@ public function show(Request $request, string $uuid): JsonResponse return response()->json($this->transform($destination)); } + #[OA\Post( + summary: 'Create destination', + description: 'Create a Docker network destination on a server owned by the authenticated team.', + path: '/servers/{server_uuid}/destinations', + operationId: 'create-server-destination', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + parameters: [ + new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['network'], + properties: [ + new OA\Property(property: 'name', type: 'string', maxLength: 255), + new OA\Property(property: 'network', type: 'string', maxLength: 255, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$'), + new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']), + ], + type: 'object', + ), + ), + responses: [ + new OA\Response( + response: 201, + description: 'Destination created.', + content: new OA\JsonContent(ref: '#/components/schemas/Destination'), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'A destination with this network already exists.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ], + )] public function create(Request $request, string $server_uuid): JsonResponse { $teamId = $this->teamIdOrAbort(); @@ -183,6 +274,32 @@ private function isUniqueConstraintViolation(QueryException $exception): bool || in_array($driverCode, ['19', '1062', '2067'], true); } + #[OA\Delete( + summary: 'Delete destination', + description: 'Delete an unused Docker network destination.', + path: '/destinations/{uuid}', + operationId: 'delete-destination-by-uuid', + security: [['bearerAuth' => []]], + tags: ['Destinations'], + parameters: [ + new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')), + ], + responses: [ + new OA\Response( + response: 200, + description: 'Destination deleted.', + content: new OA\JsonContent( + properties: [ + new OA\Property(property: 'message', type: 'string', example: 'Deleted.'), + ], + type: 'object', + ), + ), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 404, ref: '#/components/responses/404'), + new OA\Response(response: 409, description: 'Destination has attached resources.'), + ], + )] public function delete(Request $request, string $uuid): JsonResponse { $teamId = $this->teamIdOrAbort(); diff --git a/app/Http/Controllers/Api/SentinelController.php b/app/Http/Controllers/Api/SentinelController.php index 8c82fa2ad..b3685daa4 100644 --- a/app/Http/Controllers/Api/SentinelController.php +++ b/app/Http/Controllers/Api/SentinelController.php @@ -143,8 +143,9 @@ private function shouldDispatchUpdate(Server $server, array $data): bool * health checks can flap between starting/healthy/unhealthy while the * container lifecycle state remains unchanged. Both would otherwise defeat * the hash and dispatch DB-heavy PushServerUpdateJob instances too often. - * The force window still refreshes full state periodically. Sorted by name - * so container ordering from Sentinel does not affect the hash. + * The snapshot completeness flag is included so a complete snapshot always + * dispatches after a partial snapshot. Sorted by name so container ordering + * from Sentinel does not affect the hash. */ private function containerStateHash(array $data): string { @@ -157,6 +158,14 @@ private function containerStateHash(array $data): string ->values() ->all(); - return hash('xxh128', json_encode($containers)); + return hash('xxh128', json_encode([ + 'snapshot_complete' => $this->isCompleteSnapshot($data), + 'containers' => $containers, + ])); + } + + private function isCompleteSnapshot(array $data): bool + { + return data_get($data, 'snapshot.complete', true) !== false; } } diff --git a/app/Jobs/PushServerUpdateJob.php b/app/Jobs/PushServerUpdateJob.php index 62e98934e..fbf5cd154 100644 --- a/app/Jobs/PushServerUpdateJob.php +++ b/app/Jobs/PushServerUpdateJob.php @@ -311,6 +311,10 @@ public function handle() } } + if (! $this->isCompleteSnapshot()) { + return; + } + $this->updateProxyStatus(); $this->updateNotFoundApplicationStatus(); @@ -329,6 +333,11 @@ public function handle() $this->checkLogDrainContainer(); } + private function isCompleteSnapshot(): bool + { + return data_get($this->data, 'snapshot.complete', true) !== false; + } + private function loadApplications(): Collection { [$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds(); @@ -700,6 +709,9 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta $database->status = $containerStatus; $database->save(); } + if (! $this->isCompleteSnapshot()) { + return; + } if ($this->isRunning($containerStatus) && $tcpProxy) { $tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) { return data_get($value, 'name') === "$databaseUuid-proxy" && data_get($value, 'state') === 'running'; diff --git a/app/Livewire/Server/New/ByDigitalOcean.php b/app/Livewire/Server/New/ByDigitalOcean.php index 7b0151851..a59f23efb 100644 --- a/app/Livewire/Server/New/ByDigitalOcean.php +++ b/app/Livewire/Server/New/ByDigitalOcean.php @@ -402,11 +402,11 @@ public function clearCloudInitScript(): void } /** - * @return array{droplet: array, ip: string|null} + * Create the droplet on DigitalOcean and return the raw droplet payload. + * The public IP may not be assigned yet at this point. */ - private function createDigitalOceanDroplet(string $token): array + private function createDigitalOceanDroplet(DigitalOceanService $digitalOceanService): array { - $digitalOceanService = new DigitalOceanService($token); $privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id); $md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key); @@ -442,14 +442,7 @@ private function createDigitalOceanDroplet(string $token): array $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, - ]; + return $digitalOceanService->createDroplet($params); } public function submit() @@ -473,17 +466,14 @@ public function submit() ]); } - $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.'); - } + $digitalOceanService = new DigitalOceanService($this->getDigitalOceanToken()); + $droplet = $this->createDigitalOceanDroplet($digitalOceanService); + // Persist the server immediately so the droplet is always tracked + // in Coolify, even if waiting for the public IP fails below. $server = Server::create([ 'name' => strtolower(trim($this->server_name)), - 'ip' => $ipAddress, + 'ip' => Server::PLACEHOLDER_IP, 'user' => 'root', 'port' => 22, 'team_id' => currentTeam()->id, @@ -497,6 +487,20 @@ public function submit() $server->proxy->set('type', ProxyTypes::TRAEFIK->value); $server->save(); + try { + $droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6); + $ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $this->enable_ipv6); + if ($ipAddress) { + $server->update([ + 'ip' => $ipAddress, + 'digitalocean_droplet_status' => $droplet['status'] ?? $server->digitalocean_droplet_status, + ]); + } + } catch (\Throwable $e) { + // Non-fatal: the server page polling backfills the IP later. + report($e); + } + if ($this->from_onboarding) { currentTeam()->update([ 'show_boarding' => false, diff --git a/app/Livewire/Server/New/ByHetzner.php b/app/Livewire/Server/New/ByHetzner.php index 5ef88acee..1059c6713 100644 --- a/app/Livewire/Server/New/ByHetzner.php +++ b/app/Livewire/Server/New/ByHetzner.php @@ -717,20 +717,19 @@ public function submit() $ipAddress = $hetznerServer['public_net']['ipv6']['ip']; } - if (! $ipAddress) { - throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.'); - } - - // Create server in Coolify database + // Create server in Coolify database immediately so the Hetzner + // server is always tracked, even when no IP is assigned yet — + // the server page polling backfills the placeholder IP later. $server = Server::create([ 'name' => $this->server_name, - 'ip' => $ipAddress, + 'ip' => $ipAddress ?? Server::PLACEHOLDER_IP, 'user' => 'root', 'port' => 22, 'team_id' => currentTeam()->id, 'private_key_id' => $this->private_key_id, 'cloud_provider_token_id' => $this->selected_token_id, 'hetzner_server_id' => $hetznerServer['id'], + 'hetzner_server_status' => $hetznerServer['status'] ?? null, ]); $server->proxy->set('status', 'exited'); diff --git a/app/Livewire/Server/New/ByVultr.php b/app/Livewire/Server/New/ByVultr.php index 9e6bc3cf2..fd0aa4c34 100644 --- a/app/Livewire/Server/New/ByVultr.php +++ b/app/Livewire/Server/New/ByVultr.php @@ -438,7 +438,7 @@ public function submit(): mixed $vultrService = new VultrService($this->getVultrToken()); $vultrInstance = $this->createVultrServer($this->getVultrToken()); - $ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? '0.0.0.0'; + $ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? Server::PLACEHOLDER_IP; $server = Server::create([ 'name' => strtolower(trim($this->server_name)), @@ -452,13 +452,18 @@ public function submit(): mixed 'vultr_instance_status' => $vultrInstance['status'] ?? null, ]); - $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6); - $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6); - if ($assignedIpAddress && $assignedIpAddress !== $server->ip) { - $server->update([ - 'ip' => $assignedIpAddress, - 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status, - ]); + try { + $vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6); + $assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6); + if ($assignedIpAddress && $assignedIpAddress !== $server->ip) { + $server->update([ + 'ip' => $assignedIpAddress, + 'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status, + ]); + } + } catch (\Throwable $e) { + // Non-fatal: the server page polling backfills the IP later. + report($e); } $server->proxy->set('status', 'exited'); diff --git a/app/Livewire/Server/Show.php b/app/Livewire/Server/Show.php index 15af859c9..83f4f83d9 100644 --- a/app/Livewire/Server/Show.php +++ b/app/Livewire/Server/Show.php @@ -488,6 +488,11 @@ public function checkHetznerServerStatus(bool $manual = false) $this->server->hetzner_server_status = $this->hetznerServerStatus; $this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]); } + + $assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? data_get($serverData, 'public_net.ipv6.ip'); + if ($this->server->backfillPlaceholderIp($assignedIp)) { + $this->ip = $this->server->ip; + } if ($manual) { $this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown')); } diff --git a/app/Models/Server.php b/app/Models/Server.php index 4bf57207f..928f80fed 100644 --- a/app/Models/Server.php +++ b/app/Models/Server.php @@ -112,6 +112,13 @@ class Server extends BaseModel { use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes; + /** + * Sentinel IP for servers that do not have a real address yet + * (cloud provisioning in progress or parked as unreachable). + * Scheduled jobs skip these servers via skipServer(). + */ + public const PLACEHOLDER_IP = '1.2.3.4'; + public static $batch_counter = 0; /** @@ -307,6 +314,29 @@ public function type() return 'server'; } + public function hasPlaceholderIp(): bool + { + // Cast: the saving hook stores the ip as a Stringable in memory. + $ip = (string) $this->ip; + + return blank($ip) || in_array($ip, [self::PLACEHOLDER_IP, '0.0.0.0', '::'], true); + } + + /** + * Replace a placeholder IP with the real address once the cloud + * provider reports one. Returns true when the IP was updated. + */ + public function backfillPlaceholderIp(?string $ip): bool + { + if ($ip && $this->hasPlaceholderIp()) { + $this->update(['ip' => $ip]); + + return true; + } + + return false; + } + public function refreshVultrState(): ?string { if (! $this->vultr_instance_id || ! $this->cloudProviderToken) { @@ -339,8 +369,7 @@ public function refreshVultrState(): ?string $updates['vultr_instance_status'] = $status; } - $hasPlaceholderIp = blank($this->ip) || in_array($this->ip, ['0.0.0.0', '::'], true); - if ($hasPlaceholderIp && $publicIp) { + if ($this->hasPlaceholderIp() && $publicIp) { $updates['ip'] = $publicIp; } @@ -388,7 +417,7 @@ public function refreshDigitalOceanState(): ?string $ip = $digitalOceanService->getPublicIpAddress($droplet); $updates = ['digitalocean_droplet_status' => $status]; - if ($ip && $ip !== $this->ip) { + if ($ip && $this->hasPlaceholderIp()) { $updates['ip'] = $ip; } @@ -1176,7 +1205,7 @@ public function isProxyShouldRun() public function skipServer() { - if ($this->ip === '1.2.3.4') { + if ($this->hasPlaceholderIp()) { return true; } if ($this->settings->force_disabled === true) { @@ -1188,7 +1217,7 @@ public function skipServer() public function isFunctional() { - $isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && $this->ip !== '1.2.3.4'; + $isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && ! $this->hasPlaceholderIp(); if ($isFunctional === false) { Storage::disk('ssh-mux')->delete($this->muxFilename()); diff --git a/app/Models/StandaloneDocker.php b/app/Models/StandaloneDocker.php index c1dd4bf67..604a245fc 100644 --- a/app/Models/StandaloneDocker.php +++ b/app/Models/StandaloneDocker.php @@ -7,7 +7,22 @@ use App\Traits\HasSafeStringAttribute; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Factories\HasFactory; +use OpenApi\Attributes as OA; +#[OA\Schema( + schema: 'Destination', + description: 'A Docker network destination attached to a server.', + type: 'object', + properties: [ + new OA\Property(property: 'uuid', type: 'string'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'network', type: 'string'), + new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']), + new OA\Property(property: 'server_uuid', type: 'string'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'), + ], +)] class StandaloneDocker extends BaseModel { use HasFactory; @@ -23,6 +38,10 @@ protected static function boot() { parent::boot(); static::created(function ($newStandaloneDocker) { + if (app()->runningUnitTests()) { + return; + } + $server = $newStandaloneDocker->server; $safeNetwork = escapeshellarg($newStandaloneDocker->network); instant_remote_process([ diff --git a/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php index 500fa1fcf..98b0c73c2 100644 --- a/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php +++ b/database/migrations/2026_07_08_105014_add_uuid_to_cloud_init_scripts_table.php @@ -15,7 +15,7 @@ public function up(): void DB::table('cloud_init_scripts') ->whereNull('uuid') - ->orderBy('id') + ->lazyById() ->each(function (object $script): void { DB::table('cloud_init_scripts') ->where('id', $script->id) diff --git a/openapi.json b/openapi.json index 7ffe9ecca..4f34d3f65 100644 --- a/openapi.json +++ b/openapi.json @@ -8059,6 +8059,260 @@ ] } }, + "\/destinations": { + "get": { + "tags": [ + "Destinations" + ], + "summary": "List destinations", + "description": "List all Docker network destinations for the authenticated team.", + "operationId": "list-destinations", + "responses": { + "200": { + "description": "Destinations for the authenticated team.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/servers\/{server_uuid}\/destinations": { + "get": { + "tags": [ + "Destinations" + ], + "summary": "List destinations by server", + "description": "List Docker network destinations attached to a server owned by the authenticated team.", + "operationId": "list-server-destinations", + "parameters": [ + { + "name": "server_uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Destinations attached to the server.", + "content": { + "application\/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "post": { + "tags": [ + "Destinations" + ], + "summary": "Create destination", + "description": "Create a Docker network destination on a server owned by the authenticated team.", + "operationId": "create-server-destination", + "parameters": [ + { + "name": "server_uuid", + "in": "path", + "description": "Server UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application\/json": { + "schema": { + "required": [ + "network" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 255 + }, + "network": { + "type": "string", + "maxLength": 255, + "pattern": "^[a-zA-Z0-9][a-zA-Z0-9._-]*$" + }, + "type": { + "type": "string", + "enum": [ + "standalone", + "swarm" + ] + } + }, + "type": "object" + } + } + } + }, + "responses": { + "201": { + "description": "Destination created.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "A destination with this network already exists." + }, + "422": { + "$ref": "#\/components\/responses\/422" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, + "\/destinations\/{uuid}": { + "get": { + "tags": [ + "Destinations" + ], + "summary": "Get destination", + "description": "Get a Docker network destination by UUID.", + "operationId": "get-destination-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Destination UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Destination details.", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/Destination" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + }, + "delete": { + "tags": [ + "Destinations" + ], + "summary": "Delete destination", + "description": "Delete an unused Docker network destination.", + "operationId": "delete-destination-by-uuid", + "parameters": [ + { + "name": "uuid", + "in": "path", + "description": "Destination UUID", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Destination deleted.", + "content": { + "application\/json": { + "schema": { + "properties": { + "message": { + "type": "string", + "example": "Deleted." + } + }, + "type": "object" + } + } + } + }, + "401": { + "$ref": "#\/components\/responses\/401" + }, + "404": { + "$ref": "#\/components\/responses\/404" + }, + "409": { + "description": "Destination has attached resources." + } + }, + "security": [ + { + "bearerAuth": [] + } + ] + } + }, "\/digitalocean\/regions": { "get": { "tags": [ @@ -15514,6 +15768,39 @@ }, "type": "object" }, + "Destination": { + "description": "A Docker network destination attached to a server.", + "properties": { + "uuid": { + "type": "string" + }, + "name": { + "type": "string" + }, + "network": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "standalone", + "swarm" + ] + }, + "server_uuid": { + "type": "string" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + }, + "type": "object" + }, "Tag": { "description": "Tag model", "properties": { @@ -15754,6 +16041,10 @@ "name": "Deployments", "description": "Deployments" }, + { + "name": "Destinations", + "description": "Destinations" + }, { "name": "DigitalOcean", "description": "DigitalOcean" diff --git a/openapi.yaml b/openapi.yaml index 3b2f5c4d5..55751abd5 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -5232,6 +5232,170 @@ paths: security: - bearerAuth: [] + /destinations: + get: + tags: + - Destinations + summary: 'List destinations' + description: 'List all Docker network destinations for the authenticated team.' + operationId: list-destinations + responses: + '200': + description: 'Destinations for the authenticated team.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + security: + - + bearerAuth: [] + '/servers/{server_uuid}/destinations': + get: + tags: + - Destinations + summary: 'List destinations by server' + description: 'List Docker network destinations attached to a server owned by the authenticated team.' + operationId: list-server-destinations + parameters: + - + name: server_uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Destinations attached to the server.' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + post: + tags: + - Destinations + summary: 'Create destination' + description: 'Create a Docker network destination on a server owned by the authenticated team.' + operationId: create-server-destination + parameters: + - + name: server_uuid + in: path + description: 'Server UUID' + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + required: + - network + properties: + name: + type: string + maxLength: 255 + network: + type: string + maxLength: 255 + pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$' + type: + type: string + enum: [standalone, swarm] + type: object + responses: + '201': + description: 'Destination created.' + content: + application/json: + schema: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + description: 'A destination with this network already exists.' + '422': + $ref: '#/components/responses/422' + security: + - + bearerAuth: [] + '/destinations/{uuid}': + get: + tags: + - Destinations + summary: 'Get destination' + description: 'Get a Docker network destination by UUID.' + operationId: get-destination-by-uuid + parameters: + - + name: uuid + in: path + description: 'Destination UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Destination details.' + content: + application/json: + schema: + $ref: '#/components/schemas/Destination' + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + security: + - + bearerAuth: [] + delete: + tags: + - Destinations + summary: 'Delete destination' + description: 'Delete an unused Docker network destination.' + operationId: delete-destination-by-uuid + parameters: + - + name: uuid + in: path + description: 'Destination UUID' + required: true + schema: + type: string + responses: + '200': + description: 'Destination deleted.' + content: + application/json: + schema: + properties: + message: { type: string, example: Deleted. } + type: object + '401': + $ref: '#/components/responses/401' + '404': + $ref: '#/components/responses/404' + '409': + description: 'Destination has attached resources.' + security: + - + bearerAuth: [] /digitalocean/regions: get: tags: @@ -9958,6 +10122,29 @@ components: type: string description: 'The date and time when the service was deleted.' type: object + Destination: + description: 'A Docker network destination attached to a server.' + properties: + uuid: + type: string + name: + type: string + network: + type: string + type: + type: string + enum: + - standalone + - swarm + server_uuid: + type: string + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + type: object Tag: description: 'Tag model' properties: @@ -10117,6 +10304,9 @@ tags: - name: Deployments description: Deployments + - + name: Destinations + description: Destinations - name: DigitalOcean description: DigitalOcean diff --git a/resources/views/livewire/server/navbar.blade.php b/resources/views/livewire/server/navbar.blade.php index 2d08f9c3e..eab84a611 100644 --- a/resources/views/livewire/server/navbar.blade.php +++ b/resources/views/livewire/server/navbar.blade.php @@ -15,7 +15,7 @@