Fix-Runde 2: Warnung fuer nicht reparierte Hosts, case-sichere Vorabpruefung, wiederholbare Migration

Vier Befunde aus dem Abschluss-Review ueber d62a2c8/c815aee/11ba7ee:

- Die Migration renamt nur Hosts mit dns_name; pve-fsn-1/pve-hel-1 blieben
  unrepariert und stumm. Sie werden jetzt gesammelt und gemeldet (Log +
  Konsole), ohne die Migration abzubrechen - diese Hosts laufen weiter.
- Die Vorabpruefung verglich in PHP byteweise, die Spalte liegt auf
  utf8mb4_unicode_ci. Umgestellt auf GROUP BY/HAVING in SQL, damit dieselbe
  Kollation entscheidet, die spaeter den Unique-Index baut.
- Ein Fehlschlag nach der Vorabpruefung liess einen zweiten Anlauf sofort an
  "Duplicate column name" sterben. Die beiden betroffenen Schema-Schritte
  stehen jetzt hinter Schema::hasColumn(), macht den Kommentar darueber wahr.
- Seeder (DatabaseSeeder, DemoCustomerSeeder) sind auf pve-*-Namen sitzen
  geblieben, weil sie dns_name nie benutzt hatten. Auf fsn-01/hel-01
  umgestellt, next_host_number entsprechend vorbelegt.

Dazu vier Kleinigkeiten: ein Test nagelte den falschen Config-Schluessel fest
(dns.zone statt platform_zone), HostName::claim() erzwingt jetzt wirklich
eine Transaktion statt es nur zu verlangen, down() vergisst nicht mehr den
Settings-Cache, und der Kommentar ueber HostName::free() nennt jetzt ehrlich
die Einschraenkung auf einen einzelnen Thread.

Alle vier Migrationslaeufe (Vorabpruefung-Kollision, Meldung fuer
unreparierte Hosts, Fehlschlag-und-erneuter-Anlauf, Rueckbau mit
Cache-Invalidierung) gegen echtes MariaDB auf einer Scratch-Datenbank
geprueft, um die parallele Billing-Session nicht zu beruehren. Voller
Testlauf: 2395 bestanden. Bericht mit allen Befehlen und Ausgaben unter
.superpowers/sdd/2026-08-01-hostname-vergabe/final-fix-report.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/neue-pakete
nexxo 2026-08-01 14:52:15 +02:00
parent d6f2aa1c9e
commit 4cd848eead
7 changed files with 1132 additions and 26 deletions

View File

@ -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.
*

View File

