CluPilotCloud/app/Livewire/Admin/EditDatacenter.php

201 lines
8.2 KiB
PHP

<?php
namespace App\Livewire\Admin;
use App\Models\Datacenter;
use App\Models\Host;
use App\Models\Order;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Validate;
use LivewireUI\Modal\ModalComponent;
/**
* Edit a datacenter in a modal (avoids the row-height jump of inline editing).
* Country is picked from a list so it can't be mistyped.
*
* The code is editable, but only while nothing depends on it.
*
* It was fully immutable, which made a typo permanent. It cannot be freely
* mutable either, because it is not only a foreign key: hosts.datacenter
* references it (and MariaDB refuses the update outright, there being no ON
* UPDATE clause), every past order stores it as plain text, each host's DNS
* name was minted from it (fsn-01 — a record that exists at the provider and
* does not move), the machine's own hostname was set from it while it was being
* built, and the per-code counter that hands out those numbers is keyed by it.
* Renaming a code with hosts in it is therefore not a rename; it is a datacenter
* called hel full of machines called fsn-01.
*
* So: free while nothing references it — which is the case that matters, a
* typo noticed shortly after creating one — and locked with the reason named
* once anything does.
*/
class EditDatacenter extends ModalComponent
{
public string $uuid = '';
public string $code = '';
#[Validate('required|string|max:255')]
public string $name = '';
/**
* Which building, not which city — fsn-dc-15.
*
* The provider's own label, free text on purpose: it is theirs to change,
* and nothing here may key off it. Only so that picking "fsn" twice for
* redundancy is a decision somebody can actually make.
*/
#[Validate('nullable|string|max:255')]
public string $facility = '';
public string $location = '';
/** The location present when the modal opened — a pre-curation free-form value. */
public string $originalLocation = '';
/**
* Whether new hosts and orders may still be placed here.
*
* The column and the scope existed; the form did not offer it, so a
* datacenter could be created and never switched off again — the one
* lifecycle action a location actually needs.
*/
public bool $active = true;
/** What references this code, and therefore what stops it being renamed. */
public int $hostCount = 0;
public int $orderCount = 0;
public function mount(string $uuid): void
{
$this->authorize('datacenters.manage'); // modals are reachable without the route middleware
$dc = Datacenter::query()->where('uuid', $uuid)->firstOrFail();
$this->uuid = $uuid;
$this->code = $dc->code;
$this->name = $dc->name;
$this->facility = (string) $dc->facility;
$this->location = (string) $dc->location;
$this->originalLocation = (string) $dc->location;
$this->active = (bool) $dc->active;
$this->hostCount = $this->hostsIn($dc->code);
$this->orderCount = $this->ordersIn($dc->code);
}
/**
* May this code still be changed?
*
* Always answered from the database, never from a hydrated property: the
* lock is an authorisation decision, and a forged request that flipped it
* would take the DNS names of every host in this datacenter with it.
*/
public function codeIsFree(string $code): bool
{
return $this->hostsIn($code) === 0 && $this->ordersIn($code) === 0;
}
private function hostsIn(string $code): int
{
return Host::query()->where('datacenter', $code)->count();
}
private function ordersIn(string $code): int
{
return Order::query()->where('datacenter', $code)->count();
}
public function save()
{
$this->authorize('datacenters.manage');
// Accept the configured countries plus the record's own legacy value, so a
// datacenter created before the curated list can still be edited. The
// legacy value is read from the DB — never from the client-hydrated
// property, which could be forged to whitelist an arbitrary location.
$dc = Datacenter::query()->where('uuid', $this->uuid)->firstOrFail();
$allowed = array_keys((array) config('countries'));
if ($dc->location) {
$allowed[] = $dc->location;
}
// Recomputed here, never taken from the property the browser sent back:
// the lock is what protects every host's DNS name in this datacenter
// from being orphaned, and a forged request must not be able to lift it.
$codeIsFree = $this->codeIsFree($dc->code);
if ($codeIsFree) {
// Normalised before validating so the unique check matches how the
// row is stored — otherwise `FSN` passes the rule and the update
// then collides with an existing lowercased row (mirrors Datacenters).
$this->code = strtolower(trim($this->code));
} else {
// Whatever came back from the browser is discarded. Silently, and
// deliberately: the field was never offered, so there is nothing to
// report to an honest client, and a dishonest one gets no purchase.
$this->code = $dc->code;
}
$rules = [
'name' => 'required|string|max:255',
'facility' => 'nullable|string|max:255',
'location' => ['nullable', Rule::in($allowed)], // array form — a legacy value may contain commas
'active' => 'boolean',
];
if ($codeIsFree) {
// The same rule the create form uses: a code becomes a DNS label
// (fsn-01.node.clupilot.com), so it has to be one. Ignoring its own
// row, or saving without touching the code would collide with itself.
$rules['code'] = [
'required', 'string', 'max:8',
'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/',
Rule::unique('datacenters', 'code')->ignore($dc->id),
];
}
$data = $this->validate($rules);
// Only on the transition, and compared against what is STORED: an
// already-inactive location would otherwise announce that it had just
// been switched off every time its name was edited.
$justDeactivated = $dc->active && ! $data['active'];
$hostsLeftRunning = $justDeactivated ? $dc->hosts()->count() : 0;
Datacenter::query()->where('uuid', $this->uuid)->update([
// Only where it was actually offered. $data has no 'code' key at
// all when the field was locked, and writing $this->code back would
// be the same value anyway — but only by luck, and this is the line
// that would quietly stop being true if the lock ever moved.
...($codeIsFree ? ['code' => $data['code']] : []),
'name' => $data['name'],
'facility' => $data['facility'] ?: null,
'location' => $data['location'] ?: null,
'active' => $data['active'],
]);
// One message, not two. Both go through the same toast, and the second
// replaces the first — so the generic "saved" would swallow the only
// sentence that says the hosts there are still running.
$this->dispatch('notify', message: $hostsLeftRunning > 0
? __('datacenters.deactivated_with_hosts', ['n' => $hostsLeftRunning])
: __('datacenters.updated'));
return $this->redirectRoute('admin.datacenters', navigate: true);
}
public function render()
{
// Read again rather than shown from the mounted properties: a host can
// be onboarded into this datacenter while the modal sits open, and an
// input offered on the strength of a stale count would be refused on
// save with nothing on screen explaining why.
$stored = Datacenter::query()->where('uuid', $this->uuid)->value('code') ?? $this->code;
$this->hostCount = $this->hostsIn($stored);
$this->orderCount = $this->ordersIn($stored);
return view('livewire.admin.edit-datacenter', [
'countries' => config('countries'),
'codeFree' => $this->hostCount === 0 && $this->orderCount === 0,
]);
}
}