CluPilotCloud/app/Models/Host.php

244 lines
8.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<?php
namespace App\Models;
use App\Models\Concerns\HasUuid;
use App\Provisioning\Contracts\ProvisioningSubject;
use App\Services\Secrets\SecretCipher;
use Database\Factories\HostFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\Relations\MorphMany;
class Host extends Model implements ProvisioningSubject
{
/** @use HasFactory<HostFactory> */
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 [
'last_seen_at' => 'datetime',
'total_gb' => 'integer',
'total_ram_mb' => 'integer',
'cpu_cores' => 'integer',
'cpu_weight' => 'integer',
'reserve_pct' => 'integer',
];
}
/**
* The Proxmox API token, stored under SECRETS_KEY — NOT through Laravel's
* `encrypted` cast.
*
* That cast uses APP_KEY, and SecretVault's docblock states the rule this
* column was breaking: rotating APP_KEY is ordinary maintenance, and every
* credential that lives behind it would become unreadable at once. For this
* column that means every host's automation token in the same moment — no
* Proxmox API on any host, discovered when provisioning stops rather than
* when the key is rotated. Same handling as Mailbox::password, through the
* same SecretCipher, so there is one key story and not two.
*/
public function setApiTokenRefAttribute(?string $value): void
{
$this->attributes['api_token_ref'] = ($value === null || $value === '')
? null
: app(SecretCipher::class)->encrypt($value);
}
public function getApiTokenRefAttribute(?string $value): ?string
{
return ($value === null || $value === '')
? null
: app(SecretCipher::class)->decrypt($value);
}
/** 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 provisioningPipeline(): string
{
return 'host';
}
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), 0100. */
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);
}
/** Could this particular host take an instance of this size right now? */
public function canTake(int $quotaGb): bool
{
return $this->status === 'active' && $this->availableGb() >= $quotaGb;
}
}