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.
This commit is contained in:
parent
d3fbb32c52
commit
e01b8a057e
29 changed files with 1275 additions and 59 deletions
|
|
@ -10,6 +10,7 @@
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
class DestinationsController extends Controller
|
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();
|
?? 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
|
public function index(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
$teamId = $this->teamIdOrAbort();
|
$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
|
public function index_by_server(Request $request, string $server_uuid): JsonResponse
|
||||||
{
|
{
|
||||||
$teamId = $this->teamIdOrAbort();
|
$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());
|
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
|
public function show(Request $request, string $uuid): JsonResponse
|
||||||
{
|
{
|
||||||
$teamId = $this->teamIdOrAbort();
|
$teamId = $this->teamIdOrAbort();
|
||||||
|
|
@ -100,6 +157,40 @@ public function show(Request $request, string $uuid): JsonResponse
|
||||||
return response()->json($this->transform($destination));
|
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
|
public function create(Request $request, string $server_uuid): JsonResponse
|
||||||
{
|
{
|
||||||
$teamId = $this->teamIdOrAbort();
|
$teamId = $this->teamIdOrAbort();
|
||||||
|
|
@ -183,6 +274,32 @@ private function isUniqueConstraintViolation(QueryException $exception): bool
|
||||||
|| in_array($driverCode, ['19', '1062', '2067'], true);
|
|| 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
|
public function delete(Request $request, string $uuid): JsonResponse
|
||||||
{
|
{
|
||||||
$teamId = $this->teamIdOrAbort();
|
$teamId = $this->teamIdOrAbort();
|
||||||
|
|
|
||||||
|
|
@ -143,8 +143,9 @@ private function shouldDispatchUpdate(Server $server, array $data): bool
|
||||||
* health checks can flap between starting/healthy/unhealthy while the
|
* health checks can flap between starting/healthy/unhealthy while the
|
||||||
* container lifecycle state remains unchanged. Both would otherwise defeat
|
* container lifecycle state remains unchanged. Both would otherwise defeat
|
||||||
* the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
|
* the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
|
||||||
* The force window still refreshes full state periodically. Sorted by name
|
* The snapshot completeness flag is included so a complete snapshot always
|
||||||
* so container ordering from Sentinel does not affect the hash.
|
* dispatches after a partial snapshot. Sorted by name so container ordering
|
||||||
|
* from Sentinel does not affect the hash.
|
||||||
*/
|
*/
|
||||||
private function containerStateHash(array $data): string
|
private function containerStateHash(array $data): string
|
||||||
{
|
{
|
||||||
|
|
@ -157,6 +158,14 @@ private function containerStateHash(array $data): string
|
||||||
->values()
|
->values()
|
||||||
->all();
|
->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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -311,6 +311,10 @@ public function handle()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (! $this->isCompleteSnapshot()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$this->updateProxyStatus();
|
$this->updateProxyStatus();
|
||||||
|
|
||||||
$this->updateNotFoundApplicationStatus();
|
$this->updateNotFoundApplicationStatus();
|
||||||
|
|
@ -329,6 +333,11 @@ public function handle()
|
||||||
$this->checkLogDrainContainer();
|
$this->checkLogDrainContainer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isCompleteSnapshot(): bool
|
||||||
|
{
|
||||||
|
return data_get($this->data, 'snapshot.complete', true) !== false;
|
||||||
|
}
|
||||||
|
|
||||||
private function loadApplications(): Collection
|
private function loadApplications(): Collection
|
||||||
{
|
{
|
||||||
[$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
|
[$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
|
||||||
|
|
@ -700,6 +709,9 @@ private function updateDatabaseStatus(string $databaseUuid, string $containerSta
|
||||||
$database->status = $containerStatus;
|
$database->status = $containerStatus;
|
||||||
$database->save();
|
$database->save();
|
||||||
}
|
}
|
||||||
|
if (! $this->isCompleteSnapshot()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($this->isRunning($containerStatus) && $tcpProxy) {
|
if ($this->isRunning($containerStatus) && $tcpProxy) {
|
||||||
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
|
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
|
||||||
return data_get($value, 'name') === "$databaseUuid-proxy" && data_get($value, 'state') === 'running';
|
return data_get($value, 'name') === "$databaseUuid-proxy" && data_get($value, 'state') === 'running';
|
||||||
|
|
|
||||||
|
|
@ -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);
|
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
|
||||||
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
|
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
|
||||||
|
|
||||||
|
|
@ -442,14 +442,7 @@ private function createDigitalOceanDroplet(string $token): array
|
||||||
$params['user_data'] = $this->cloud_init_script;
|
$params['user_data'] = $this->cloud_init_script;
|
||||||
}
|
}
|
||||||
|
|
||||||
$droplet = $digitalOceanService->createDroplet($params);
|
return $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()
|
public function submit()
|
||||||
|
|
@ -473,17 +466,14 @@ public function submit()
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = $this->createDigitalOceanDroplet($this->getDigitalOceanToken());
|
$digitalOceanService = new DigitalOceanService($this->getDigitalOceanToken());
|
||||||
$droplet = $result['droplet'];
|
$droplet = $this->createDigitalOceanDroplet($digitalOceanService);
|
||||||
$ipAddress = $result['ip'];
|
|
||||||
|
|
||||||
if (! $ipAddress) {
|
|
||||||
throw new \Exception('No public IP address available for the new droplet.');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Persist the server immediately so the droplet is always tracked
|
||||||
|
// in Coolify, even if waiting for the public IP fails below.
|
||||||
$server = Server::create([
|
$server = Server::create([
|
||||||
'name' => strtolower(trim($this->server_name)),
|
'name' => strtolower(trim($this->server_name)),
|
||||||
'ip' => $ipAddress,
|
'ip' => Server::PLACEHOLDER_IP,
|
||||||
'user' => 'root',
|
'user' => 'root',
|
||||||
'port' => 22,
|
'port' => 22,
|
||||||
'team_id' => currentTeam()->id,
|
'team_id' => currentTeam()->id,
|
||||||
|
|
@ -497,6 +487,20 @@ public function submit()
|
||||||
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
|
||||||
$server->save();
|
$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) {
|
if ($this->from_onboarding) {
|
||||||
currentTeam()->update([
|
currentTeam()->update([
|
||||||
'show_boarding' => false,
|
'show_boarding' => false,
|
||||||
|
|
|
||||||
|
|
@ -717,20 +717,19 @@ public function submit()
|
||||||
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
|
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $ipAddress) {
|
// Create server in Coolify database immediately so the Hetzner
|
||||||
throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.');
|
// server is always tracked, even when no IP is assigned yet —
|
||||||
}
|
// the server page polling backfills the placeholder IP later.
|
||||||
|
|
||||||
// Create server in Coolify database
|
|
||||||
$server = Server::create([
|
$server = Server::create([
|
||||||
'name' => $this->server_name,
|
'name' => $this->server_name,
|
||||||
'ip' => $ipAddress,
|
'ip' => $ipAddress ?? Server::PLACEHOLDER_IP,
|
||||||
'user' => 'root',
|
'user' => 'root',
|
||||||
'port' => 22,
|
'port' => 22,
|
||||||
'team_id' => currentTeam()->id,
|
'team_id' => currentTeam()->id,
|
||||||
'private_key_id' => $this->private_key_id,
|
'private_key_id' => $this->private_key_id,
|
||||||
'cloud_provider_token_id' => $this->selected_token_id,
|
'cloud_provider_token_id' => $this->selected_token_id,
|
||||||
'hetzner_server_id' => $hetznerServer['id'],
|
'hetzner_server_id' => $hetznerServer['id'],
|
||||||
|
'hetzner_server_status' => $hetznerServer['status'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$server->proxy->set('status', 'exited');
|
$server->proxy->set('status', 'exited');
|
||||||
|
|
|
||||||
|
|
@ -438,7 +438,7 @@ public function submit(): mixed
|
||||||
|
|
||||||
$vultrService = new VultrService($this->getVultrToken());
|
$vultrService = new VultrService($this->getVultrToken());
|
||||||
$vultrInstance = $this->createVultrServer($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([
|
$server = Server::create([
|
||||||
'name' => strtolower(trim($this->server_name)),
|
'name' => strtolower(trim($this->server_name)),
|
||||||
|
|
@ -452,13 +452,18 @@ public function submit(): mixed
|
||||||
'vultr_instance_status' => $vultrInstance['status'] ?? null,
|
'vultr_instance_status' => $vultrInstance['status'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
|
try {
|
||||||
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
|
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
|
||||||
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
|
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
|
||||||
$server->update([
|
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
|
||||||
'ip' => $assignedIpAddress,
|
$server->update([
|
||||||
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
|
'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');
|
$server->proxy->set('status', 'exited');
|
||||||
|
|
|
||||||
|
|
@ -488,6 +488,11 @@ public function checkHetznerServerStatus(bool $manual = false)
|
||||||
$this->server->hetzner_server_status = $this->hetznerServerStatus;
|
$this->server->hetzner_server_status = $this->hetznerServerStatus;
|
||||||
$this->server->update(['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) {
|
if ($manual) {
|
||||||
$this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown'));
|
$this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown'));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,13 @@ class Server extends BaseModel
|
||||||
{
|
{
|
||||||
use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes;
|
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;
|
public static $batch_counter = 0;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -307,6 +314,29 @@ public function type()
|
||||||
return 'server';
|
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
|
public function refreshVultrState(): ?string
|
||||||
{
|
{
|
||||||
if (! $this->vultr_instance_id || ! $this->cloudProviderToken) {
|
if (! $this->vultr_instance_id || ! $this->cloudProviderToken) {
|
||||||
|
|
@ -339,8 +369,7 @@ public function refreshVultrState(): ?string
|
||||||
$updates['vultr_instance_status'] = $status;
|
$updates['vultr_instance_status'] = $status;
|
||||||
}
|
}
|
||||||
|
|
||||||
$hasPlaceholderIp = blank($this->ip) || in_array($this->ip, ['0.0.0.0', '::'], true);
|
if ($this->hasPlaceholderIp() && $publicIp) {
|
||||||
if ($hasPlaceholderIp && $publicIp) {
|
|
||||||
$updates['ip'] = $publicIp;
|
$updates['ip'] = $publicIp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -388,7 +417,7 @@ public function refreshDigitalOceanState(): ?string
|
||||||
$ip = $digitalOceanService->getPublicIpAddress($droplet);
|
$ip = $digitalOceanService->getPublicIpAddress($droplet);
|
||||||
|
|
||||||
$updates = ['digitalocean_droplet_status' => $status];
|
$updates = ['digitalocean_droplet_status' => $status];
|
||||||
if ($ip && $ip !== $this->ip) {
|
if ($ip && $this->hasPlaceholderIp()) {
|
||||||
$updates['ip'] = $ip;
|
$updates['ip'] = $ip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1176,7 +1205,7 @@ public function isProxyShouldRun()
|
||||||
|
|
||||||
public function skipServer()
|
public function skipServer()
|
||||||
{
|
{
|
||||||
if ($this->ip === '1.2.3.4') {
|
if ($this->hasPlaceholderIp()) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if ($this->settings->force_disabled === true) {
|
if ($this->settings->force_disabled === true) {
|
||||||
|
|
@ -1188,7 +1217,7 @@ public function skipServer()
|
||||||
|
|
||||||
public function isFunctional()
|
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) {
|
if ($isFunctional === false) {
|
||||||
Storage::disk('ssh-mux')->delete($this->muxFilename());
|
Storage::disk('ssh-mux')->delete($this->muxFilename());
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,22 @@
|
||||||
use App\Traits\HasSafeStringAttribute;
|
use App\Traits\HasSafeStringAttribute;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
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
|
class StandaloneDocker extends BaseModel
|
||||||
{
|
{
|
||||||
use HasFactory;
|
use HasFactory;
|
||||||
|
|
@ -23,6 +38,10 @@ protected static function boot()
|
||||||
{
|
{
|
||||||
parent::boot();
|
parent::boot();
|
||||||
static::created(function ($newStandaloneDocker) {
|
static::created(function ($newStandaloneDocker) {
|
||||||
|
if (app()->runningUnitTests()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$server = $newStandaloneDocker->server;
|
$server = $newStandaloneDocker->server;
|
||||||
$safeNetwork = escapeshellarg($newStandaloneDocker->network);
|
$safeNetwork = escapeshellarg($newStandaloneDocker->network);
|
||||||
instant_remote_process([
|
instant_remote_process([
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ public function up(): void
|
||||||
|
|
||||||
DB::table('cloud_init_scripts')
|
DB::table('cloud_init_scripts')
|
||||||
->whereNull('uuid')
|
->whereNull('uuid')
|
||||||
->orderBy('id')
|
->lazyById()
|
||||||
->each(function (object $script): void {
|
->each(function (object $script): void {
|
||||||
DB::table('cloud_init_scripts')
|
DB::table('cloud_init_scripts')
|
||||||
->where('id', $script->id)
|
->where('id', $script->id)
|
||||||
|
|
|
||||||
291
openapi.json
291
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": {
|
"\/digitalocean\/regions": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
|
|
@ -15514,6 +15768,39 @@
|
||||||
},
|
},
|
||||||
"type": "object"
|
"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": {
|
"Tag": {
|
||||||
"description": "Tag model",
|
"description": "Tag model",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|
@ -15754,6 +16041,10 @@
|
||||||
"name": "Deployments",
|
"name": "Deployments",
|
||||||
"description": "Deployments"
|
"description": "Deployments"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Destinations",
|
||||||
|
"description": "Destinations"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "DigitalOcean",
|
"name": "DigitalOcean",
|
||||||
"description": "DigitalOcean"
|
"description": "DigitalOcean"
|
||||||
|
|
|
||||||
190
openapi.yaml
190
openapi.yaml
|
|
@ -5232,6 +5232,170 @@ paths:
|
||||||
security:
|
security:
|
||||||
-
|
-
|
||||||
bearerAuth: []
|
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:
|
/digitalocean/regions:
|
||||||
get:
|
get:
|
||||||
tags:
|
tags:
|
||||||
|
|
@ -9958,6 +10122,29 @@ components:
|
||||||
type: string
|
type: string
|
||||||
description: 'The date and time when the service was deleted.'
|
description: 'The date and time when the service was deleted.'
|
||||||
type: object
|
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:
|
Tag:
|
||||||
description: 'Tag model'
|
description: 'Tag model'
|
||||||
properties:
|
properties:
|
||||||
|
|
@ -10117,6 +10304,9 @@ tags:
|
||||||
-
|
-
|
||||||
name: Deployments
|
name: Deployments
|
||||||
description: Deployments
|
description: Deployments
|
||||||
|
-
|
||||||
|
name: Destinations
|
||||||
|
description: Destinations
|
||||||
-
|
-
|
||||||
name: DigitalOcean
|
name: DigitalOcean
|
||||||
description: DigitalOcean
|
description: DigitalOcean
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
<h1>Server</h1>
|
<h1>Server</h1>
|
||||||
<div class="pt-2 pb-4 md:pb-10">
|
<div class="pt-2 pb-4 md:pb-10">
|
||||||
<div class="flex-col md:flex-row flex gap-2">
|
<div class="flex-col md:flex-row flex gap-2">
|
||||||
<div data-testid="server-subtitle" class="text-xs lg:text-sm min-w-0 truncate">
|
<div data-testid="server-subtitle" class="text-xs lg:text-sm min-w-0 truncate text-neutral-600 dark:text-neutral-400">
|
||||||
{{ data_get($server, 'name') }}
|
{{ data_get($server, 'name') }}
|
||||||
</div>
|
</div>
|
||||||
@php
|
@php
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,26 @@ function destinationsApiToken(User $user, Team $team, array $abilities): string
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('POST /api/v1/servers/{server_uuid}/destinations', function () {
|
describe('POST /api/v1/servers/{server_uuid}/destinations', function () {
|
||||||
|
test('creates a standalone destination', function () {
|
||||||
|
$response = $this->withHeaders(destinationsApiHeaders($this->bearerToken))
|
||||||
|
->postJson("/api/v1/servers/{$this->server->uuid}/destinations", [
|
||||||
|
'name' => 'API Standalone',
|
||||||
|
'network' => 'api-standalone-network',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response->assertCreated()
|
||||||
|
->assertJson([
|
||||||
|
'name' => 'API Standalone',
|
||||||
|
'network' => 'api-standalone-network',
|
||||||
|
'type' => 'standalone',
|
||||||
|
'server_uuid' => $this->server->uuid,
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(StandaloneDocker::where('server_id', $this->server->id)
|
||||||
|
->where('network', 'api-standalone-network')
|
||||||
|
->exists())->toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
test('requires a write token', function () {
|
test('requires a write token', function () {
|
||||||
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
|
$readOnlyToken = destinationsApiToken($this->user, $this->team, ['read']);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
InstanceSettings::updateOrCreate(['id' => 0], ['is_api_enabled' => true]);
|
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
||||||
|
['id' => 0],
|
||||||
|
['is_api_enabled' => true],
|
||||||
|
));
|
||||||
|
|
||||||
$this->team = Team::factory()->create();
|
$this->team = Team::factory()->create();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Livewire\Server\New\ByDigitalOcean;
|
use App\Livewire\Server\New\ByDigitalOcean;
|
||||||
|
use App\Models\CloudProviderToken;
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
|
use App\Models\PrivateKey;
|
||||||
|
use App\Models\Server;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
use Livewire\Livewire;
|
use Livewire\Livewire;
|
||||||
|
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
@ -25,6 +29,88 @@
|
||||||
|
|
||||||
$this->actingAs($this->user);
|
$this->actingAs($this->user);
|
||||||
session(['currentTeam' => $this->team]);
|
session(['currentTeam' => $this->team]);
|
||||||
|
|
||||||
|
$this->digitalOceanToken = CloudProviderToken::create([
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'provider' => 'digitalocean',
|
||||||
|
'token' => 'test-digitalocean-token',
|
||||||
|
'name' => 'Test DigitalOcean Token',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
function submitDigitalOceanServer(): void
|
||||||
|
{
|
||||||
|
Livewire::test(ByDigitalOcean::class, ['selectedTokenUuid' => test()->digitalOceanToken->uuid])
|
||||||
|
->assertSet('current_step', 2)
|
||||||
|
->set('server_name', 'test-do-server')
|
||||||
|
->set('selected_region', 'nyc1')
|
||||||
|
->set('selected_size', 's-1vcpu-1gb')
|
||||||
|
->set('selected_image', 'ubuntu-24-04-x64')
|
||||||
|
->set('private_key_id', test()->privateKey->id)
|
||||||
|
->call('submit')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('persists the server with a placeholder IP when waiting for the droplet IP fails', function () {
|
||||||
|
Http::fake([
|
||||||
|
'https://api.digitalocean.com/v2/account/keys' => Http::response([
|
||||||
|
'ssh_key' => ['id' => 123],
|
||||||
|
], 201),
|
||||||
|
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
|
||||||
|
'ssh_keys' => [],
|
||||||
|
], 200),
|
||||||
|
'https://api.digitalocean.com/v2/droplets' => Http::response([
|
||||||
|
'droplet' => ['id' => 555, 'status' => 'new'],
|
||||||
|
], 202),
|
||||||
|
'https://api.digitalocean.com/v2/droplets/555' => Http::response(['message' => 'server error'], 500),
|
||||||
|
]);
|
||||||
|
|
||||||
|
submitDigitalOceanServer();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('servers', [
|
||||||
|
'name' => 'test-do-server',
|
||||||
|
'ip' => Server::PLACEHOLDER_IP,
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'cloud_provider_token_id' => $this->digitalOceanToken->id,
|
||||||
|
'digitalocean_droplet_id' => '555',
|
||||||
|
'digitalocean_droplet_status' => 'new',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates the placeholder IP once the droplet reports one', function () {
|
||||||
|
Http::fake([
|
||||||
|
'https://api.digitalocean.com/v2/account/keys' => Http::response([
|
||||||
|
'ssh_key' => ['id' => 123],
|
||||||
|
], 201),
|
||||||
|
'https://api.digitalocean.com/v2/account/keys*' => Http::response([
|
||||||
|
'ssh_keys' => [],
|
||||||
|
], 200),
|
||||||
|
'https://api.digitalocean.com/v2/droplets' => Http::response([
|
||||||
|
'droplet' => ['id' => 555, 'status' => 'new'],
|
||||||
|
], 202),
|
||||||
|
'https://api.digitalocean.com/v2/droplets/555' => Http::response([
|
||||||
|
'droplet' => [
|
||||||
|
'id' => 555,
|
||||||
|
'status' => 'active',
|
||||||
|
'networks' => [
|
||||||
|
'v4' => [
|
||||||
|
['type' => 'public', 'ip_address' => '203.0.113.40'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
submitDigitalOceanServer();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('servers', [
|
||||||
|
'name' => 'test-do-server',
|
||||||
|
'ip' => '203.0.113.40',
|
||||||
|
'digitalocean_droplet_id' => '555',
|
||||||
|
'digitalocean_droplet_status' => 'active',
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders only the full width buy button at the bottom of the DigitalOcean form', function () {
|
it('renders only the full width buy button at the bottom of the DigitalOcean form', function () {
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,17 @@ function expectMcpAuditLog(array $expected): void
|
||||||
$response->assertJson(['message' => 'MCP server is disabled for this team.']);
|
$response->assertJson(['message' => 'MCP server is disabled for this team.']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('MCP endpoint is enabled for teams by default', function () {
|
||||||
|
$defaultTeam = Team::factory()->create();
|
||||||
|
$this->user->teams()->attach($defaultTeam->id, ['role' => 'owner']);
|
||||||
|
session(['currentTeam' => $defaultTeam]);
|
||||||
|
$token = $this->user->createToken('mcp-read', ['read'])->plainTextToken;
|
||||||
|
|
||||||
|
$response = mcpListTools($token);
|
||||||
|
|
||||||
|
$response->assertOk();
|
||||||
|
});
|
||||||
|
|
||||||
test('MCP endpoint rejects unauthenticated requests', function () {
|
test('MCP endpoint rejects unauthenticated requests', function () {
|
||||||
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
|
$response = mcpPost(['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
|
||||||
$response->assertStatus(401);
|
$response->assertStatus(401);
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,10 @@
|
||||||
uses(RefreshDatabase::class);
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
beforeEach(function () {
|
beforeEach(function () {
|
||||||
InstanceSettings::create(['id' => 0, 'is_api_enabled' => true]);
|
InstanceSettings::unguarded(fn () => InstanceSettings::updateOrCreate(
|
||||||
|
['id' => 0],
|
||||||
|
['is_api_enabled' => true],
|
||||||
|
));
|
||||||
|
|
||||||
$this->team = Team::factory()->create();
|
$this->team = Team::factory()->create();
|
||||||
$this->user = User::factory()->create();
|
$this->user = User::factory()->create();
|
||||||
|
|
|
||||||
|
|
@ -126,3 +126,112 @@ function createPushUpdatePostgresql(Team $team, array $attributes = []): Standal
|
||||||
|
|
||||||
return $database;
|
return $database;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test('partial sentinel snapshots do not mark missing databases as exited', function () {
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$database = createPushUpdatePostgresql($team, [
|
||||||
|
'status' => 'running:healthy',
|
||||||
|
'is_public' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$server = $database->destination->server;
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'snapshot' => [
|
||||||
|
'version' => 1,
|
||||||
|
'complete' => false,
|
||||||
|
],
|
||||||
|
'containers' => [
|
||||||
|
[
|
||||||
|
'name' => 'unrelated-container',
|
||||||
|
'state' => 'running',
|
||||||
|
'health_status' => 'healthy',
|
||||||
|
'labels' => [
|
||||||
|
'coolify.managed' => 'true',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$job = new PushServerUpdateJob($server, $data);
|
||||||
|
$job->handle();
|
||||||
|
|
||||||
|
$database->refresh();
|
||||||
|
|
||||||
|
expect($database->status)->toBe('running:healthy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial sentinel snapshots apply directly observed database status updates', function () {
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$database = createPushUpdatePostgresql($team, [
|
||||||
|
'status' => 'exited',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$server = $database->destination->server;
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'snapshot' => [
|
||||||
|
'version' => 1,
|
||||||
|
'complete' => false,
|
||||||
|
],
|
||||||
|
'containers' => [
|
||||||
|
[
|
||||||
|
'name' => $database->uuid,
|
||||||
|
'state' => 'running',
|
||||||
|
'health_status' => 'healthy',
|
||||||
|
'labels' => [
|
||||||
|
'coolify.managed' => 'true',
|
||||||
|
'coolify.type' => 'database',
|
||||||
|
'com.docker.compose.service' => $database->uuid,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$job = new PushServerUpdateJob($server, $data);
|
||||||
|
$job->handle();
|
||||||
|
|
||||||
|
$database->refresh();
|
||||||
|
|
||||||
|
expect($database->status)->toBe('running:healthy');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial sentinel snapshots do not trigger missing tcp proxy recovery', function () {
|
||||||
|
$team = Team::factory()->create();
|
||||||
|
$database = createPushUpdatePostgresql($team, [
|
||||||
|
'status' => 'exited',
|
||||||
|
'is_public' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$server = $database->destination->server;
|
||||||
|
Queue::fake();
|
||||||
|
|
||||||
|
$data = [
|
||||||
|
'snapshot' => [
|
||||||
|
'version' => 1,
|
||||||
|
'complete' => false,
|
||||||
|
],
|
||||||
|
'containers' => [
|
||||||
|
[
|
||||||
|
'name' => $database->uuid,
|
||||||
|
'state' => 'running',
|
||||||
|
'health_status' => 'healthy',
|
||||||
|
'labels' => [
|
||||||
|
'coolify.managed' => 'true',
|
||||||
|
'coolify.type' => 'database',
|
||||||
|
'com.docker.compose.service' => $database->uuid,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$job = new PushServerUpdateJob($server, $data);
|
||||||
|
$job->handle();
|
||||||
|
|
||||||
|
$database->refresh();
|
||||||
|
|
||||||
|
expect($database->status)->toBe('running:healthy');
|
||||||
|
Queue::assertNothingPushed();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -196,3 +196,24 @@ function sentinelPayload(array $containers, ?float $diskPercentage = 42.0): arra
|
||||||
|
|
||||||
Queue::assertNotPushed(PushServerUpdateJob::class);
|
Queue::assertNotPushed(PushServerUpdateJob::class);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('dispatches a complete snapshot after an identical partial snapshot', function () use ($running) {
|
||||||
|
$partialPayload = sentinelPayload($running()) + [
|
||||||
|
'snapshot' => [
|
||||||
|
'version' => 1,
|
||||||
|
'complete' => false,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$completePayload = sentinelPayload($running()) + [
|
||||||
|
'snapshot' => [
|
||||||
|
'version' => 1,
|
||||||
|
'complete' => true,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
pushSentinel($this->token, $partialPayload)->assertOk();
|
||||||
|
pushSentinel($this->token, $completePayload)->assertOk();
|
||||||
|
|
||||||
|
Queue::assertPushed(PushServerUpdateJob::class, 2);
|
||||||
|
});
|
||||||
|
|
|
||||||
118
tests/Feature/Server/HetznerServerPlaceholderIpTest.php
Normal file
118
tests/Feature/Server/HetznerServerPlaceholderIpTest.php
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Livewire\Server\New\ByHetzner;
|
||||||
|
use App\Models\CloudProviderToken;
|
||||||
|
use App\Models\InstanceSettings;
|
||||||
|
use App\Models\PrivateKey;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
|
||||||
|
uses(RefreshDatabase::class);
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
config([
|
||||||
|
'cache.default' => 'array',
|
||||||
|
'session.driver' => 'array',
|
||||||
|
]);
|
||||||
|
|
||||||
|
InstanceSettings::unguarded(fn () => InstanceSettings::query()->create([
|
||||||
|
'id' => 0,
|
||||||
|
]));
|
||||||
|
|
||||||
|
$this->team = Team::factory()->create();
|
||||||
|
$this->user = User::factory()->create();
|
||||||
|
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||||
|
|
||||||
|
$this->actingAs($this->user);
|
||||||
|
session(['currentTeam' => $this->team]);
|
||||||
|
|
||||||
|
$this->hetznerToken = CloudProviderToken::create([
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'provider' => 'hetzner',
|
||||||
|
'token' => 'test-hetzner-token',
|
||||||
|
'name' => 'Test Hetzner Token',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->privateKey = PrivateKey::factory()->create(['team_id' => $this->team->id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists the server with a placeholder IP when Hetzner has not assigned one yet', function () {
|
||||||
|
Http::fake([
|
||||||
|
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||||
|
'ssh_key' => ['id' => 42, 'fingerprint' => 'ff:ff'],
|
||||||
|
], 201),
|
||||||
|
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||||
|
'ssh_keys' => [],
|
||||||
|
], 200),
|
||||||
|
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||||
|
'server' => [
|
||||||
|
'id' => 777,
|
||||||
|
'status' => 'initializing',
|
||||||
|
'public_net' => [
|
||||||
|
'ipv4' => null,
|
||||||
|
'ipv6' => null,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 201),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid])
|
||||||
|
->assertSet('current_step', 2)
|
||||||
|
->set('server_name', 'test-hetzner-server')
|
||||||
|
->set('selected_location', 'fsn1')
|
||||||
|
->set('selected_server_type', 'cx22')
|
||||||
|
->set('selected_image', 114690387)
|
||||||
|
->set('private_key_id', $this->privateKey->id)
|
||||||
|
->call('submit')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('servers', [
|
||||||
|
'name' => 'test-hetzner-server',
|
||||||
|
'ip' => Server::PLACEHOLDER_IP,
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'cloud_provider_token_id' => $this->hetznerToken->id,
|
||||||
|
'hetzner_server_id' => '777',
|
||||||
|
'hetzner_server_status' => 'initializing',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates the server with the real IP when Hetzner assigns one immediately', function () {
|
||||||
|
Http::fake([
|
||||||
|
'https://api.hetzner.cloud/v1/ssh_keys' => Http::response([
|
||||||
|
'ssh_key' => ['id' => 42, 'fingerprint' => 'ff:ff'],
|
||||||
|
], 201),
|
||||||
|
'https://api.hetzner.cloud/v1/ssh_keys*' => Http::response([
|
||||||
|
'ssh_keys' => [],
|
||||||
|
], 200),
|
||||||
|
'https://api.hetzner.cloud/v1/servers' => Http::response([
|
||||||
|
'server' => [
|
||||||
|
'id' => 777,
|
||||||
|
'status' => 'running',
|
||||||
|
'public_net' => [
|
||||||
|
'ipv4' => ['ip' => '203.0.113.30'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 201),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ByHetzner::class, ['selectedTokenUuid' => $this->hetznerToken->uuid])
|
||||||
|
->assertSet('current_step', 2)
|
||||||
|
->set('server_name', 'test-hetzner-server')
|
||||||
|
->set('selected_location', 'fsn1')
|
||||||
|
->set('selected_server_type', 'cx22')
|
||||||
|
->set('selected_image', 114690387)
|
||||||
|
->set('private_key_id', $this->privateKey->id)
|
||||||
|
->call('submit')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('servers', [
|
||||||
|
'name' => 'test-hetzner-server',
|
||||||
|
'ip' => '203.0.113.30',
|
||||||
|
'hetzner_server_id' => '777',
|
||||||
|
'hetzner_server_status' => 'running',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
@ -15,8 +15,8 @@ function serviceExtraFieldsTestServiceWithApplicationImage(string $image): Servi
|
||||||
$team = Team::factory()->create();
|
$team = Team::factory()->create();
|
||||||
$project = Project::factory()->create(['team_id' => $team->id]);
|
$project = Project::factory()->create(['team_id' => $team->id]);
|
||||||
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
$environment = Environment::factory()->create(['project_id' => $project->id]);
|
||||||
$server = Server::factory()->create();
|
$server = Server::factory()->create(['team_id' => $team->id]);
|
||||||
$destination = StandaloneDocker::factory()->create(['server_id' => $server->id]);
|
$destination = StandaloneDocker::where('server_id', $server->id)->firstOrFail();
|
||||||
|
|
||||||
$service = Service::factory()->create([
|
$service = Service::factory()->create([
|
||||||
'environment_id' => $environment->id,
|
'environment_id' => $environment->id,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
use App\Models\CloudProviderToken;
|
use App\Models\CloudProviderToken;
|
||||||
use App\Models\InstanceSettings;
|
use App\Models\InstanceSettings;
|
||||||
use App\Models\PrivateKey;
|
use App\Models\PrivateKey;
|
||||||
|
use App\Models\Server;
|
||||||
use App\Models\Team;
|
use App\Models\Team;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
|
@ -95,7 +96,7 @@ function vultrLivewireTestPublicKey(): string
|
||||||
'instance' => [
|
'instance' => [
|
||||||
'id' => 'instance-1',
|
'id' => 'instance-1',
|
||||||
'label' => 'test-vultr-server',
|
'label' => 'test-vultr-server',
|
||||||
'main_ip' => '1.2.3.4',
|
'main_ip' => '192.0.2.10',
|
||||||
'v6_main_ip' => '2001:db8::1',
|
'v6_main_ip' => '2001:db8::1',
|
||||||
'status' => 'pending',
|
'status' => 'pending',
|
||||||
],
|
],
|
||||||
|
|
@ -115,7 +116,7 @@ function vultrLivewireTestPublicKey(): string
|
||||||
|
|
||||||
$this->assertDatabaseHas('servers', [
|
$this->assertDatabaseHas('servers', [
|
||||||
'name' => 'test-vultr-server',
|
'name' => 'test-vultr-server',
|
||||||
'ip' => '1.2.3.4',
|
'ip' => '192.0.2.10',
|
||||||
'team_id' => $this->team->id,
|
'team_id' => $this->team->id,
|
||||||
'cloud_provider_token_id' => $this->vultrToken->id,
|
'cloud_provider_token_id' => $this->vultrToken->id,
|
||||||
'vultr_instance_id' => 'instance-1',
|
'vultr_instance_id' => 'instance-1',
|
||||||
|
|
@ -132,6 +133,45 @@ function vultrLivewireTestPublicKey(): string
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists the server with a placeholder IP when Vultr has not assigned one yet', function () {
|
||||||
|
Http::fake([
|
||||||
|
'https://api.vultr.com/v2/ssh-keys' => Http::response([
|
||||||
|
'ssh_key' => ['id' => 'key-1', 'ssh_key' => vultrLivewireTestPublicKey()],
|
||||||
|
], 201),
|
||||||
|
'https://api.vultr.com/v2/ssh-keys*' => Http::response([
|
||||||
|
'ssh_keys' => [],
|
||||||
|
'meta' => ['links' => ['next' => null]],
|
||||||
|
], 200),
|
||||||
|
'https://api.vultr.com/v2/instances/instance-1' => Http::response(['error' => 'temporary error'], 500),
|
||||||
|
'https://api.vultr.com/v2/instances' => Http::response([
|
||||||
|
'instance' => [
|
||||||
|
'id' => 'instance-1',
|
||||||
|
'label' => 'test-vultr-server',
|
||||||
|
'main_ip' => '0.0.0.0',
|
||||||
|
'v6_main_ip' => '::',
|
||||||
|
'status' => 'pending',
|
||||||
|
],
|
||||||
|
], 202),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ByVultr::class, ['selectedTokenUuid' => $this->vultrToken->uuid])
|
||||||
|
->set('server_name', 'test-vultr-server')
|
||||||
|
->set('selected_region', 'ewr')
|
||||||
|
->set('selected_plan', 'vc2-1c-1gb')
|
||||||
|
->set('selected_os_id', 2284)
|
||||||
|
->set('private_key_id', $this->privateKey->id)
|
||||||
|
->call('submit')
|
||||||
|
->assertHasNoErrors();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('servers', [
|
||||||
|
'name' => 'test-vultr-server',
|
||||||
|
'ip' => Server::PLACEHOLDER_IP,
|
||||||
|
'team_id' => $this->team->id,
|
||||||
|
'vultr_instance_id' => 'instance-1',
|
||||||
|
'vultr_instance_status' => 'pending',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('requires IPv6 when public IPv4 is disabled', function () {
|
it('requires IPv6 when public IPv4 is disabled', function () {
|
||||||
Livewire::test(ByVultr::class)
|
Livewire::test(ByVultr::class)
|
||||||
->set('selected_token_id', $this->vultrToken->id)
|
->set('selected_token_id', $this->vultrToken->id)
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,14 @@
|
||||||
use App\Exceptions\DeploymentException;
|
use App\Exceptions\DeploymentException;
|
||||||
use App\Jobs\ApplicationDeploymentJob;
|
use App\Jobs\ApplicationDeploymentJob;
|
||||||
use App\Models\Application;
|
use App\Models\Application;
|
||||||
|
use App\Models\ApplicationDeploymentQueue;
|
||||||
use App\Models\ApplicationSetting;
|
use App\Models\ApplicationSetting;
|
||||||
use App\Models\EnvironmentVariable;
|
use App\Models\EnvironmentVariable;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
uses(TestCase::class);
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
class TestableRailpackDeploymentJob extends ApplicationDeploymentJob
|
class TestableRailpackDeploymentJob extends ApplicationDeploymentJob
|
||||||
{
|
{
|
||||||
|
|
@ -373,9 +375,9 @@ function invokeRailpackMethod(object $job, ReflectionClass $reflection, string $
|
||||||
]));
|
]));
|
||||||
|
|
||||||
foreach ([
|
foreach ([
|
||||||
'application_deployment_queue' => new class
|
'application_deployment_queue' => new class extends ApplicationDeploymentQueue
|
||||||
{
|
{
|
||||||
public function addLogEntry(string $message, string $type = 'info', bool $hidden = false): void {}
|
public function addLogEntry(string $message, string $type = 'stdout', bool $hidden = false): void {}
|
||||||
},
|
},
|
||||||
'build_pack' => 'railpack',
|
'build_pack' => 'railpack',
|
||||||
'pull_request_id' => 0,
|
'pull_request_id' => 0,
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat
|
||||||
'is_buildtime' => false,
|
'is_buildtime' => false,
|
||||||
'is_runtime' => true,
|
'is_runtime' => true,
|
||||||
'is_preview' => false,
|
'is_preview' => false,
|
||||||
|
'is_shown_once' => true,
|
||||||
'resourceable_type' => Application::class,
|
'resourceable_type' => Application::class,
|
||||||
'resourceable_id' => $application->id,
|
'resourceable_id' => $application->id,
|
||||||
]);
|
]);
|
||||||
|
|
@ -112,6 +113,7 @@ function markSnapshotTestApplicationDeployed(Application $application): Applicat
|
||||||
'is_buildtime' => false,
|
'is_buildtime' => false,
|
||||||
'is_runtime' => true,
|
'is_runtime' => true,
|
||||||
'is_preview' => false,
|
'is_preview' => false,
|
||||||
|
'is_shown_once' => true,
|
||||||
'resourceable_type' => Application::class,
|
'resourceable_type' => Application::class,
|
||||||
'resourceable_id' => $application->id,
|
'resourceable_id' => $application->id,
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
13
tests/Unit/DestinationsOpenApiTest.php
Normal file
13
tests/Unit/DestinationsOpenApiTest.php
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
it('defines OpenAPI documentation for every destinations endpoint', function () {
|
||||||
|
$controller = file_get_contents(__DIR__.'/../../app/Http/Controllers/Api/DestinationsController.php');
|
||||||
|
$model = file_get_contents(__DIR__.'/../../app/Models/StandaloneDocker.php');
|
||||||
|
|
||||||
|
expect($model)->toContain("schema: 'Destination'")
|
||||||
|
->and($controller)->toContain("operationId: 'list-destinations'")
|
||||||
|
->and($controller)->toContain("operationId: 'get-destination-by-uuid'")
|
||||||
|
->and($controller)->toContain("operationId: 'delete-destination-by-uuid'")
|
||||||
|
->and($controller)->toContain("operationId: 'list-server-destinations'")
|
||||||
|
->and($controller)->toContain("operationId: 'create-server-destination'");
|
||||||
|
});
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
|
|
||||||
uses(TestCase::class, RefreshDatabase::class);
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
function createDigitalOceanServerForStateTest(string $status = 'active'): Server
|
function createDigitalOceanServerForStateTest(string $status = 'active', array $attributes = []): Server
|
||||||
{
|
{
|
||||||
$team = Team::create([
|
$team = Team::create([
|
||||||
'name' => 'Test Team',
|
'name' => 'Test Team',
|
||||||
|
|
@ -24,13 +24,13 @@ function createDigitalOceanServerForStateTest(string $status = 'active'): Server
|
||||||
]);
|
]);
|
||||||
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||||
|
|
||||||
return Server::factory()->create([
|
return Server::factory()->create(array_merge([
|
||||||
'team_id' => $team->id,
|
'team_id' => $team->id,
|
||||||
'private_key_id' => $privateKey->id,
|
'private_key_id' => $privateKey->id,
|
||||||
'cloud_provider_token_id' => $token->id,
|
'cloud_provider_token_id' => $token->id,
|
||||||
'digitalocean_droplet_id' => 987,
|
'digitalocean_droplet_id' => 987,
|
||||||
'digitalocean_droplet_status' => $status,
|
'digitalocean_droplet_status' => $status,
|
||||||
]);
|
], $attributes));
|
||||||
}
|
}
|
||||||
|
|
||||||
it('marks a DigitalOcean droplet as deleted when the provider returns 404', function () {
|
it('marks a DigitalOcean droplet as deleted when the provider returns 404', function () {
|
||||||
|
|
@ -45,6 +45,48 @@ function createDigitalOceanServerForStateTest(string $status = 'active'): Server
|
||||||
expect($server->fresh()->digitalocean_droplet_status)->toBe('deleted');
|
expect($server->fresh()->digitalocean_droplet_status)->toBe('deleted');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('backfills a placeholder IP from the DigitalOcean droplet state', function () {
|
||||||
|
$server = createDigitalOceanServerForStateTest('new', ['ip' => Server::PLACEHOLDER_IP]);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.digitalocean.com/v2/droplets/987' => Http::response([
|
||||||
|
'droplet' => [
|
||||||
|
'id' => 987,
|
||||||
|
'status' => 'active',
|
||||||
|
'networks' => [
|
||||||
|
'v4' => [
|
||||||
|
['type' => 'public', 'ip_address' => '203.0.113.10'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($server->refreshDigitalOceanState())->toBe('active');
|
||||||
|
expect($server->fresh()->ip)->toBe('203.0.113.10');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite a real IP from the DigitalOcean droplet state', function () {
|
||||||
|
$server = createDigitalOceanServerForStateTest('active', ['ip' => '198.51.100.20']);
|
||||||
|
|
||||||
|
Http::fake([
|
||||||
|
'https://api.digitalocean.com/v2/droplets/987' => Http::response([
|
||||||
|
'droplet' => [
|
||||||
|
'id' => 987,
|
||||||
|
'status' => 'active',
|
||||||
|
'networks' => [
|
||||||
|
'v4' => [
|
||||||
|
['type' => 'public', 'ip_address' => '203.0.113.10'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
], 200),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$server->refreshDigitalOceanState();
|
||||||
|
expect($server->fresh()->ip)->toBe('198.51.100.20');
|
||||||
|
});
|
||||||
|
|
||||||
it('does not mark a DigitalOcean droplet as deleted on transient provider errors', function () {
|
it('does not mark a DigitalOcean droplet as deleted on transient provider errors', function () {
|
||||||
$server = createDigitalOceanServerForStateTest();
|
$server = createDigitalOceanServerForStateTest();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,12 @@
|
||||||
<?php
|
<?php
|
||||||
|
|
||||||
test('expanded navbar theme item cycles themes without a right side selector', function () {
|
test('navbar does not render the removed theme switcher', function () {
|
||||||
$navbar = file_get_contents(__DIR__.'/../../resources/views/components/navbar.blade.php');
|
$navbar = file_get_contents(__DIR__.'/../../resources/views/components/navbar.blade.php');
|
||||||
|
|
||||||
expect(substr_count($navbar, '@click.stop="cycleTheme()"'))->toBe(2)
|
expect($navbar)->not->toContain('cycleTheme()')
|
||||||
->and($navbar)->not->toContain('aria-label="Theme switcher"')
|
->and($navbar)->not->toContain('aria-label="Theme switcher"')
|
||||||
->and($navbar)->not->toContain('ml-auto flex items-center gap-0.5')
|
|
||||||
->and($navbar)->not->toContain('@click.stop="setTheme(\'light\')"')
|
->and($navbar)->not->toContain('@click.stop="setTheme(\'light\')"')
|
||||||
->and($navbar)->not->toContain('@click.stop="setTheme(\'system\')"')
|
->and($navbar)->not->toContain('@click.stop="setTheme(\'system\')"')
|
||||||
->and($navbar)->not->toContain('@click.stop="setTheme(\'dark\')"')
|
->and($navbar)->not->toContain('@click.stop="setTheme(\'dark\')"')
|
||||||
->and($navbar)->toContain('<span class="text-left menu-item-label">Theme</span>');
|
->and($navbar)->not->toContain('<span class="text-left menu-item-label">Theme</span>');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
67
tests/Unit/ServerPlaceholderIpTest.php
Normal file
67
tests/Unit/ServerPlaceholderIpTest.php
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\PrivateKey;
|
||||||
|
use App\Models\Server;
|
||||||
|
use App\Models\Team;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
uses(TestCase::class, RefreshDatabase::class);
|
||||||
|
|
||||||
|
function createServerForPlaceholderIpTest(string $ip): Server
|
||||||
|
{
|
||||||
|
$team = Team::create([
|
||||||
|
'name' => 'Test Team',
|
||||||
|
'personal_team' => false,
|
||||||
|
]);
|
||||||
|
$privateKey = PrivateKey::factory()->create(['team_id' => $team->id]);
|
||||||
|
|
||||||
|
return Server::factory()->create([
|
||||||
|
'team_id' => $team->id,
|
||||||
|
'private_key_id' => $privateKey->id,
|
||||||
|
'ip' => $ip,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('detects placeholder IPs', function () {
|
||||||
|
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
|
||||||
|
|
||||||
|
foreach ([Server::PLACEHOLDER_IP, '0.0.0.0', '::'] as $ip) {
|
||||||
|
$server->update(['ip' => $ip]);
|
||||||
|
|
||||||
|
expect($server->fresh()->hasPlaceholderIp())->toBeTrue();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat a real IP as a placeholder', function () {
|
||||||
|
$server = createServerForPlaceholderIpTest('203.0.113.5');
|
||||||
|
|
||||||
|
expect($server->hasPlaceholderIp())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('backfills a placeholder IP with the real address', function () {
|
||||||
|
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
|
||||||
|
|
||||||
|
expect($server->backfillPlaceholderIp('203.0.113.5'))->toBeTrue();
|
||||||
|
expect($server->fresh()->ip)->toBe('203.0.113.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not overwrite a real IP when backfilling', function () {
|
||||||
|
$server = createServerForPlaceholderIpTest('203.0.113.5');
|
||||||
|
|
||||||
|
expect($server->backfillPlaceholderIp('198.51.100.9'))->toBeFalse();
|
||||||
|
expect($server->fresh()->ip)->toBe('203.0.113.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores a missing IP when backfilling', function () {
|
||||||
|
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
|
||||||
|
|
||||||
|
expect($server->backfillPlaceholderIp(null))->toBeFalse();
|
||||||
|
expect($server->fresh()->ip)->toBe(Server::PLACEHOLDER_IP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips servers with a placeholder IP in scheduled jobs', function () {
|
||||||
|
$server = createServerForPlaceholderIpTest(Server::PLACEHOLDER_IP);
|
||||||
|
|
||||||
|
expect($server->skipServer())->toBeTrue();
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue