*/ use HasFactory, HasUuid; protected $fillable = [ 'customer_id', 'order_id', 'host_id', 'vmid', 'guest_ip', 'plan', 'quota_gb', 'quota_applied_gb', 'traffic_addons', 'disk_gb', 'ram_mb', 'cores', 'restart_required_since', 'subdomain', 'custom_domain', 'nc_admin_ref', 'admin_password', 'credentials_acknowledged_at', 'route_written', 'routed_hostnames', 'cert_ok', 'status', 'cancel_requested_at', 'service_ends_at', 'domain_token', 'domain_verified_at', 'domain_cert_ok', 'domain_checked_at', 'domain_error', 'domain_failures', ]; protected $hidden = ['nc_admin_ref', 'admin_password']; protected function casts(): array { return [ 'nc_admin_ref' => 'encrypted', // The initial Nextcloud admin password, held only until the // customer acknowledges it on the dashboard (App\Livewire\ // Dashboard::acknowledgeCredentials). Same 'encrypted' cast as // nc_admin_ref and Host::api_token_ref, for the same reason. 'admin_password' => 'encrypted', 'credentials_acknowledged_at' => 'datetime', 'domain_verified_at' => 'datetime', 'domain_checked_at' => 'datetime', 'domain_failures' => 'integer', 'domain_cert_ok' => 'boolean', 'route_written' => 'boolean', 'routed_hostnames' => 'array', 'cert_ok' => 'boolean', 'vmid' => 'integer', 'traffic_addons' => 'integer', 'quota_gb' => 'integer', 'quota_applied_gb' => 'integer', 'disk_gb' => 'integer', 'ram_mb' => 'integer', 'cores' => 'integer', 'cancel_requested_at' => 'datetime', 'service_ends_at' => 'datetime', 'restart_required_since' => 'datetime', ]; } /** * Is this machine running on less CPU or RAM than it has been sold? * * Proxmox writes a new `cores`/`memory` into the VM's configuration * immediately and a running guest picks it up at its next cold boot. A plan * change therefore leaves an honest gap, and rebooting somebody's cloud as a * side effect of a purchase is not a way to close it — so the gap is stated * instead, here, and shown to the operator and to the customer. */ public function restartIsPending(): bool { return $this->restart_required_since !== null; } /** * Has this machine's Nextcloud actually been told the allowance that was * sold? * * Two columns rather than one because they answer two different questions: * `quota_gb` is what the customer bought, `quota_applied_gb` is what the * guest was last told. They agree on every machine the quota step has * visited since it started recording, and they disagree on exactly the two * cases worth finding — the estate built before the step existed, and any * machine whose package changed while the run that should have followed did * not finish. */ public function quotaIsEnforced(): bool { return $this->quota_gb !== null && $this->quota_gb > 0 && (int) $this->quota_applied_gb === (int) $this->quota_gb; } /** * Is there a machine here that remote work can actually reach? * * A reservation with no VM, a failed build and an instance whose order has * gone all fail the same way: the steps reach for a guest agent that is not * there and burn a run's retries doing it. An instance that is still * `provisioning` is excluded for a different reason — its own run is already * writing this machine, and a second run beside it would write the same * router and race the same occ calls. * * Asked by everything that starts a maintenance run against a live instance * (ReapplyInstanceAddress, ApplyPlanChange), so the rule is one rule. */ public function hasLiveMachine(): bool { return $this->order !== null && $this->host !== null && $this->vmid !== null && in_array($this->status, ['active', 'cancellation_scheduled'], true); } /** * Instances that are actually occupying storage on their host. * * A failed instance still holds its disk while its VM exists; a failure * before any VM was created releases it. Kept here rather than written out * at each call site so the host's own accounting and anything reporting on * it cannot drift apart — a dashboard that counted differently from * placement would call a host comfortable while orders were being refused * on it. * * @param Builder $query */ public function scopeOccupyingHost($query): void { $query->where(fn ($q) => $q->where('status', '!=', 'failed')->orWhereNotNull('vmid')); } /** * Is the customer's own domain proven to be theirs? * * The one flag. A domain that has never been verified — or that lost its * DNS proof and was withdrawn — is not this instance's address, whatever is * typed in the column. */ public function domainIsVerified(): bool { return filled($this->custom_domain) && $this->domain_verified_at !== null; } /** * Is the customer's own domain not merely proven, but actually answering? * * Verification says the domain is theirs. It says nothing about whether * they have pointed it at us — that is an A record in their zone, which we * cannot create and (behind a CDN) cannot even read. So there is a real * state between "proven" and "working", it can last forever, and the portal * has to be able to say which one the customer is in rather than printing * an address that answers nothing. */ public function domainIsServed(): bool { return $this->domainIsVerified() && (bool) $this->domain_cert_ok; } /** * The address this instance is actually served at. * * Every caller goes through here rather than reading `custom_domain ?: * subdomain` for itself. That expression was the hole: it served, certified * and trusted whatever hostname was in the column, so anybody who pointed * any domain at the proxy got somebody else's Nextcloud under their own * name — with a certificate to match. */ public function address(string $zone): string { return $this->domainIsVerified() ? (string) $this->custom_domain : $this->subdomain.'.'.$zone; } public function customer(): BelongsTo { return $this->belongsTo(Customer::class); } public function order(): BelongsTo { return $this->belongsTo(Order::class); } /** * The contract this machine fulfils. The authority on what the customer is * entitled to — the catalogue only describes what we sell today. */ public function subscription(): HasOne { return $this->hasOne(Subscription::class); } public function host(): BelongsTo { return $this->belongsTo(Host::class); } public function dnsRecords(): HasMany { return $this->hasMany(DnsRecord::class); } public function backups(): HasMany { return $this->hasMany(Backup::class); } public function monitoringTargets(): HasMany { return $this->hasMany(MonitoringTarget::class); } public function onboardingTasks(): HasMany { return $this->hasMany(OnboardingTask::class); } }