CluPilotCloud/app/Models/Host.php

179 lines
6.0 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\Wireguard\PeerSnapshot;
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\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 [
'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);
}
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;
/**
* 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. $snapshot (from WireguardHub::peers(), live and not persisted) is
* what actually answers "is it up right now"; $hubReachable distinguishes
* "the hub said no recent handshake" from "the hub could not be asked at
* all" — collapsing those would show a false state instead of an honest one.
*/
public function vpnState(?PeerSnapshot $snapshot, bool $hubReachable): string
{
if ($this->wg_pubkey === null) {
return 'not_configured';
}
if (! $hubReachable) {
return 'unknown';
}
if ($snapshot?->latestHandshake !== null
&& $snapshot->latestHandshake->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);
}
}