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:
Andras Bacsai 2026-07-11 21:35:10 +02:00
parent d3fbb32c52
commit e01b8a057e
29 changed files with 1275 additions and 59 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View 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();
});