mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 04:18:34 +00:00
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:
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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in new issue
Block a user