*/ use HasFactory, HasUuid; protected $fillable = [ 'name', 'datacenter', 'cluster', 'public_ip', 'wg_ip', 'wg_pubkey', 'ssh_host_key', 'api_token_ref', 'total_gb', 'total_ram_mb', 'cpu_cores', 'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', 'dns_name', 'dns_record_id', ]; protected $hidden = ['api_token_ref']; protected function casts(): array { return [ 'api_token_ref' => 'encrypted', 'last_seen_at' => 'datetime', 'total_gb' => 'integer', 'total_ram_mb' => 'integer', 'cpu_cores' => 'integer', 'cpu_weight' => 'integer', 'reserve_pct' => 'integer', ]; } /** Placement runs (polymorphic subject). */ public function runs(): MorphMany { return $this->morphMany(ProvisioningRun::class, 'subject'); } /** Runner hook: a failed onboarding run moves the host to the error status. */ public function onProvisioningFailed(): void { $this->update(['status' => 'error']); } public function instances(): HasMany { return $this->hasMany(Instance::class); } /** * The synced VPN peer row SyncVpnPeers adopts for this host (see that * job's docblock — the console container has no wg0, so this table, not a * live hub call, is the only honest source for handshake state here). */ public function vpnPeer(): HasOne { return $this->hasOne(VpnPeer::class); } public function maintenanceWindows(): BelongsToMany { return $this->belongsToMany(MaintenanceWindow::class); } /** Free committable storage: total minus reserve. */ public function freeGb(): int { if ($this->total_gb === null) { return 0; } return (int) floor($this->total_gb * (100 - $this->reserve_pct) / 100); } /** * Host storage already committed, counted as the VM disk allocation * (disk_gb) since that is what is actually placed on the host — not the * (smaller) Nextcloud user quota. A failed instance still counts while its VM * exists (has a vmid); a failure before any VM was created releases it. */ public function committedGb(): int { // Answer a preloaded sum when the caller supplied one (see // Instance::scopeOccupyingHost) — listing every host otherwise costs a // query per host, twice over once usedPct() asks again. // // Tested for PRESENCE, not for null: SUM over no rows is null, so a // host with nothing on it arrives preloaded-but-null. Reading that as // "not preloaded" would re-query for exactly the empty hosts, which is // the case the preload exists for. if (array_key_exists('committed_disk_gb', $this->getAttributes())) { return (int) $this->getAttributes()['committed_disk_gb']; } return (int) $this->instances()->occupyingHost()->sum('disk_gb'); } /** Storage still available for new instances. */ public function availableGb(): int { return max(0, $this->freeGb() - $this->committedGb()); } /** Used-storage percentage (committed vs committable free), 0–100. */ public function usedPct(): int { $free = $this->freeGb(); return $free > 0 ? min(100, (int) round($this->committedGb() / $free * 100)) : 0; } /** * Heartbeat health from last_seen_at: online (≤5 min), stale (≤30 min), * offline (older or never seen). Drives the health dot in the console. */ public function healthState(): string { if ($this->last_seen_at === null) { return 'offline'; } $minutes = $this->last_seen_at->diffInMinutes(now()); return match (true) { $minutes <= 5 => 'online', $minutes <= 30 => 'stale', default => 'offline', }; } /** * WireGuard rekeys a live tunnel every REKEY_AFTER_TIME (120s, protocol * constant) whenever there is traffic, and considers a session dead outright * after REJECT_AFTER_TIME (180s). 180s is therefore "the tunnel protocol * itself would call this gone" — one rekey cycle of slack over the 120s * baseline, not a round number picked for looks. */ public const VPN_HANDSHAKE_FRESH_SECONDS = 180; /** * How old `vpn_peers.observed_at` may be before its `last_handshake_at` is * no longer trusted. SyncVpnPeers runs every minute (routes/console.php); * 300s is five missed scheduler ticks — the same order of magnitude as * healthState()'s own "still counts as online" heartbeat window (5 min) * for a per-minute signal, generous enough to absorb ordinary queue * latency without being trigger-happy, tight enough to actually catch the * sync worker being down rather than the tunnel. */ public const VPN_SYNC_STALE_SECONDS = 300; /** * VPN tunnel state for the console's hosts list. * * `wg_pubkey` only proves onboarding CONFIGURED a peer — a tunnel silent for * two days looks identical in the database to one that handshaked a minute * ago. $peer (the row SyncVpnPeers keeps current — see that job's docblock: * the console container has no wg0, so this table is the only honest source * here) carries both `last_handshake_at` and `observed_at`. * * Two different kinds of "we don't know" collapse to `unknown`, not to a * guessed `silent`: no row at all (never synced yet), and a row whose * `observed_at` is stale — because a stopped sync worker leaves * `last_handshake_at` frozen at whatever it last saw, which would * otherwise be misread as "the tunnel went silent" when the truth is * "nobody has looked lately". */ public function vpnState(?VpnPeer $peer): string { if ($this->wg_pubkey === null) { return 'not_configured'; } if ($peer === null || $peer->observed_at === null || $peer->observed_at->diffInSeconds(now()) > self::VPN_SYNC_STALE_SECONDS) { return 'unknown'; } if ($peer->last_handshake_at !== null && $peer->last_handshake_at->diffInSeconds(now()) <= self::VPN_HANDSHAKE_FRESH_SECONDS) { return 'connected'; } return 'silent'; } /** * Placement (spec §1): first active host in the datacenter with enough free * committable storage. Cluster is ignored in v1.0. */ public static function placeableIn(string $datacenter, int $quotaGb): ?self { return self::query() ->where('datacenter', $datacenter) ->where('status', 'active') ->orderBy('name') ->get() ->first(fn (self $host) => $host->availableGb() >= $quotaGb); } }