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

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