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

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