201 lines
8.2 KiB
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,
|
|
]);
|
|
}
|
|
}
|