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