130 lines
4.0 KiB
PHP
130 lines
4.0 KiB
PHP
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use App\Models\Concerns\HasUuid;
|
||
use App\Provisioning\Contracts\ProvisioningSubject;
|
||
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<\Database\Factories\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
|
||
{
|
||
return (int) $this->instances()
|
||
->where(fn ($q) => $q->where('status', '!=', 'failed')->orWhereNotNull('vmid'))
|
||
->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',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 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);
|
||
}
|
||
}
|