@ -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 `<rz>-<nn>`
// 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.<zone>` — 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 <rz>-<nn> setzen, next_host_number '
.'entsprechend hochziehen, DNS und Proxmox-Node nachziehen.',
$unrepaired->count(),
$unrepaired->pluck('name')->implode(', '),
);
Log::warning($message);
(new ConsoleOutput)->writeln('<comment>'.$message.'</comment>');
}
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) {

View File

@ -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'],

View File

@ -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'],

View File

@ -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 (`<rz>-<nn>`) 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
<?php
use App\Actions\StartHostOnboarding;
use App\Livewire\Admin\HostCreate;
use App\Models\Datacenter;
use App\Models\Host;
use App\Provisioning\Jobs\PurgeHost;
use App\Support\HostName;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
beforeEach(function () {
Queue::fake();
fakeServices();
// Die Zone wird hier festgenagelt statt aus der .env dieser Maschine
// gelesen: `provisioning.dns.platform_zone` leitet sich sonst aus APP_URL
// ab, und dieselbe Prüfung fiele auf einer richtig konfigurierten
// Installation um. Geprüft wird die FORM des Namens, nicht die Domain
// dieser einen Kiste.
config()->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
<?php
namespace App\Support;
use App\Models\Datacenter;
use App\Models\Host;
use RuntimeException;
/**
* Der Name eines Hosts — an dieser einen Stelle gebildet.
*
* Vorher gab es zwei: den getippten in `hosts.name`, den die Konsole zeigte,
* und den aus Rechenzentrum und Nummer gebauten in `hosts.dns_name`, den das
* DNS führte. Nur der zweite löste auf, und er stand in keiner einzigen
* Ansicht. Der Betreiber rief den Namen auf, den er selbst vergeben hatte, und
* stand vor einer Seite, die nicht lädt.
*
* Jetzt vergibt CluPilot ihn: `<rz>-<nn>`, 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
<?php
use App\Support\HostName;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* CluPilot vergibt die Hostnamen, nicht der Betreiber.
*
* Vorher führte diese Installation zwei Namen für dieselbe Maschine: den
* getippten in `name` (den die Konsole zeigte) und den systematischen in
* `dns_name` (den das DNS führte). Nur der zweite löste auf, und er stand in
* keiner Ansicht.
*
* Der systematische gewinnt und wird DER Name. `pve-fns-1` heißt danach
* `fsn-01` — die ZEILE wird umbenannt, die Maschine nicht: ein Proxmox-Node
* lässt sich nachträglich nur mühsam umbenennen, und dass er anders heißt, ist
* auf der Detailseite als eigenes Feld sichtbar.
*
* Der Zähler zieht von `app_settings` an die Rechenzentrums-Zeile um. Er darf
* nicht aus den vorhandenen Hosts abgeleitet werden: `MAX(nummer) + 1` fällt
* zurück, sobald ein Host entfernt wird, und die nächste Maschine bekäme den
* Namen, der noch in Protokollen, Sicherungen und DNS-Zwischenspeichern steht.
*/
return new class extends Migration
{
public function up(): void
{
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
// 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 1831:
```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 3031 (`$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
<x-ui.row :label="__('hosts.field.name')" :hint="__('hosts.field.name_hint')" for="name">
<x-ui.input name="name" wire:model="name" autofocus />
</x-ui.row>
```
wird zur Anzeigezeile — und sie zieht UNTER die Rechenzentrums-Auswahl, weil sie erst von ihr abhängt:
```blade
<x-ui.row :label="__('hosts.field.name')" :hint="$previewFqdn ? __('hosts.field.name_hint', ['fqdn' => $previewFqdn]) : null">
{{-- 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. --}}
<p class="font-mono text-sm font-semibold text-ink">{{ $previewName ?? __('hosts.unknown') }}</p>
</x-ui.row>
```
Im `select` darüber muss `wire:model` zu `wire:model.live` werden, sonst steht die Vorschau still:
```blade
<select id="datacenter" wire:model.live="datacenter"
```
Die Reihenfolge im Panel lautet danach: Rechenzentrum, Name, Öffentliche IP, Root-Passwort. Das `autofocus` wandert an das erste verbliebene Eingabefeld — `public_ip`:
```blade
<x-ui.input name="public_ip" wire:model="public_ip" inputmode="numeric" placeholder="203.0.113.10" autofocus />
```
- [ ] **Schritt 9: Die Sprachdateien**
`lang/de/hosts.php`, Zeile 4344:
```php
'name' => 'Name',
'name_hint' => 'Vergibt CluPilot fortlaufend. Erreichbar unter :fqdn',
```
`lang/en/hosts.php`, Zeile 4344:
```php
'name' => 'Name',
'name_hint' => 'Assigned by CluPilot in sequence. Reachable at :fqdn',
```
- [ ] **Schritt 10: `fqdnFor()` verweist auf die eine Quelle**
`app/Support/HostTakeoverCommand.php`, Zeilen 109114. Der Kopfkommentar darüber (Zeilen 96108) bleibt, der Rumpf wird zur Weiterleitung:
```php
public static function fqdnFor(Host $host): string
{
return HostName::fqdn($host->name);
}
```
Kein Import nötig: `HostTakeoverCommand` und `HostName` liegen beide in `App\Support`.
- [ ] **Schritt 11: `RegisterHostDns` vergibt nichts mehr**
`app/Provisioning/Steps/Host/RegisterHostDns.php`. `execute()` schrumpft, `reserveName()` und `dnsLabel()` fallen ganz weg, ebenso die Importe `Cache` und `Settings`:
```php
public function execute(ProvisioningRun $run): StepResult
{
$host = $this->host($run);
if (blank($host->wg_ip)) {
return StepResult::fail('The host has no management address to publish.');
}
// Der Name steht seit dem Anlegen fest (StartHostOnboarding →
// HostName::claim). Dieser Schritt bildete ihn früher ein zweites Mal
// — daher kamen zwei Namen für dieselbe Maschine, von denen nur einer
// auflöste. Er veröffentlicht jetzt nur noch, was schon gilt.
$name = $host->name;
$fqdn = HostName::fqdn($name);
try {
$this->dns->write($name, $fqdn, $host->wg_ip);
} catch (Throwable $e) {
// (unverändert)
```
Der Kopfkommentar der Klasse bekommt seinen zweiten Absatz korrigiert — er beschrieb die Vergabe, die es hier nicht mehr gibt. Der Import oben: `use App\Support\HostName;`.
- [ ] **Schritt 12: `/etc/hosts` bekommt denselben Namen**
`app/Provisioning/Steps/Host/PrepareBaseSystem.php`, Zeile 23:
```php
// Derselbe FQDN, den DNS führt und den die Bootstrap-Zeile mitgibt.
// Hier stand `{$host->name}.{$host->datacenter}.clupilot.net` — eine
// Domain, die im ganzen Repo sonst nirgends vorkommt und in keiner
// Konfiguration steht. Die Maschine trug damit einen dritten Namen im
// eigenen /etc/hosts.
$fqdn = HostName::fqdn($host->name);
```
Import oben: `use App\Support\HostName;`
- [ ] **Schritt 13: `PurgeHost` räumt unter `name` weg**
`app/Provisioning/Jobs/PurgeHost.php`, Zeilen 8488:
```php
// Der interne hostsdir-Eintrag (vpn-dns). Der Name lebt auf der Zeile,
// die gleich gelöscht wird, also muss er zuerst heraus. `remove()` ist
// auf einen Eintrag, den es nie gab, ausdrücklich ein Nichtstun — ein
// Host, der nie bis zur DNS-Registrierung kam, purgt trotzdem sauber.
if (filled($host->name)) {
app(HostDnsDirectory::class)->remove($host->name);
}
```
- [ ] **Schritt 14: Testlauf für die neue Datei**
```bash
docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Admin/HostNamingTest.php
```
Erwartet: PASS, alle elf.
- [ ] **Schritt 15: Die bestehenden Tests nachziehen**
`tests/Feature/Provisioning/HostStepsTest.php` — im Block ab Zeile 1329:
- `it('gives the host a name that points at the tunnel, not at its public address')`: `'dns_name' => null` fällt weg, dafür `'name' => 'fsn-01'` setzen; `->and($host->fresh()->dns_name)->toBe('fsn-01')` wird zu `->and($host->fresh()->name)->toBe('fsn-01')`. Der Rest der Erwartungen (`fqdns['fsn-01']`, `ips['fsn-01']`) bleibt wörtlich stehen.
- `it('never reaches the public Hetzner DNS client for a host name')`: `'dns_name' => null``'name' => 'fsn-02'`.
- **`it('numbers hosts per datacenter and never reuses a number')` ersatzlos löschen.** Der Schritt nummeriert nicht mehr; die Zusage steht jetzt in `HostNamingTest`, geprüft an der Stelle, die sie vergibt.
- **`it('builds a valid DNS label even from an awkward datacenter code')` ersatzlos löschen** — dasselbe, ebenfalls nach `HostNamingTest` gewandert.
- **`it('does not hand the same name to two datacenter codes that look alike')` ersatzlos löschen** — ebenfalls in `HostNamingTest`.
- `it('retries a failed DNS registration before giving up on the name')`: `'dns_name' => null``'name' => 'fsn-04'`.
- `it("takes the host's internal DNS entry with it when the host is purged")`: `'dns_name' => null``'name' => 'fsn-05'`; `$name = $host->fresh()->dns_name;``$name = $host->name;`.
- `it('keeps the host until its internal DNS entry is really gone')`: `'dns_name' => null``'name' => 'hel-01'`.
- `it('still removes a legacy public Hetzner record when a pre-fix host is purged')`: `'dns_name' => 'fsn-legacy'` fällt weg, `'name' => 'fsn-legacy'` tritt an seine Stelle. `dns_record_id` bleibt — die Altlast in der öffentlichen Zone ist etwas anderes und bleibt bestehen.
- `it('sees an internal DNS entry registered while the purge was waiting')`: `'dns_name' => null``'name' => 'fsn-06'`; `$name = $host->fresh()->dns_name;``$name = $host->name;`.
`tests/Feature/Admin/HostManagementTest.php`:
- `it('creates a host and starts onboarding with an encrypted password')`: `->set('name', 'pve-fsn-7')` streichen; `Host::query()->where('name', 'pve-fsn-7')` wird zu `Host::query()->where('name', 'fsn-01')`.
- `it('rejects a duplicate public ip')`: `->set('name', 'pve-dup')` streichen.
- `it('validates the add-host form')`: `->set('name', '')` streichen, und `assertHasErrors(['name', 'public_ip', 'root_password'])` wird zu `assertHasErrors(['public_ip', 'root_password'])`.
`tests/Feature/Admin/HostTakeoverPageTest.php:96` und `tests/Feature/Host/FilesHostTest.php:99`: `['dns_name' => 'fsn-01']``['name' => 'fsn-01']`.
`tests/Feature/Provisioning/HostOnboardingEndToEndTest.php`: die Zeile `'name' => 'pve-fsn-9',` (Zeile 51) und `'name' => 'pve-fsn-10',` (Zeile 123) ersatzlos streichen. Weiter ist dort nichts zu tun — der Name wird in dieser Datei nirgends geprüft, und `fsn` legt die Migration `create_datacenters_table` schon an, `HostName::claim()` findet die Zeile also.
`tests/Feature/Provisioning/ServicesTest.php`, Zeile 369 — der Kommentar nennt eine Prüfung, die es nicht mehr gibt:
```php
// Mirrors PurgeHost's guard (filled($host->name)) not always holding
```
- [ ] **Schritt 16: Der vollständige Testlauf**
```bash
docker compose exec -u 1000:1000 -T app php artisan test
```
Erwartet: grün. Bleibt etwas rot, das hier nicht aufgezählt ist, mit `grep -rn "dns_name" app/ tests/ database/ resources/` nachsehen — es darf danach keinen einzigen Treffer mehr geben.
- [ ] **Schritt 17: Committen**
```bash
git add app/Support/HostName.php database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php tests/Feature/Admin/HostNamingTest.php app/Models/Datacenter.php app/Models/Host.php app/Actions/StartHostOnboarding.php app/Livewire/Admin/HostCreate.php resources/views/livewire/admin/host-create.blade.php app/Support/HostTakeoverCommand.php app/Provisioning/Steps/Host/RegisterHostDns.php app/Provisioning/Steps/Host/PrepareBaseSystem.php app/Provisioning/Jobs/PurgeHost.php lang/de/hosts.php lang/en/hosts.php tests/Feature/Provisioning/HostStepsTest.php tests/Feature/Admin/HostManagementTest.php tests/Feature/Admin/HostTakeoverPageTest.php tests/Feature/Host/FilesHostTest.php tests/Feature/Provisioning/HostOnboardingEndToEndTest.php tests/Feature/Provisioning/ServicesTest.php && git commit -m "Hostnamen vergibt CluPilot: ein Name statt zweier, und der Zaehler ueberlebt das Loeschen"
```
**Achtung — in diesem Arbeitsverzeichnis wird parallel gearbeitet.** Es liegen fremde, unfestgeschriebene Änderungen im Baum (zuletzt Billing: `app/Actions/BookAddon.php`, `app/Livewire/Billing.php`, `app/Services/Billing/AddonCatalogue.php`, `app/Livewire/ConfirmBookStorage.php`, `lang/*/billing.php`, `tests/Feature/Billing/*`). Deshalb gilt ausnahmslos:
- **Niemals `git add -A`, `git add .` oder `git commit -a`.** Nur die oben namentlich genannten Pfade.
- **Fremde Änderungen nicht zurücknehmen, nicht stashen, nicht committen.** Sie bleiben unangetastet im Baum liegen.
- Schlägt im vollständigen Testlauf etwas aus `tests/Feature/Billing/` fehl, gehört das nicht zu dieser Aufgabe. Melden, nicht reparieren.
---
## Aufgabe 2: Die Detailseite zeigt IP und Domain
Der Name allein hilft nicht, wenn niemand weiß, wie die Adresse dazu lautet. Der FQDN steht als Link in der Ausstattungstafel, neben der öffentlichen IP und der Mgmt-IP, die dort schon stehen.
**Dateien:**
- Ändern: `app/Livewire/Admin/HostDetail.php:147-162`
- Ändern: `resources/views/livewire/admin/host-detail.blade.php:139-151`
- Ändern: `lang/de/hosts.php` (Block `detail`), `lang/en/hosts.php` (Block `detail`)
- Test: `tests/Feature/Admin/HostNamingTest.php`
**Schnittstellen:**
- Verbraucht: `App\Support\HostName::fqdn(string $name): string` aus Aufgabe 1
- Erzeugt: Ansichtsvariable `$fqdn` in `livewire.admin.host-detail`
---
- [ ] **Schritt 1: Den fehlschlagenden Test schreiben**
Ans Ende von `tests/Feature/Admin/HostNamingTest.php` anfügen:
```php
it('zeigt auf der Detailseite die IP und die Domain', function () {
$host = onboard();
$host->update(['status' => 'active', 'wg_ip' => '10.66.0.100', 'public_ip' => '203.0.113.200']);
Livewire::actingAs(admin(), 'operator')
->test(App\Livewire\Admin\HostDetail::class, ['host' => $host])
->assertSee('203.0.113.200')
->assertSee('10.66.0.100')
// Der Name allein hilft nicht, wenn niemand die Adresse dazu kennt.
// 8006 ist die Proxmox-Oberfläche; SecureHostFirewall lässt sie nur aus
// dem Tunnel zu, und der hostsdir-Eintrag löst nur dort auf.
->assertSee('fsn-01.node.clupilot.com')
->assertSee('https://fsn-01.node.clupilot.com:8006', false);
});
```
- [ ] **Schritt 2: Testlauf, der scheitern muss**
```bash
docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Admin/HostNamingTest.php --filter="Detailseite"
```
Erwartet: FAIL — die Seite zeigt weder den FQDN noch die Adresse.
- [ ] **Schritt 3: Die Ansichtsvariable durchreichen**
`app/Livewire/Admin/HostDetail.php`, im `return view(...)`-Array von `render()` (nach `'version' => …`):
```php
'fqdn' => HostName::fqdn($this->host->name),
```
Import oben: `use App\Support\HostName;`
- [ ] **Schritt 4: Die Tafel um den Link erweitern**
`resources/views/livewire/admin/host-detail.blade.php`. Direkt vor dem bestehenden `<div class="min-w-0">`-Block für `hosts.meta.version` einfügen:
```blade
{{-- Die Adresse, nicht bloß der Name: ein Klick landet auf der
Proxmox-Oberfläche des Hosts. Sie antwortet nur aus dem Tunnel
— SecureHostFirewall lässt 8006 ausschließlich aus dem
Management-Subnetz zu, und der Name löst nur dort auf. --}}
<div class="min-w-0">
<dt class="text-xs text-muted">{{ __('hosts.detail.fqdn') }}</dt>
<dd class="mt-0.5 truncate font-mono text-sm font-semibold">
<a href="https://{{ $fqdn }}:8006" target="_blank" rel="noopener"
class="text-accent-text underline-offset-2 hover:underline"
title="{{ __('hosts.detail.fqdn_hint') }}">{{ $fqdn }}</a>
</dd>
</div>
```
Das Raster darüber steht auf `lg:grid-cols-6` bei sechs Einträgen; mit dem siebten wird daraus eine zweite Reihe. Damit die Tafel einreihig bleibt, `lg:grid-cols-6` in Zeile 139 auf `lg:grid-cols-7` heben:
```blade
<dl class="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3 lg:grid-cols-7">
```
- [ ] **Schritt 5: Die Sprachdateien**
`lang/de/hosts.php`, im Block `'detail'` neben `'node'`:
```php
'fqdn' => 'Adresse',
'fqdn_hint' => 'Proxmox-Oberfläche — antwortet nur aus dem WireGuard-Tunnel.',
```
`lang/en/hosts.php`, an derselben Stelle:
```php
'fqdn' => 'Address',
'fqdn_hint' => 'Proxmox web UI — answers from inside the WireGuard tunnel only.',
```
- [ ] **Schritt 6: Testlauf**
```bash
docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Admin/HostNamingTest.php
```
Erwartet: PASS, alle zwölf.
- [ ] **Schritt 7: Der vollständige Testlauf**
```bash
docker compose exec -u 1000:1000 -T app php artisan test
```
Erwartet: grün.
- [ ] **Schritt 8: Committen**
```bash
git add app/Livewire/Admin/HostDetail.php resources/views/livewire/admin/host-detail.blade.php lang/de/hosts.php lang/en/hosts.php tests/Feature/Admin/HostNamingTest.php && git commit -m "Hostdetailseite: die Adresse steht neben den IPs und ist ein Klick zur Proxmox-Oberflaeche"
```
---
## Nach dem Bauen
- [ ] **Migration auf dieser Installation fahren**
```bash
docker compose exec -u 1000:1000 app php artisan migrate
```
Danach prüfen, dass der bestehende Host umbenannt ist und der Zähler bei 2 steht:
```bash
docker compose exec -u 1000:1000 -T app php artisan tinker --execute="dump(App\Models\Host::pluck('name'), App\Models\Datacenter::pluck('next_host_number', 'code'));"
```
Erwartet: der Host heißt `fsn-01`, `fsn` steht auf `2` — genau wie in der Spec unter „Der bestehende Host".
- [ ] **R15: Codex-Review** über den Diff beider Commits (siehe `clupilot-r15-codex-review`). Höchstens zwei Runden ohne P1; Restbefunde als Folgepunkte notieren (R22).
## Was dieser Plan bewusst NICHT tut
- **Der Proxmox-Node wird nicht umbenannt.** `pve-fns-1` bleibt der Node-Name der laufenden Maschine; ein Node lässt sich nachträglich nur mühsam umbenennen. Die Detailseite führt „Node" ohnehin als eigenes Feld, der Unterschied ist damit sichtbar und schadet nicht. So steht es in der Spec.
- **`hosts.dns_record_id` bleibt.** Das ist die Altlast aus der öffentlichen Hetzner-Zone, die `clupilot:prune-host-dns` aufräumt — eine andere Sache als der Name.
- **Hosts ohne DNS-Namen werden nicht umbenannt.** Wer noch im Onboarding steckt und nie bis `RegisterHostDns` kam, trägt keine Nummer, die sich übertragen ließe. Auf dieser Installation gibt es keinen solchen.

View File

@ -0,0 +1,131 @@
# Hostnamen vergibt CluPilot, nicht der Betreiber
**Stand:** Entwurf, 1. August 2026
**Auslöser:** gemessen auf `pve-fns-1`, dem ersten echten Host
---
## Das Problem, an dem es aufgefallen ist
Der Betreiber legte einen Host als `pve-fns-1` an. Die Konsole zeigte diesen
Namen, `PrepareBaseSystem` setzte ihn als Hostnamen, und Proxmox übernahm ihn
als Node-Namen.
`RegisterHostDns` trug aber etwas anderes ein:
```
10.66.0.100 fsn-01.node.clupilot.com
```
`HostTakeoverCommand::fqdnFor()` bildet den Namen **systematisch** aus
Rechenzentrum und laufender Nummer — unabhängig davon, was eingegeben wurde.
Damit führt CluPilot zwei Namen für dieselbe Maschine, und **nur einer davon
löst auf.** Der Betreiber rief den Namen auf, den er selbst vergeben hatte,
und stand vor einer Seite, die nicht lädt. Nirgendwo in der Oberfläche stand,
dass es einen zweiten gibt.
Das ist keine Anzeigeschwäche. Es sind zwei Wahrheiten über dieselbe Sache,
und die zweite fällt erst auf, wenn jemand sie braucht.
## Die Entscheidung
**Der systematische Name gewinnt. Das Namensfeld entfällt.**
Der Betreiber wählt ein Rechenzentrum und trägt IP und Root-Passwort ein.
Den Namen vergibt CluPilot:
```
<rz>-<nn> fsn-03
<rz>-<nn>.node.<zone> fsn-03.node.clupilot.com
```
Ein Name, an einer Stelle gebildet, von Maschine, Proxmox-Node, DNS und
Konsole gleichermaßen benutzt.
Begründung des Besitzers, wörtlich: *„das wäre sogar besser wie pve-fns-1 was
ich eingetragen habe."* 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.
## Wie die Nummer vergeben wird
**Fortlaufend je Rechenzentrum, niemals wiederverwendet.**
Eine gelöschte `fsn-02` steht danach noch in Protokollen, in Sicherungen, in
der Überwachungshistorie und im DNS-Zwischenspeicher. Bekäme die nächste
Maschine denselben Namen, teilten sich zwei verschiedene Server einen Namen
über die Zeit — und niemand könnte im Nachhinein sagen, welche gemeint war.
`MAX(nummer) + 1` **reicht dafür nicht**, weil `PurgeHost` die Zeile löscht und
das Maximum damit zurückfällt. Der Zähler muss die Löschung überleben:
- Spalte `next_host_number` an `datacenters`, Vorgabe 1
- Vergabe erhöht sie, Löschen eines Hosts fasst sie nicht an
- Vergabe innerhalb der bestehenden Transaktion in `StartHostOnboarding`,
mit Sperre auf die Rechenzentrums-Zeile (`lockForUpdate`)
- Zusätzlich ein eindeutiger Index auf `hosts.name` — ein Riegel, kein Ersatz
Zweistellig aufgefüllt (`fsn-03`), ab hundert wächst es natürlich weiter
(`fsn-100`). Kein Sonderfall nötig.
## Was sich ändert
| Ort | Heute | Danach |
|---|---|---|
| `Admin\HostCreate` | Feld „Name", Pflicht | entfällt; die Seite **zeigt**, welcher Name vergeben wird |
| `StartHostOnboarding` | nimmt `name` entgegen | vergibt ihn selbst aus dem Rechenzentrum |
| `HostTakeoverCommand::fqdnFor()` | bildet `<rz>-<nn>` eigenständig | `$host->name.'.node.'.$zone` — eine Zeile |
| `RegisterHostDns` | eigene Ableitung | benutzt dieselbe Quelle |
| `PrepareBaseSystem` | `hostnamectl set-hostname $host->name` | unverändert — der Name ist jetzt der richtige |
| `datacenters` | — | Spalte `next_host_number` |
| `hosts` | `name` frei | eindeutiger Index auf `name` |
`PrepareBaseSystem` bleibt bewusst unangetastet: Es setzte immer schon
`$host->name`. Sobald der Name systematisch ist, stimmen Maschine, Node und DNS
von selbst überein. Das ist der Beweis, dass die Reparatur an der richtigen
Stelle sitzt — sie entfernt eine zweite Quelle, statt eine dritte einzuführen.
## Die Anlegen-Seite sagt den Namen vorher
Ein Name, der ohne Ankündigung entsteht, ist eine Überraschung. Die Seite
zeigt ihn, sobald ein Rechenzentrum gewählt ist:
> Dieser Host wird **fsn-03** heißen und unter
> `fsn-03.node.clupilot.com` erreichbar sein.
Vergeben wird er trotzdem erst beim Speichern — die Vorschau liest den Zähler,
sie verbraucht ihn nicht. Zwei gleichzeitig geöffnete Formulare zeigen also
beide `fsn-03`, und der zweite bekommt beim Speichern `fsn-04`. Das ist
richtig so und darf nicht durch eine Reservierung „behoben" werden: ein
abgebrochenes Formular hinterließe sonst eine Lücke im Zähler, die niemand
wieder auffüllt.
## Der bestehende Host
`pve-fns-1` läuft, sein Proxmox-Node heißt so, und ein Node lässt sich
nachträglich nur mühsam umbenennen.
**Die Zeile wird auf `fsn-01` umbenannt, die Maschine nicht.** Konsole und DNS
stimmen damit überein; dass der Proxmox-Node anders heißt, ist sichtbar und
schadet nicht — die Host-Detailseite führt „Node" ohnehin als eigenes Feld.
`datacenters.next_host_number` für `fsn` startet entsprechend bei 2.
## Abnahme
1. Host in `fsn` anlegen ohne Namensfeld → heißt `fsn-02`, DNS löst auf
2. Zweiten anlegen → `fsn-03`, keine Kollision
3. `fsn-02` entfernen, dritten anlegen → **`fsn-04`**, nicht `fsn-02`
4. Auf der Maschine: `hostname` meldet denselben Namen wie das DNS
5. Zwei Formulare gleichzeitig: beide zeigen `fsn-05`, gespeichert wird
`fsn-05` und `fsn-06`
Punkt 3 ist der, der die Sorgfalt trägt. Er scheitert bei jeder Umsetzung, die
`MAX(nummer) + 1` rechnet.
## Reihenfolge
Nach `vmbr0`, vor der vollständigen Abnahmeinstallation. Der Besitzer will die
Namensvergabe im selben Durchlauf prüfen, in dem eine frische Maschine ohne
einen Handgriff auf `active` geht.

View File

@ -1329,11 +1329,11 @@ it('marks the host active on completion', function () {
it('publishes the name it was given, and points it at the tunnel, not at the public address', function () {
// The zone is pinned here rather than read from the machine's own .env. It
// used to assert the literal clupilot.com, which was true until somebody
// set CLUPILOT_DNS_ZONE to clupilot.cloud on their installation — and then
// this test failed on a correct configuration. What it is about is the
// SHAPE of the name and which address it carries, not which domain this
// particular box is configured for.
config()->set('provisioning.dns.zone', 'clupilot.com');
// set CLUPILOT_PLATFORM_ZONE to clupilot.cloud on their installation — and
// then this test failed on a correct configuration. What it is about is
// the SHAPE of the name and which address it carries, not which domain
// this particular box is configured for.
config()->set('provisioning.dns.platform_zone', 'clupilot.com');
$s = fakeServices();
$host = Host::factory()->active()->create([