179 lines
6.0 KiB
PHP
179 lines
6.0 KiB
PHP
<?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), 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;
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|