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 committed 2026-07-11 21:35:10 +02:00
1 parent d3fbb32c52
commit e01b8a057e
29 files changed
+1275 -59

No files matched your search

@@ -10,6 +10,7 @@ use App\Models\SwarmDocker;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
class DestinationsController extends Controller
{
@@ -59,6 +60,22 @@ class DestinationsController extends Controller
?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
}
#[OA\Get(
summary: 'List destinations',
description: 'List all Docker network destinations for the authenticated team.',
path: '/destinations',
operationId: 'list-destinations',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
responses: [
new OA\Response(
response: 200,
description: 'Destinations for the authenticated team.',
content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
],
)]
public function index(Request $request): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@@ -74,6 +91,26 @@ class DestinationsController extends Controller
);
}
#[OA\Get(
summary: 'List destinations by server',
description: 'List Docker network destinations attached to a server owned by the authenticated team.',
path: '/servers/{server_uuid}/destinations',
operationId: 'list-server-destinations',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Destinations attached to the server.',
content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/Destination')),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function index_by_server(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@@ -89,6 +126,26 @@ class DestinationsController extends Controller
return response()->json($list->map(fn ($destination) => $this->transform($destination))->values());
}
#[OA\Get(
summary: 'Get destination',
description: 'Get a Docker network destination by UUID.',
path: '/destinations/{uuid}',
operationId: 'get-destination-by-uuid',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Destination details.',
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function show(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@@ -100,6 +157,40 @@ class DestinationsController extends Controller
return response()->json($this->transform($destination));
}
#[OA\Post(
summary: 'Create destination',
description: 'Create a Docker network destination on a server owned by the authenticated team.',
path: '/servers/{server_uuid}/destinations',
operationId: 'create-server-destination',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'server_uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['network'],
properties: [
new OA\Property(property: 'name', type: 'string', maxLength: 255),
new OA\Property(property: 'network', type: 'string', maxLength: 255, pattern: '^[a-zA-Z0-9][a-zA-Z0-9._-]*$'),
new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']),
],
type: 'object',
),
),
responses: [
new OA\Response(
response: 201,
description: 'Destination created.',
content: new OA\JsonContent(ref: '#/components/schemas/Destination'),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 409, description: 'A destination with this network already exists.'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function create(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@@ -183,6 +274,32 @@ class DestinationsController extends Controller
|| in_array($driverCode, ['19', '1062', '2067'], true);
}
#[OA\Delete(
summary: 'Delete destination',
description: 'Delete an unused Docker network destination.',
path: '/destinations/{uuid}',
operationId: 'delete-destination-by-uuid',
security: [['bearerAuth' => []]],
tags: ['Destinations'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Destination UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Destination deleted.',
content: new OA\JsonContent(
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Deleted.'),
],
type: 'object',
),
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 409, description: 'Destination has attached resources.'),
],
)]
public function delete(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
@@ -143,8 +143,9 @@ class SentinelController extends Controller
* health checks can flap between starting/healthy/unhealthy while the
* container lifecycle state remains unchanged. Both would otherwise defeat
* the hash and dispatch DB-heavy PushServerUpdateJob instances too often.
* The force window still refreshes full state periodically. Sorted by name
* so container ordering from Sentinel does not affect the hash.
* The snapshot completeness flag is included so a complete snapshot always
* dispatches after a partial snapshot. Sorted by name so container ordering
* from Sentinel does not affect the hash.
*/
private function containerStateHash(array $data): string
{
@@ -157,6 +158,14 @@ class SentinelController extends Controller
->values()
->all();
return hash('xxh128', json_encode($containers));
return hash('xxh128', json_encode([
'snapshot_complete' => $this->isCompleteSnapshot($data),
'containers' => $containers,
]));
}
private function isCompleteSnapshot(array $data): bool
{
return data_get($data, 'snapshot.complete', true) !== false;
}
}
+12
View File
@@ -311,6 +311,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
}
}
if (! $this->isCompleteSnapshot()) {
return;
}
$this->updateProxyStatus();
$this->updateNotFoundApplicationStatus();
@@ -329,6 +333,11 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$this->checkLogDrainContainer();
}
private function isCompleteSnapshot(): bool
{
return data_get($this->data, 'snapshot.complete', true) !== false;
}
private function loadApplications(): Collection
{
[$standaloneDockerIds, $swarmDockerIds] = $this->serverDestinationIds();
@@ -700,6 +709,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$database->status = $containerStatus;
$database->save();
}
if (! $this->isCompleteSnapshot()) {
return;
}
if ($this->isRunning($containerStatus) && $tcpProxy) {
$tcpProxyContainerFound = $this->containers->filter(function ($value, $key) use ($databaseUuid) {
return data_get($value, 'name') === "$databaseUuid-proxy" && data_get($value, 'state') === 'running';
+23 -19
View File
@@ -402,11 +402,11 @@ class ByDigitalOcean extends Component
}
/**
* @return array{droplet: array, ip: string|null}
* Create the droplet on DigitalOcean and return the raw droplet payload.
* The public IP may not be assigned yet at this point.
*/
private function createDigitalOceanDroplet(string $token): array
private function createDigitalOceanDroplet(DigitalOceanService $digitalOceanService): array
{
$digitalOceanService = new DigitalOceanService($token);
$privateKey = PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id);
$md5Fingerprint = PrivateKey::generateMd5Fingerprint($privateKey->private_key);
@@ -442,14 +442,7 @@ class ByDigitalOcean extends Component
$params['user_data'] = $this->cloud_init_script;
}
$droplet = $digitalOceanService->createDroplet($params);
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6);
$ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $this->enable_ipv6);
return [
'droplet' => $droplet,
'ip' => $ipAddress,
];
return $digitalOceanService->createDroplet($params);
}
public function submit()
@@ -473,17 +466,14 @@ class ByDigitalOcean extends Component
]);
}
$result = $this->createDigitalOceanDroplet($this->getDigitalOceanToken());
$droplet = $result['droplet'];
$ipAddress = $result['ip'];
if (! $ipAddress) {
throw new \Exception('No public IP address available for the new droplet.');
}
$digitalOceanService = new DigitalOceanService($this->getDigitalOceanToken());
$droplet = $this->createDigitalOceanDroplet($digitalOceanService);
// Persist the server immediately so the droplet is always tracked
// in Coolify, even if waiting for the public IP fails below.
$server = Server::create([
'name' => strtolower(trim($this->server_name)),
'ip' => $ipAddress,
'ip' => Server::PLACEHOLDER_IP,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
@@ -497,6 +487,20 @@ class ByDigitalOcean extends Component
$server->proxy->set('type', ProxyTypes::TRAEFIK->value);
$server->save();
try {
$droplet = $digitalOceanService->waitForPublicIp($droplet, true, $this->enable_ipv6);
$ipAddress = $digitalOceanService->getPublicIpAddress($droplet, true, $this->enable_ipv6);
if ($ipAddress) {
$server->update([
'ip' => $ipAddress,
'digitalocean_droplet_status' => $droplet['status'] ?? $server->digitalocean_droplet_status,
]);
}
} catch (\Throwable $e) {
// Non-fatal: the server page polling backfills the IP later.
report($e);
}
if ($this->from_onboarding) {
currentTeam()->update([
'show_boarding' => false,
+5 -6
View File
@@ -717,20 +717,19 @@ class ByHetzner extends Component
$ipAddress = $hetznerServer['public_net']['ipv6']['ip'];
}
if (! $ipAddress) {
throw new \Exception('No public IP address available. Enable at least one of IPv4 or IPv6.');
}
// Create server in Coolify database
// Create server in Coolify database immediately so the Hetzner
// server is always tracked, even when no IP is assigned yet —
// the server page polling backfills the placeholder IP later.
$server = Server::create([
'name' => $this->server_name,
'ip' => $ipAddress,
'ip' => $ipAddress ?? Server::PLACEHOLDER_IP,
'user' => 'root',
'port' => 22,
'team_id' => currentTeam()->id,
'private_key_id' => $this->private_key_id,
'cloud_provider_token_id' => $this->selected_token_id,
'hetzner_server_id' => $hetznerServer['id'],
'hetzner_server_status' => $hetznerServer['status'] ?? null,
]);
$server->proxy->set('status', 'exited');
+13 -8
View File
@@ -438,7 +438,7 @@ class ByVultr extends Component
$vultrService = new VultrService($this->getVultrToken());
$vultrInstance = $this->createVultrServer($this->getVultrToken());
$ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? '0.0.0.0';
$ipAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6) ?? Server::PLACEHOLDER_IP;
$server = Server::create([
'name' => strtolower(trim($this->server_name)),
@@ -452,13 +452,18 @@ class ByVultr extends Component
'vultr_instance_status' => $vultrInstance['status'] ?? null,
]);
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
$server->update([
'ip' => $assignedIpAddress,
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
]);
try {
$vultrInstance = $vultrService->waitForPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
$assignedIpAddress = $vultrService->getPublicIp($vultrInstance, $this->disable_public_ipv4, $this->enable_ipv6);
if ($assignedIpAddress && $assignedIpAddress !== $server->ip) {
$server->update([
'ip' => $assignedIpAddress,
'vultr_instance_status' => $vultrInstance['status'] ?? $server->vultr_instance_status,
]);
}
} catch (\Throwable $e) {
// Non-fatal: the server page polling backfills the IP later.
report($e);
}
$server->proxy->set('status', 'exited');
+5
View File
@@ -488,6 +488,11 @@ class Show extends Component
$this->server->hetzner_server_status = $this->hetznerServerStatus;
$this->server->update(['hetzner_server_status' => $this->hetznerServerStatus]);
}
$assignedIp = data_get($serverData, 'public_net.ipv4.ip') ?? data_get($serverData, 'public_net.ipv6.ip');
if ($this->server->backfillPlaceholderIp($assignedIp)) {
$this->ip = $this->server->ip;
}
if ($manual) {
$this->dispatch('success', 'Server status refreshed: '.ucfirst($this->hetznerServerStatus ?? 'unknown'));
}
+34 -5
View File
@@ -112,6 +112,13 @@ class Server extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasMetrics, SchemalessAttributesTrait, SoftDeletes;
/**
* Sentinel IP for servers that do not have a real address yet
* (cloud provisioning in progress or parked as unreachable).
* Scheduled jobs skip these servers via skipServer().
*/
public const PLACEHOLDER_IP = '1.2.3.4';
public static $batch_counter = 0;
/**
@@ -307,6 +314,29 @@ class Server extends BaseModel
return 'server';
}
public function hasPlaceholderIp(): bool
{
// Cast: the saving hook stores the ip as a Stringable in memory.
$ip = (string) $this->ip;
return blank($ip) || in_array($ip, [self::PLACEHOLDER_IP, '0.0.0.0', '::'], true);
}
/**
* Replace a placeholder IP with the real address once the cloud
* provider reports one. Returns true when the IP was updated.
*/
public function backfillPlaceholderIp(?string $ip): bool
{
if ($ip && $this->hasPlaceholderIp()) {
$this->update(['ip' => $ip]);
return true;
}
return false;
}
public function refreshVultrState(): ?string
{
if (! $this->vultr_instance_id || ! $this->cloudProviderToken) {
@@ -339,8 +369,7 @@ class Server extends BaseModel
$updates['vultr_instance_status'] = $status;
}
$hasPlaceholderIp = blank($this->ip) || in_array($this->ip, ['0.0.0.0', '::'], true);
if ($hasPlaceholderIp && $publicIp) {
if ($this->hasPlaceholderIp() && $publicIp) {
$updates['ip'] = $publicIp;
}
@@ -388,7 +417,7 @@ class Server extends BaseModel
$ip = $digitalOceanService->getPublicIpAddress($droplet);
$updates = ['digitalocean_droplet_status' => $status];
if ($ip && $ip !== $this->ip) {
if ($ip && $this->hasPlaceholderIp()) {
$updates['ip'] = $ip;
}
@@ -1176,7 +1205,7 @@ $schema://$host {
public function skipServer()
{
if ($this->ip === '1.2.3.4') {
if ($this->hasPlaceholderIp()) {
return true;
}
if ($this->settings->force_disabled === true) {
@@ -1188,7 +1217,7 @@ $schema://$host {
public function isFunctional()
{
$isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && $this->ip !== '1.2.3.4';
$isFunctional = data_get($this->settings, 'is_reachable') && data_get($this->settings, 'is_usable') && data_get($this->settings, 'force_disabled') === false && ! $this->hasPlaceholderIp();
if ($isFunctional === false) {
Storage::disk('ssh-mux')->delete($this->muxFilename());
+19
View File
@@ -7,7 +7,22 @@ use App\Support\ValidationPatterns;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use OpenApi\Attributes as OA;
#[OA\Schema(
schema: 'Destination',
description: 'A Docker network destination attached to a server.',
type: 'object',
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'network', type: 'string'),
new OA\Property(property: 'type', type: 'string', enum: ['standalone', 'swarm']),
new OA\Property(property: 'server_uuid', type: 'string'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
],
)]
class StandaloneDocker extends BaseModel
{
use HasFactory;
@@ -23,6 +38,10 @@ class StandaloneDocker extends BaseModel
{
parent::boot();
static::created(function ($newStandaloneDocker) {
if (app()->runningUnitTests()) {
return;
}
$server = $newStandaloneDocker->server;
$safeNetwork = escapeshellarg($newStandaloneDocker->network);
instant_remote_process([