# 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