diff --git a/app/Livewire/Admin/Datacenters.php b/app/Livewire/Admin/Datacenters.php index a7e3c66..dc1236d 100644 --- a/app/Livewire/Admin/Datacenters.php +++ b/app/Livewire/Admin/Datacenters.php @@ -17,6 +17,16 @@ class Datacenters extends Component #[Validate('required|string|max:255')] public string $name = ''; + /** + * Which building, not which city — fsn-dc-15. Optional, free text, and + * nothing keys off it: see EditDatacenter and the migration that adds it. + * + * Offered here as well as in the edit modal so that creating a datacenter + * and saying which one it is are not two separate errands. + */ + #[Validate('nullable|string|max:255')] + public string $facility = ''; + // Country picked from config/countries.php (no manual code typos). #[Validate('nullable|string|max:2')] public string $location = ''; @@ -36,17 +46,19 @@ class Datacenters extends Component // trailing dash. 'code' => ['required', 'string', 'max:8', 'regex:/^[a-z0-9]([a-z0-9-]{0,6}[a-z0-9])?$/', 'unique:datacenters,code'], 'name' => 'required|string|max:255', + 'facility' => 'nullable|string|max:255', 'location' => ['nullable', Rule::in(array_keys((array) config('countries')))], ]); Datacenter::create([ 'code' => $data['code'], 'name' => $data['name'], + 'facility' => $data['facility'] ?: null, 'location' => $data['location'] ?: null, 'active' => true, ]); - $this->reset('code', 'name', 'location'); + $this->reset('code', 'name', 'facility', 'location'); $this->dispatch('notify', message: __('datacenters.created')); } diff --git a/app/Livewire/Admin/EditDatacenter.php b/app/Livewire/Admin/EditDatacenter.php index f923360..1118b8a 100644 --- a/app/Livewire/Admin/EditDatacenter.php +++ b/app/Livewire/Admin/EditDatacenter.php @@ -3,14 +3,31 @@ 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's name and location in a modal (avoids the row-height jump - * of inline editing). The code is immutable — it is referenced by hosts. Country - * is picked from a list so it can't be mistyped. + * 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 { @@ -21,6 +38,16 @@ class EditDatacenter extends ModalComponent #[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. */ @@ -35,6 +62,11 @@ class EditDatacenter extends ModalComponent */ 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 @@ -42,9 +74,34 @@ class EditDatacenter extends ModalComponent $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() @@ -60,11 +117,42 @@ class EditDatacenter extends ModalComponent if ($dc->location) { $allowed[] = $dc->location; } - $data = $this->validate([ + // 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 @@ -73,7 +161,13 @@ class EditDatacenter extends ModalComponent $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'], ]); @@ -90,8 +184,17 @@ class EditDatacenter extends ModalComponent 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, ]); } } diff --git a/app/Models/Datacenter.php b/app/Models/Datacenter.php index 862a30b..26b5f33 100644 --- a/app/Models/Datacenter.php +++ b/app/Models/Datacenter.php @@ -12,7 +12,7 @@ class Datacenter extends Model /** @use HasFactory<\Database\Factories\DatacenterFactory> */ use HasFactory, HasUuid; - protected $fillable = ['code', 'name', 'location', 'active']; + protected $fillable = ['code', 'name', 'facility', 'location', 'active']; protected function casts(): array { diff --git a/database/migrations/2026_07_29_110000_add_facility_to_datacenters_table.php b/database/migrations/2026_07_29_110000_add_facility_to_datacenters_table.php new file mode 100644 index 0000000..cc15d02 --- /dev/null +++ b/database/migrations/2026_07_29_110000_add_facility_to_datacenters_table.php @@ -0,0 +1,35 @@ +string('facility')->nullable()->after('name'); + }); + } + + public function down(): void + { + Schema::table('datacenters', function (Blueprint $table) { + $table->dropColumn('facility'); + }); + } +}; diff --git a/lang/de/datacenters.php b/lang/de/datacenters.php index 8ae58f6..cbfb70b 100644 --- a/lang/de/datacenters.php +++ b/lang/de/datacenters.php @@ -6,7 +6,15 @@ return [ 'empty' => 'Noch keine Rechenzentren. Lege dein erstes an.', 'code' => 'Kürzel', 'code_hint' => 'Kurz & eindeutig, z. B. fsn, hel, nbg.', + // Warum das Feld gesperrt ist, mit den Zahlen die es sperren. Das Kürzel + // steckt in den DNS-Namen der Hosts (fsn-01) und im Hostnamen der Maschine + // selbst — eine Umbenennung zieht dort nicht nach. + 'code_locked' => 'Nicht mehr änderbar: :hosts Host(s) und :orders Bestellung(en) hängen daran. Das Kürzel steckt in ihren DNS-Namen.', 'name' => 'Name', + // Nicht welche Stadt, sondern welches Haus. Optional und frei — es ist die + // Bezeichnung des Anbieters, und nichts im System hängt daran. + 'facility' => 'Konkretes Rechenzentrum', + 'facility_hint' => 'Optional, z. B. fsn-dc-15 — damit bei der Auswahl erkennbar ist, welches gemeint ist.', 'location' => 'Standort', 'location_hint' => 'Optional, z. B. DE, Falkenstein.', 'hosts' => 'Hosts', diff --git a/lang/en/datacenters.php b/lang/en/datacenters.php index 232c165..9c9a0d5 100644 --- a/lang/en/datacenters.php +++ b/lang/en/datacenters.php @@ -6,7 +6,15 @@ return [ 'empty' => 'No datacenters yet. Add your first one.', 'code' => 'Code', 'code_hint' => 'Short & unique, e.g. fsn, hel, nbg.', + // Why the field is locked, with the numbers that lock it. The code is baked + // into each host's DNS name (fsn-01) and into the machine's own hostname — + // a rename does not follow it there. + 'code_locked' => 'No longer editable: :hosts host(s) and :orders order(s) depend on it. The code is part of their DNS names.', 'name' => 'Name', + // Which building, not which city. Optional and free text — it is the + // provider's label, and nothing in the system keys off it. + 'facility' => 'Specific datacenter', + 'facility_hint' => 'Optional, e.g. fsn-dc-15 — so the picker shows which one is meant.', 'location' => 'Location', 'location_hint' => 'Optional, e.g. DE, Falkenstein.', 'hosts' => 'Hosts', diff --git a/resources/views/livewire/admin/datacenters.blade.php b/resources/views/livewire/admin/datacenters.blade.php index 78eb838..af31149 100644 --- a/resources/views/livewire/admin/datacenters.blade.php +++ b/resources/views/livewire/admin/datacenters.blade.php @@ -26,6 +26,10 @@
{{ __('datacenters.code_hint') }}
+ @else + +{{ __('datacenters.code_locked', ['hosts' => $hostCount, 'orders' => $orderCount]) }}
+ @endif + @error('code'){{ $message }}
@enderror +{{ $message }}
@enderror{{ __('datacenters.facility_hint') }}
+ @error('facility'){{ $message }}
@enderror +{{ $message }}
@enderror diff --git a/resources/views/livewire/admin/host-create.blade.php b/resources/views/livewire/admin/host-create.blade.php index bf35e82..46215cd 100644 --- a/resources/views/livewire/admin/host-create.blade.php +++ b/resources/views/livewire/admin/host-create.blade.php @@ -14,8 +14,12 @@ @error('datacenter'){{ $message }}
@enderror diff --git a/tests/Feature/Admin/DatacenterTest.php b/tests/Feature/Admin/DatacenterTest.php index 934ad7b..e7f4b08 100644 --- a/tests/Feature/Admin/DatacenterTest.php +++ b/tests/Feature/Admin/DatacenterTest.php @@ -52,6 +52,133 @@ it('edits a datacenter name and country via the modal', function () { expect($dc->name)->toBe('Nürnberg')->and($dc->location)->toBe('DE')->and($dc->code)->toBe('nbg'); }); +/** + * Renaming a code, and why it stops being allowed. + * + * The code was immutable, which made a typo permanent. It cannot be freely + * mutable either: hosts.datacenter references it, every past order stores it as + * plain text, and — the part no cascade reaches — each host's DNS name was + * minted from it and registered at the provider, while the machine's own + * hostname was set from it as it was built. Rename a code with hosts in it and + * you get a datacenter called hel full of machines called fsn-01. + * + * Free while nothing references it, which is the case that matters: a typo + * noticed shortly after creating one. + */ +it('renames a code while nothing depends on it', function () { + $dc = Datacenter::factory()->create(['code' => 'nbg', 'name' => 'Nürnberg']); + + Livewire::actingAs(admin(), 'operator') + ->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid]) + ->set('code', 'NBG2') + ->call('save') + ->assertHasNoErrors(); + + // Lowercased on the way in, like the create form does it. + expect($dc->refresh()->code)->toBe('nbg2'); +}); + +it('leaves the code alone once a host is placed there, whatever the browser sends back', function () { + // fsn and hel exist from the migration that created the table. + $dc = Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); + Host::factory()->create(['datacenter' => 'fsn']); + + // set() on a locked field is exactly the forged request this guards + // against: the input is not rendered, so nothing honest can send it. + Livewire::actingAs(admin(), 'operator') + ->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid]) + ->set('code', 'hel') + ->set('name', 'Falkenstein Süd') + ->call('save') + ->assertHasNoErrors(); + + expect($dc->refresh()->code)->toBe('fsn') + ->and($dc->name)->toBe('Falkenstein Süd'); // the rest of the form still saves +}); + +it('leaves the code alone once an order carries it, even with no hosts left', function () { + // Orders are the reference no foreign key protects: a plain string column, + // and the record of what a customer actually bought. + $dc = Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); + \App\Models\Order::factory()->create(['datacenter' => 'fsn']); + + Livewire::actingAs(admin(), 'operator') + ->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid]) + ->set('code', 'hel') + ->call('save') + ->assertHasNoErrors(); + + expect($dc->refresh()->code)->toBe('fsn'); +}); + +it('holds a renamed code to the same rules as a new one', function () { + // hel comes from the migration that created the table. + $dc = Datacenter::factory()->create(['code' => 'nbg']); + + $component = Livewire::actingAs(admin(), 'operator') + ->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid]); + + // Not a DNS label — the code becomes one (fsn-01.node.clupilot.com). + $component->set('code', 'nbg_1')->call('save')->assertHasErrors(['code']); + + // Already taken. + $component->set('code', 'hel')->call('save')->assertHasErrors(['code']); + + expect($dc->refresh()->code)->toBe('nbg'); +}); + +it('lets a datacenter keep its own code when only the name is edited', function () { + // The unique rule has to ignore this row, or saving anything at all would + // fail on the code the record already holds. + $dc = Datacenter::factory()->create(['code' => 'nbg', 'name' => 'Old']); + + Livewire::actingAs(admin(), 'operator') + ->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid]) + ->set('name', 'Nürnberg') + ->call('save') + ->assertHasNoErrors(); + + expect($dc->refresh()->name)->toBe('Nürnberg')->and($dc->code)->toBe('nbg'); +}); + +/** + * Which building, not which city. + * + * A provider runs several datacenters within one location, on separate power + * and separate uplinks. "Falkenstein (fsn)" twice over is not a choice anybody + * can make — and placing a second machine for redundancy is exactly when it + * matters which hall it lands in. + */ +it('stores the specific datacenter and shows it where hosts are placed', function () { + Livewire::actingAs(admin(), 'operator') + ->test(Datacenters::class) + ->set('code', 'nbg') + ->set('name', 'Nürnberg') + ->set('facility', 'nbg-dc-15') + ->call('save') + ->assertHasNoErrors(); + + expect(Datacenter::query()->where('code', 'nbg')->value('facility'))->toBe('nbg-dc-15'); + + Livewire::actingAs(admin(), 'operator') + ->test(HostCreate::class) + ->assertSee('nbg-dc-15'); +}); + +it('keeps the specific datacenter optional, and empty rather than blank', function () { + // '' in the column would read as "there is one and it has no name" to + // every @if that decides whether to print it. + $dc = Datacenter::factory()->create(['code' => 'nbg', 'facility' => 'nbg-dc-3']); + + Livewire::actingAs(admin(), 'operator') + ->test(\App\Livewire\Admin\EditDatacenter::class, ['uuid' => $dc->uuid]) + ->set('facility', '') + ->call('save') + ->assertHasNoErrors(); + + expect($dc->refresh()->facility)->toBeNull(); +}); + it('rejects an invalid country code on edit', function () { $dc = Datacenter::factory()->create(['code' => 'nbg', 'location' => 'DE']);