diff --git a/app/Support/HostName.php b/app/Support/HostName.php index 013f485..805de4b 100644 --- a/app/Support/HostName.php +++ b/app/Support/HostName.php @@ -4,6 +4,7 @@ namespace App\Support; use App\Models\Datacenter; use App\Models\Host; +use Illuminate\Support\Facades\DB; use RuntimeException; /** @@ -49,6 +50,17 @@ final class HostName */ public static function claim(string $datacenterCode): string { + if (DB::transactionLevel() === 0) { + // Der Absatz oben VERLANGT eine Transaktion, erzwingt sie bisher + // aber nicht: außerhalb einer Transaktion hält `lockForUpdate` + // die Sperre nur bis zum Ende dieser einen Anfrage, nicht bis zum + // `update()` weiter unten — zwei gleichzeitige Aufrufe holten + // sich still dieselbe Nummer, ohne dass irgendwo ein Fehler + // auftaucht. Lieber hier laut scheitern als dort leise falsch + // vergeben. + throw new RuntimeException('HostName::claim() muss innerhalb einer Transaktion aufgerufen werden.'); + } + $dc = Datacenter::query()->where('code', $datacenterCode)->lockForUpdate()->first(); if ($dc === null) { @@ -95,7 +107,13 @@ final class HostName * * Nach unten geht es dabei nie: eine entfernte `fsn-02` wird nicht wieder * vergeben, weil der Zähler längst darüber steht. Dieses Weiterzählen ist - * der Riegel, nicht der Zähler. + * der Riegel, nicht der Zähler — aber nur einfädig. `claim()` sperrt die + * ZEILE des Rechenzentrums, und `eu_west` und `eu-west` sind zwei + * verschiedene Zeilen, die zur selben Bezeichnung normalisieren. Laufen + * beide gleichzeitig durchs Onboarding, sperrt jeder Aufruf nur seine + * eigene Zeile, beide sehen hier denselben Kandidaten frei, und wer den + * `INSERT` als Zweiter absetzt, bekommt am `unique('name')`-Index eine + * unbehandelte `QueryException` statt der nächsten Nummer. * * `%02d` füllt zweistellig auf und wächst ab hundert von selbst weiter. * diff --git a/database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php b/database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php index e5fd3dd..70261e0 100644 --- a/database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php +++ b/database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php @@ -3,8 +3,11 @@ use App\Support\HostName; use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Schema; +use Symfony\Component\Console\Output\ConsoleOutput; /** * CluPilot vergibt die Hostnamen, nicht der Betreiber. @@ -50,12 +53,20 @@ return new class extends Migration // fremden `dns_name` gleicht — und lässt die Datenbank unangetastet, // wenn es einen Treffer gibt: eine reine SELECT-Prüfung vor der // ersten Schreiboperation, beliebig oft wiederholbar. + // + // GROUP BY/HAVING statt `pluck()->countBy()`: Letzteres vergleicht in + // PHP byteweise, die Spalte aber liegt auf `utf8mb4_unicode_ci` — + // unempfindlich gegen Groß-/Kleinschreibung und Akzente. `Pve-Fsn-1` + // und `pve-fsn-1` kämen an der PHP-Prüfung vorbei und kollidierten + // erst am `unique('name')`-Index weiter unten — ausgerechnet der Fall, + // den diese Prüfung abfangen soll. GROUP BY lässt dieselbe Kollation + // entscheiden, die später den Index baut, statt in PHP etwas + // nachzubilden, das von der Spalte abdriften kann. $duplicates = DB::table('hosts') ->selectRaw("(CASE WHEN dns_name IS NOT NULL AND dns_name <> '' THEN dns_name ELSE name END) as future_name") - ->pluck('future_name') - ->countBy() - ->filter(fn (int $count) => $count > 1) - ->keys(); + ->groupBy('future_name') + ->havingRaw('COUNT(*) > 1') + ->pluck('future_name'); if ($duplicates->isNotEmpty()) { throw new RuntimeException( @@ -65,9 +76,19 @@ return new class extends Migration ); } - Schema::table('datacenters', function (Blueprint $table) { - $table->unsignedInteger('next_host_number')->default(1)->after('code'); - }); + // Hinter `hasColumn`, damit ein zweiter Anlauf nach einem Fehlschlag + // WEITER UNTEN (am Riegel, oder an einem `dns_name`-Index, den eine + // Rücksicherung nicht mitbrachte) nicht sofort an „Duplicate column + // name 'next_host_number'" stirbt. MariaDB hat diese Spalte im ersten + // Anlauf längst committet, ganz ohne Eintrag in der Migrationstabelle + // — ein zweiter Versuch soll bis zum tatsächlichen Fehler durchlaufen, + // nicht schon davor. Billig, und es hält die Zusage von oben ein: + // beliebig oft wiederholbar. + if (! Schema::hasColumn('datacenters', 'next_host_number')) { + Schema::table('datacenters', function (Blueprint $table) { + $table->unsignedInteger('next_host_number')->default(1)->after('code'); + }); + } // Der systematische Name wird der Name. Hosts ohne DNS-Namen (noch im // Onboarding, nie so weit gekommen) behalten ihren — sie tragen keine @@ -78,6 +99,42 @@ return new class extends Migration ->where('dns_name', '<>', '') ->update(['name' => DB::raw('dns_name')]); + // Wer nach der Übertragung oben immer noch nicht wie `-` + // aussieht, hatte keinen `dns_name` zum Übertragen — der getippte + // Name blieb stehen. Auf dieser Installation ist das kein + // theoretischer Fall: `pve-fsn-1` und `pve-hel-1` tragen genau diesen + // Namen, unverändert, und die Host-Detailseite zeigt seitdem einen + // Link auf `pve-fsn-1.node.` — einen Namen, den nie jemand ins + // DNS geschrieben hat. + // + // Kein Abbruch: diese Hosts laufen und sollen weiterlaufen, das + // Namensfeld ist nur beim Anlegen entfallen, nicht rückwirkend + // Pflicht geworden. Aber still bleiben darf das nicht — eine + // Migration läuft im Container, ihr Protokoll liest niemand von + // selbst nach, deshalb zusätzlich auf die Konsole, wo ein Betreiber + // `artisan migrate` tatsächlich ansieht. + $unrepaired = DB::table('hosts')->get()->filter(function ($host) { + $label = HostName::label($host->datacenter); + + return preg_match('/^'.preg_quote($label, '/').'-\d{2,}$/', (string) $host->name) !== 1; + }); + + if ($unrepaired->isNotEmpty()) { + $message = sprintf( + 'clupilot_vergibt_die_hostnamen: %d Host(s) tragen nach dieser Migration weiterhin einen ' + .'getippten statt eines systematischen Namens (kein dns_name zum Übertragen vorhanden): %s. ' + .'Sie laufen unverändert weiter — aber jede Adresse, die aus dem Namen gebildet wird ' + .'(Detailseite, DNS, /etc/hosts), zeigt auf einen Namen, den niemand ins DNS geschrieben hat. ' + .'Von Hand umbenennen: Zeile auf ein systematisches - setzen, next_host_number ' + .'entsprechend hochziehen, DNS und Proxmox-Node nachziehen.', + $unrepaired->count(), + $unrepaired->pluck('name')->implode(', '), + ); + + Log::warning($message); + (new ConsoleOutput)->writeln(''.$message.''); + } + foreach (DB::table('datacenters')->get() as $dc) { $label = HostName::label($dc->code); @@ -106,13 +163,22 @@ return new class extends Migration // Getrennte Aufrufe: Index löschen, Spalte löschen und Index anlegen in // einem Blueprint bringt SQLite (Testlauf) durcheinander. - Schema::table('hosts', function (Blueprint $table) { - $table->dropUnique(['dns_name']); - }); + // + // Beide hinter derselben `hasColumn`-Prüfung wie oben und aus + // demselben Grund: scheitert der Riegel weiter unten, oder fehlt + // einer Rücksicherung genau der `dns_name`-Index, den `dropUnique` + // gleich sucht, ist `next_host_number` schon da, `dns_name` aber + // noch — ein zweiter Versuch soll wieder bis hierher kommen, nicht + // vorher an der Spalte scheitern. + if (Schema::hasColumn('hosts', 'dns_name')) { + Schema::table('hosts', function (Blueprint $table) { + $table->dropUnique(['dns_name']); + }); - Schema::table('hosts', function (Blueprint $table) { - $table->dropColumn('dns_name'); - }); + Schema::table('hosts', function (Blueprint $table) { + $table->dropColumn('dns_name'); + }); + } // Der Riegel — dank der Vorabprüfung oben ohne Überraschung, aber // trotzdem der riskanteste Schritt dieser Migration: der einzige, der @@ -146,14 +212,24 @@ return new class extends Migration DB::table('hosts')->update(['dns_name' => DB::raw('name')]); foreach (DB::table('datacenters')->get() as $dc) { + $key = 'dns.sequence.'.HostName::label($dc->code); + DB::table('app_settings')->updateOrInsert( - ['key' => 'dns.sequence.'.HostName::label($dc->code)], + ['key' => $key], [ 'value' => json_encode(max(0, (int) $dc->next_host_number - 1)), 'created_at' => now(), 'updated_at' => now(), ], ); + + // App\Support\Settings hält jeden gelesenen Wert unbegrenzt im + // Cache und leert ihn nur bei seinem eigenen set(). Ohne diese + // Zeile schriebe die Zeile darüber die Datenbank richtig zurück, + // während Settings::get('dns.sequence.'.$label) — läse den + // jemand vor diesem Rückbau schon einmal — weiter den alten Wert + // aus dem Cache liefert. + Cache::forget('app_setting:'.$key); } Schema::table('datacenters', function (Blueprint $table) { diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 991814a..327aa96 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -47,15 +47,24 @@ class DatabaseSeeder extends Seeder ], ); - // Datacenters — hosts + orders pick their code. + // Datacenters — hosts + orders pick their code. next_host_number + // steht schon auf 2: die Demoflotte unten belegt die 01 in jedem + // Rechenzentrum selbst, und ein Seed-Lauf ist kein Onboarding, das + // HostName::claim durchläuft und den Zähler von selbst hochzöge. foreach ([['fsn', 'Falkenstein', 'DE'], ['hel', 'Helsinki', 'FI']] as [$dcCode, $dcName, $dcLocation]) { - Datacenter::updateOrCreate(['code' => $dcCode], ['name' => $dcName, 'location' => $dcLocation, 'active' => true]); + Datacenter::updateOrCreate( + ['code' => $dcCode], + ['name' => $dcName, 'location' => $dcLocation, 'active' => true, 'next_host_number' => 2], + ); } // Demo fleet so the operator console hosts view has content locally. + // Namen wie die Konsole sie selbst vergäbe (App\Support\HostName) — + // seit der Hostnamen-Vergabe ist ein getippter `pve-…`-Name genau das + // Gegenbeispiel zu der Regel, die diese Installation gerade lernt. $fleet = [ - ['name' => 'pve-fsn-1', 'datacenter' => 'fsn', 'public_ip' => '203.0.113.11', 'wg_ip' => '10.66.0.2'], - ['name' => 'pve-hel-1', 'datacenter' => 'hel', 'public_ip' => '203.0.113.21', 'wg_ip' => '10.66.0.3'], + ['name' => 'fsn-01', 'datacenter' => 'fsn', 'public_ip' => '203.0.113.11', 'wg_ip' => '10.66.0.2'], + ['name' => 'hel-01', 'datacenter' => 'hel', 'public_ip' => '203.0.113.21', 'wg_ip' => '10.66.0.3'], ]; foreach ($fleet as $host) { Host::updateOrCreate(['name' => $host['name']], [ @@ -75,7 +84,7 @@ class DatabaseSeeder extends Seeder } // A live customer (matched to the portal login) with an active instance. - $fsn1 = Host::query()->where('name', 'pve-fsn-1')->first(); + $fsn1 = Host::query()->where('name', 'fsn-01')->first(); $berger = Customer::updateOrCreate( ['email' => 'kunde@clupilot.local'], ['name' => 'Kanzlei Berger', 'locale' => 'de'], diff --git a/database/seeders/DemoCustomerSeeder.php b/database/seeders/DemoCustomerSeeder.php index c5fe913..24a7c6d 100644 --- a/database/seeders/DemoCustomerSeeder.php +++ b/database/seeders/DemoCustomerSeeder.php @@ -67,7 +67,7 @@ class DemoCustomerSeeder extends Seeder ); $host = Host::query()->where('datacenter', $datacenter->code)->first() - ?? Host::factory()->create(['datacenter' => $datacenter->code, 'name' => 'pve-fsn-01', 'status' => 'active']); + ?? Host::factory()->create(['datacenter' => $datacenter->code, 'name' => 'fsn-01', 'status' => 'active']); $order = Order::firstOrCreate( ['customer_id' => $customer->id, 'type' => 'plan'], diff --git a/docs/superpowers/plans/2026-08-01-hostname-vergabe.md b/docs/superpowers/plans/2026-08-01-hostname-vergabe.md new file mode 100644 index 0000000..00a567b --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-hostname-vergabe.md @@ -0,0 +1,872 @@ +# Hostnamen-Vergabe — Umsetzungsplan + +> **Für ausführende Agenten:** ERFORDERLICHE UNTER-SKILL: `superpowers:subagent-driven-development` (empfohlen) oder `superpowers:executing-plans`, um diesen Plan Aufgabe für Aufgabe umzusetzen. Die Schritte benutzen Checkbox-Syntax (`- [ ]`) zur Verfolgung. + +**Ziel:** CluPilot vergibt den Hostnamen systematisch (`-`) an genau einer Stelle; das Namensfeld beim Anlegen entfällt, und Maschine, Proxmox-Node, DNS, `/etc/hosts` und Konsole benutzen denselben Namen. + +**Architektur:** Eine neue Quelle `App\Support\HostName` bildet Bezeichnung, Nummer und FQDN. Der Zähler wohnt als `datacenters.next_host_number` in der Datenbank und überlebt das Löschen eines Hosts. Vergeben wird beim Anlegen innerhalb der bestehenden Transaktion in `StartHostOnboarding`, unter `lockForUpdate` auf die Rechenzentrums-Zeile. Die zweite Namensspalte `hosts.dns_name` entfällt; `hosts.name` trägt den Namen und bekommt einen eindeutigen Index. + +**Tech-Stack:** Laravel 13.8, Livewire 3 (klassenbasiert, kein Volt), Pest, Tailwind v4, MariaDB 11.4 (Tests: SQLite in-memory). + +## Verbindliche Rahmenbedingungen + +- **Spec:** `docs/superpowers/specs/2026-08-01-hostname-vergabe-design.md`. Bei Abweichung: die Spec gewinnt, außer wo dieser Plan eine dort getroffene Entscheidung ausdrücklich korrigiert (siehe „Abweichungen von der Spec"). +- **`MAX(nummer) + 1` ist verboten.** Der Zähler muss die Löschung eines Hosts überleben. Abnahmepunkt 3 der Spec scheitert bei jeder Umsetzung, die das rechnet. +- **Zweistellig aufgefüllt:** `fsn-03`, ab hundert natürlich weiter `fsn-100`. `sprintf('%s-%02d', …)` leistet beides ohne Sonderfall. +- **Die Vorschau verbraucht den Zähler nicht.** Zwei gleichzeitig geöffnete Formulare zeigen dieselbe Nummer; der zweite bekommt beim Speichern die nächste. Keine Reservierung beim Öffnen. +- **Plattform-Zone, nicht Kundenzone:** `config('provisioning.dns.platform_zone')` (`clupilot.com`), niemals `provisioning.dns.zone` (`clupilot.cloud`). +- **R19 (Zeitzone), R20 (Bearbeiten im Modal), R23 (kein `wire:confirm`), R24 (Modal-Höhe)** aus `CLAUDE.md` gelten unverändert. Diese Aufgabe legt kein Modal an. +- **R22:** Eine Prüfrunde, eine Fix-Runde, ein Re-Review über den Fix-Diff. Danach werden offene Befunde geparkt. +- **Kommentare und Nutzertexte auf Deutsch**, im Ton der umliegenden Dateien: sie erklären *warum*, nicht *was*. +- **Testlauf:** `docker compose exec -u 1000:1000 -T app php artisan test …` aus `/home/nexxo/clupilot`. + +## Abweichungen von der Spec (vom Besitzer entschieden, 1. August 2026) + +1. **`hosts.dns_name` entfällt.** Die Spec schweigt dazu. Die Spalte war die unsichtbare zweite Wahrheit — sie steht in keiner einzigen Ansicht, während Liste und Detailseite `hosts.name` zeigen. Genau daran ist der Fehler aufgefallen. `name` bekommt den systematischen Namen, `dns_name` wird nach dem Übertragen gelöscht. +2. **`PrepareBaseSystem` wird doch angefasst.** Die Spec sagt „unverändert" und meint damit Zeile 27 (`hostnamectl`). Zeile 23 baut aber einen zweiten FQDN: `{name}.{datacenter}.clupilot.net` — und `clupilot.net` steht genau an dieser einen Stelle im ganzen Repo, in keiner Konfiguration. Das ist dieselbe Krankheit eine Ebene tiefer und wird mitrepariert. +3. **Die Detailseite zeigt IP und Domain.** Zusätzlich verlangt: der FQDN steht als anklickbarer Link in der Ausstattungstafel, damit ein Klick auf der Proxmox-Oberfläche des Hosts landet. + +## Dateien + +| Datei | Zuständigkeit | +|---|---| +| `app/Support/HostName.php` **(neu)** | Die einzige Stelle, die Bezeichnung, Nummer und FQDN bildet. `label()`, `preview()`, `claim()`, `fqdn()`. | +| `database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php` **(neu)** | Zähler anlegen und aus dem Bestand füllen, Namen übertragen, eindeutiger Index, `dns_name` löschen. | +| `app/Models/Datacenter.php` | `next_host_number` in `$fillable` + Cast. | +| `app/Models/Host.php` | `dns_name` aus `$fillable`. | +| `app/Actions/StartHostOnboarding.php` | Vergibt den Namen selbst statt ihn entgegenzunehmen. | +| `app/Livewire/Admin/HostCreate.php` | Namensfeld entfällt; die Seite zeigt den Namen vorher an. | +| `resources/views/livewire/admin/host-create.blade.php` | Eingabezeile wird Anzeigezeile; RZ-Auswahl wird `.live`. | +| `app/Support/HostTakeoverCommand.php` | `fqdnFor()` verweist auf `HostName::fqdn()` — keine eigene Ableitung mehr. | +| `app/Provisioning/Steps/Host/RegisterHostDns.php` | Vergibt nichts mehr; schreibt `$host->name`. | +| `app/Provisioning/Steps/Host/PrepareBaseSystem.php` | `/etc/hosts` bekommt denselben FQDN wie DNS. | +| `app/Provisioning/Jobs/PurgeHost.php` | Räumt den Eintrag unter `name` statt unter `dns_name` weg. | +| `app/Livewire/Admin/HostDetail.php` + `.blade.php` | Ausstattungstafel zeigt den FQDN als Link. | +| `lang/de/hosts.php`, `lang/en/hosts.php` | `field.name_hint` neu, `detail.fqdn` neu. | +| `tests/Feature/Admin/HostNamingTest.php` **(neu)** | Die fünf Abnahmepunkte der Spec. | +| `tests/Feature/Provisioning/HostStepsTest.php` | Der Namensvergabe-Block zieht um; DNS-Schritt wird auf `name` geprüft. | +| `tests/Feature/Admin/HostManagementTest.php`, `HostTakeoverPageTest.php`, `Host/FilesHostTest.php`, `Provisioning/HostOnboardingEndToEndTest.php` | `dns_name` / `set('name', …)` fallen weg. | + +--- + +## Aufgabe 1: Ein Name, an einer Stelle + +Zähler, Vergabe, Anlegen-Seite und alle Leser wandern gemeinsam. Sie lassen sich nicht trennen: sobald `dns_name` fällt, muss jeder Leser schon auf `name` zeigen, und sobald `StartHostOnboarding` vergibt, darf `RegisterHostDns` nicht mehr vergeben. Eine Zwischenstufe hätte zwei laufende Zähler. + +**Dateien:** +- Neu: `app/Support/HostName.php` +- Neu: `database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php` +- Neu: `tests/Feature/Admin/HostNamingTest.php` +- Ändern: `app/Models/Datacenter.php:15`, `app/Models/Host.php:25` +- Ändern: `app/Actions/StartHostOnboarding.php:18-31` +- Ändern: `app/Livewire/Admin/HostCreate.php:30-31,62-69` +- Ändern: `resources/views/livewire/admin/host-create.blade.php` (Zeile „Name" + `select`) +- Ändern: `app/Support/HostTakeoverCommand.php:109-114` +- Ändern: `app/Provisioning/Steps/Host/RegisterHostDns.php:36-135` +- Ändern: `app/Provisioning/Steps/Host/PrepareBaseSystem.php:23` +- Ändern: `app/Provisioning/Jobs/PurgeHost.php:86-88` +- Ändern: `lang/de/hosts.php:43-44`, `lang/en/hosts.php:43-44` +- Ändern: `tests/Feature/Provisioning/HostStepsTest.php:1329-1540` +- Ändern: `tests/Feature/Admin/HostManagementTest.php:40-95` +- Ändern: `tests/Feature/Admin/HostTakeoverPageTest.php:96` +- Ändern: `tests/Feature/Host/FilesHostTest.php:99` +- Ändern: `tests/Feature/Provisioning/HostOnboardingEndToEndTest.php:50,122` + +**Schnittstellen:** +- Erzeugt: `App\Support\HostName::label(string $datacenterCode): string`, `::preview(string $datacenterCode): string`, `::claim(string $datacenterCode): string`, `::fqdn(string $name): string` +- Erzeugt: Spalte `datacenters.next_host_number` (unsigned int, Vorgabe 1) +- Erzeugt: eindeutiger Index auf `hosts.name` +- Entfernt: Spalte `hosts.dns_name`, `RegisterHostDns::reserveName()`, `RegisterHostDns::dnsLabel()`, Einstellungsschlüssel `dns.sequence.*` +- Geändert: `StartHostOnboarding::run()` nimmt `array{datacenter, public_ip, root_password}` — **ohne** `name` + +--- + +- [ ] **Schritt 1: Den fehlschlagenden Test schreiben** + +Neue Datei `tests/Feature/Admin/HostNamingTest.php`. Das sind die fünf Abnahmepunkte der Spec, wörtlich übersetzt. + +```php +set('provisioning.dns.platform_zone', 'clupilot.com'); + // Die Migration von `create_datacenters_table` legt fsn und hel schon an; + // firstOrCreate steht hier, damit der Test nicht daran hängt. + Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']); +}); + +/** Ein Host, der so angelegt wird, wie die Konsole es tut. */ +function onboard(string $dc = 'fsn'): Host +{ + static $n = 0; + $n++; + + return app(StartHostOnboarding::class)->run([ + 'datacenter' => $dc, + 'public_ip' => '203.0.113.'.$n, + 'root_password' => 'supersecret', + ]); +} + +// --- Abnahme 1 + 2 --- + +it('vergibt den Namen selbst, fortlaufend je Rechenzentrum', function () { + expect(onboard()->name)->toBe('fsn-01') + ->and(onboard()->name)->toBe('fsn-02'); +}); + +it('führt je Rechenzentrum einen eigenen Zähler', function () { + Datacenter::query()->firstOrCreate(['code' => 'hel'], ['name' => 'Helsinki']); + + expect(onboard('fsn')->name)->toBe('fsn-01') + ->and(onboard('hel')->name)->toBe('hel-01'); +}); + +// --- Abnahme 3: der Punkt, der die Sorgfalt trägt --- + +it('gibt die Nummer eines entfernten Hosts nie wieder aus', function () { + onboard(); // fsn-01 + $zweiter = onboard(); // fsn-02 + onboard(); // fsn-03 + + // Die HÖCHSTE zu entfernen ist der gefährliche Fall: MAX(nummer) + 1 + // reichte fsn-03 sofort wieder heraus, und ein zwischengespeicherter Name + // zeigte danach auf eine andere Maschine. + $zweiter->delete(); + + expect(onboard()->name)->toBe('fsn-04'); +}); + +it('überlebt auch das Entfernen des höchsten Hosts', function () { + onboard(); // fsn-01 + $hoechster = onboard(); // fsn-02 + $hoechster->delete(); + + expect(onboard()->name)->toBe('fsn-03'); +}); + +// --- Abnahme 5 --- + +it('zeigt den Namen vorher an, ohne ihn zu verbrauchen', function () { + onboard(); // fsn-01 ist weg + + $seite = Livewire::actingAs(admin(), 'operator')->test(HostCreate::class) + ->set('datacenter', 'fsn'); + + $seite->assertSee('fsn-02')->assertSee('fsn-02.node.clupilot.com'); + + // Zweimal ansehen verbraucht nichts. + Livewire::actingAs(admin(), 'operator')->test(HostCreate::class) + ->set('datacenter', 'fsn') + ->assertSee('fsn-02'); + + expect(HostName::preview('fsn'))->toBe('fsn-02'); +}); + +it('gibt zwei gleichzeitig geöffneten Formularen zwei verschiedene Namen', function () { + // Beide sehen fsn-01, gespeichert wird fsn-01 und fsn-02. + expect(HostName::preview('fsn'))->toBe('fsn-01') + ->and(HostName::preview('fsn'))->toBe('fsn-01'); + + expect(onboard()->name)->toBe('fsn-01') + ->and(onboard()->name)->toBe('fsn-02'); +}); + +// --- Der Riegel --- + +it('lässt zwei Hosts nicht denselben Namen tragen', function () { + onboard(); + + expect(fn () => Host::factory()->create(['name' => 'fsn-01'])) + ->toThrow(Illuminate\Database\QueryException::class); +}); + +it('gibt zwei ähnlich geschriebenen Rechenzentrums-Codes nicht denselben Namen', function () { + // Codes von vor der Verschärfung der Prüfregel: eu_west und eu-west werden + // zur selben DNS-Bezeichnung, führen aber getrennte Zähler. Ohne das + // Weiterzählen bekäme der zweite eu-west-01 und liefe in den Index. + Datacenter::factory()->create(['code' => 'eu_west', 'name' => 'EU West (alt)']); + Datacenter::factory()->create(['code' => 'eu-west', 'name' => 'EU West']); + + expect(onboard('eu_west')->name)->toBe('eu-west-01') + ->and(onboard('eu-west')->name)->toBe('eu-west-02'); +}); + +it('zählt ab hundert ohne Sonderfall weiter', function () { + Datacenter::query()->where('code', 'fsn')->update(['next_host_number' => 100]); + + expect(onboard()->name)->toBe('fsn-100'); +}); + +// --- Ein Name, überall derselbe --- + +it('nennt den Host im DNS so, wie die Konsole ihn nennt', function () { + $host = onboard(); + + expect(HostName::fqdn($host->name))->toBe('fsn-01.node.clupilot.com') + ->and(App\Support\HostTakeoverCommand::fqdnFor($host)) + ->toBe(HostName::fqdn($host->name)); +}); +``` + +- [ ] **Schritt 2: Testlauf, der scheitern muss** + +```bash +docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Admin/HostNamingTest.php +``` + +Erwartet: FAIL — `Class "App\Support\HostName" not found`. + +- [ ] **Schritt 3: `App\Support\HostName` anlegen** + +Neue Datei `app/Support/HostName.php`: + +```php +-`, fortlaufend je Rechenzentrum und + * niemals wiederverwendet. Maschine, Proxmox-Node, DNS, /etc/hosts und Konsole + * benutzen denselben. + */ +final class HostName +{ + /** + * Der Name, den der nächste Host in diesem Rechenzentrum bekäme. + * + * LIEST den Zähler, verbraucht ihn nicht: die Anlegen-Seite zeigt den Namen, + * bevor gespeichert wird. Zwei gleichzeitig geöffnete Formulare zeigen + * deshalb beide dieselbe Nummer, und der zweite bekommt beim Speichern die + * nächste. Eine Reservierung beim Öffnen wäre die schlechtere Antwort — ein + * abgebrochenes Formular hinterließe eine Lücke im Zähler, die niemand + * wieder auffüllt. + */ + public static function preview(string $datacenterCode): string + { + $from = (int) (Datacenter::query() + ->where('code', $datacenterCode) + ->value('next_host_number') ?? 1); + + return self::free(self::label($datacenterCode), $from)[1]; + } + + /** + * Vergibt den Namen und verbraucht die Nummer. + * + * Gehört in eine Transaktion: die Sperre auf die Rechenzentrums-Zeile hält + * nur bis zu deren Ende, und ohne sie holten sich zwei gleichzeitige + * Anlegen-Vorgänge dieselbe Nummer. + */ + public static function claim(string $datacenterCode): string + { + $dc = Datacenter::query()->where('code', $datacenterCode)->lockForUpdate()->first(); + + if ($dc === null) { + throw new RuntimeException("Kein Rechenzentrum mit dem Code {$datacenterCode}."); + } + + [$number, $name] = self::free(self::label($datacenterCode), (int) $dc->next_host_number); + + $dc->update(['next_host_number' => $number + 1]); + + return $name; + } + + /** + * `fsn-03` → `fsn-03.node.clupilot.com`. + * + * Die PLATTFORM-Zone, nicht die Kundenzone: die zwei sind laut + * OfficialDomains getrennt, und ein Host gehört auf die Seite der Plattform. + */ + public static function fqdn(string $name): string + { + return $name.'.node.'.config('provisioning.dns.platform_zone'); + } + + /** + * Rechenzentrums-Codes von vor der Verschärfung der Prüfregel können + * Zeichen tragen, die eine DNS-Bezeichnung nicht darf. Ein Name, den der + * Anbieter ablehnt, ließe den Host für immer namenlos. + */ + public static function label(string $datacenterCode): string + { + $label = trim(preg_replace('/[^a-z0-9-]+/', '-', strtolower($datacenterCode)) ?? '', '-'); + + return $label !== '' ? $label : 'node'; + } + + /** + * Ab dieser Nummer aufwärts der erste freie Name. + * + * Der Zähler allein reicht nicht: zwei Alt-Codes (`eu_west` und `eu-west`) + * werden zur selben Bezeichnung und führen doch getrennte Zähler. Ohne + * dieses Weiterzählen bekäme der zweite `eu-west-01` und liefe in den + * eindeutigen Index — ein Abbruch beim Anlegen statt eines Namens. + * + * Nach unten geht es dabei nie: eine entfernte `fsn-02` wird nicht wieder + * vergeben, weil der Zähler längst darüber steht. Dieses Weiterzählen ist + * der Riegel, nicht der Zähler. + * + * `%02d` füllt zweistellig auf und wächst ab hundert von selbst weiter. + * + * @return array{0: int, 1: string} + */ + private static function free(string $label, int $from): array + { + $number = max(1, $from); + + while (Host::query()->where('name', sprintf('%s-%02d', $label, $number))->exists()) { + $number++; + } + + return [$number, sprintf('%s-%02d', $label, $number)]; + } +} +``` + +- [ ] **Schritt 4: Die Migration schreiben** + +Neue Datei `database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php`. Der Dateiname sortiert hinter `2026_08_03_140000_say_where_a_proxy_host_came_from.php`, die bisher letzte. + +```php +unsignedInteger('next_host_number')->default(1)->after('code'); + }); + + // Der systematische Name wird der Name. Hosts ohne DNS-Namen (noch im + // Onboarding, nie so weit gekommen) behalten ihren — sie tragen keine + // Nummer, die sich übertragen ließe, und das Weiterzählen in + // HostName::free() geht an ihnen vorbei. + DB::table('hosts') + ->whereNotNull('dns_name') + ->where('dns_name', '<>', '') + ->update(['name' => DB::raw('dns_name')]); + + foreach (DB::table('datacenters')->get() as $dc) { + $label = HostName::label($dc->code); + + // Was schon vergeben ist — der Boden, unter den der Zähler nie darf. + $inUse = DB::table('hosts') + ->where('name', 'like', $label.'-%') + ->pluck('name') + ->map(fn (string $name) => (int) substr($name, strrpos($name, '-') + 1)) + ->max() ?? 0; + + // Und was der alte Zähler schon ausgegeben HATTE. Ohne diesen Wert + // ginge die Zusage „niemals wiederverwendet" beim Umzug verloren: + // ein Host, der angelegt und wieder entfernt wurde, steht in keiner + // Zeile mehr, aber sein Name steht noch in den Protokollen. + $carried = (int) (json_decode( + (string) DB::table('app_settings')->where('key', 'dns.sequence.'.$label)->value('value'), + true, + ) ?? 0); + + DB::table('datacenters')->where('id', $dc->id) + ->update(['next_host_number' => max($inUse, $carried) + 1]); + } + + // Der alte Zähler geht mit. Eine tote Einstellung, die noch wie eine + // Quelle aussieht, ist genau das Problem, das diese Migration behebt. + DB::table('app_settings')->where('key', 'like', 'dns.sequence.%')->delete(); + + // Getrennte Aufrufe: Index löschen, Spalte löschen und Index anlegen in + // einem Blueprint bringt SQLite (Testlauf) durcheinander. + Schema::table('hosts', function (Blueprint $table) { + $table->dropUnique(['dns_name']); + }); + + Schema::table('hosts', function (Blueprint $table) { + $table->dropColumn('dns_name'); + }); + + // Ein Riegel, kein Ersatz für den Zähler. + Schema::table('hosts', function (Blueprint $table) { + $table->unique('name'); + }); + } + + public function down(): void + { + Schema::table('hosts', function (Blueprint $table) { + $table->dropUnique(['name']); + }); + + Schema::table('hosts', function (Blueprint $table) { + $table->string('dns_name')->nullable()->unique()->after('name'); + }); + + // Zurück in die zwei Namen: beide tragen ab hier denselben Wert. Die + // getippten Namen von vorher sind fort — sie waren der Fehler. + DB::table('hosts')->update(['dns_name' => DB::raw('name')]); + + foreach (DB::table('datacenters')->get() as $dc) { + DB::table('app_settings')->updateOrInsert( + ['key' => 'dns.sequence.'.HostName::label($dc->code)], + [ + 'value' => json_encode(max(0, (int) $dc->next_host_number - 1)), + 'created_at' => now(), + 'updated_at' => now(), + ], + ); + } + + Schema::table('datacenters', function (Blueprint $table) { + $table->dropColumn('next_host_number'); + }); + } +}; +``` + +- [ ] **Schritt 5: Die Modelle nachziehen** + +`app/Models/Datacenter.php` — Zeile 15 und der `casts()`-Rumpf: + +```php + protected $fillable = ['code', 'name', 'facility', 'location', 'active', 'next_host_number']; + + protected function casts(): array + { + return ['active' => 'boolean', 'next_host_number' => 'integer']; + } +``` + +`app/Models/Host.php` — Zeile 25, `dns_name` fällt aus `$fillable`: + +```php + 'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', 'dns_record_id', +``` + +- [ ] **Schritt 6: `StartHostOnboarding` vergibt den Namen** + +`app/Actions/StartHostOnboarding.php`, Zeilen 18–31: + +```php + /** + * @param array{datacenter: string, public_ip: string, root_password: string} $input + */ + public function run(array $input): Host + { + // Host + run are created atomically; a partial insert would otherwise + // leave a permanently pending host with no run. + [$host, $run] = DB::transaction(function () use ($input) { + $host = Host::create([ + // Den Namen vergibt CluPilot, nicht der Betreiber. Innerhalb + // DIESER Transaktion, weil HostName::claim die + // Rechenzentrums-Zeile sperrt und die Sperre nur bis zu deren + // Ende hält — außerhalb bekämen zwei gleichzeitige + // Anlegen-Vorgänge dieselbe Nummer. + 'name' => HostName::claim($input['datacenter']), + 'datacenter' => $input['datacenter'], + 'public_ip' => $input['public_ip'], + 'status' => 'pending', + ]); +``` + +Und der Import oben: `use App\Support\HostName;` + +- [ ] **Schritt 7: Die Anlegen-Seite sagt den Namen vorher** + +`app/Livewire/Admin/HostCreate.php` — die zwei Zeilen 30–31 (`$name` samt `#[Validate]`) ersatzlos streichen, `use App\Support\HostName;` ergänzen, und `render()` erweitern: + +```php + public function render() + { + // Ein Name, der ohne Ankündigung entsteht, ist eine Überraschung. Die + // Seite zeigt ihn, sobald ein Rechenzentrum gewählt ist — gelesen, nicht + // reserviert (siehe HostName::preview). + $preview = $this->datacenter === '' ? null : HostName::preview($this->datacenter); + + return view('livewire.admin.host-create', [ + 'datacenters' => \App\Models\Datacenter::query()->active()->orderBy('name')->get(), + 'previewName' => $preview, + 'previewFqdn' => $preview === null ? null : HostName::fqdn($preview), + 'archiveUrl' => HostTakeoverCommand::archiveUrl(), + 'missingSettings' => HostTakeoverCommand::missingSettings(), + ]); + } +``` + +- [ ] **Schritt 8: Die Ansicht umbauen** + +`resources/views/livewire/admin/host-create.blade.php`. Die bisherige Namenszeile + +```blade + + + +``` + +wird zur Anzeigezeile — und sie zieht UNTER die Rechenzentrums-Auswahl, weil sie erst von ihr abhängt: + +```blade + + {{-- Kein Feld mehr. Ein selbst getippter Name ist eine + Fehlerquelle ohne Gegenwert: er muss eindeutig + sein, DNS-tauglich, und er sagt nichts, was das + Rechenzentrum nicht schon sagt. --}} +

{{ $previewName ?? __('hosts.unknown') }}

+
+``` + +Im `select` darüber muss `wire:model` zu `wire:model.live` werden, sonst steht die Vorschau still: + +```blade +