40 KiB
Hostnamen-Vergabe — Umsetzungsplan
Für ausführende Agenten: ERFORDERLICHE UNTER-SKILL:
superpowers:subagent-driven-development(empfohlen) odersuperpowers: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) + 1ist 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 weiterfsn-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), niemalsprovisioning.dns.zone(clupilot.cloud). - R19 (Zeitzone), R20 (Bearbeiten im Modal), R23 (kein
wire:confirm), R24 (Modal-Höhe) ausCLAUDE.mdgelten 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)
hosts.dns_nameentfällt. Die Spec schweigt dazu. Die Spalte war die unsichtbare zweite Wahrheit — sie steht in keiner einzigen Ansicht, während Liste und Detailseitehosts.namezeigen. Genau daran ist der Fehler aufgefallen.namebekommt den systematischen Namen,dns_namewird nach dem Übertragen gelöscht.PrepareBaseSystemwird doch angefasst. Die Spec sagt „unverändert" und meint damit Zeile 27 (hostnamectl). Zeile 23 baut aber einen zweiten FQDN:{name}.{datacenter}.clupilot.net— undclupilot.netsteht genau an dieser einen Stelle im ganzen Repo, in keiner Konfiguration. Das ist dieselbe Krankheit eine Ebene tiefer und wird mitrepariert.- 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üsseldns.sequence.* - Geändert:
StartHostOnboarding::run()nimmtarray{datacenter, public_ip, root_password}— ohnename
- 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
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
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\HostNameanlegen
Neue Datei app/Support/HostName.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
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:
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:
'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', 'dns_record_id',
- Schritt 6:
StartHostOnboardingvergibt den Namen
app/Actions/StartHostOnboarding.php, Zeilen 18–31:
/**
* @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:
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
<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:
<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:
<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:
<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 43–44:
'name' => 'Name',
'name_hint' => 'Vergibt CluPilot fortlaufend. Erreichbar unter :fqdn',
lang/en/hosts.php, Zeile 43–44:
'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 109–114. Der Kopfkommentar darüber (Zeilen 96–108) bleibt, der Rumpf wird zur Weiterleitung:
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:
RegisterHostDnsvergibt nichts mehr
app/Provisioning/Steps/Host/RegisterHostDns.php. execute() schrumpft, reserveName() und dnsLabel() fallen ganz weg, ebenso die Importe Cache und Settings:
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/hostsbekommt denselben Namen
app/Provisioning/Steps/Host/PrepareBaseSystem.php, Zeile 23:
// 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:
PurgeHosträumt unternameweg
app/Provisioning/Jobs/PurgeHost.php, Zeilen 84–88:
// 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
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' => nullfä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 inHostNamingTest, 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 nachHostNamingTestgewandert.it('does not hand the same name to two datacenter codes that look alike')ersatzlos löschen — ebenfalls inHostNamingTest.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_idbleibt — 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 zuHost::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, undassertHasErrors(['name', 'public_ip', 'root_password'])wird zuassertHasErrors(['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:
// Mirrors PurgeHost's guard (filled($host->name)) not always holding
- Schritt 16: Der vollständige Testlauf
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
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 .odergit 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(Blockdetail),lang/en/hosts.php(Blockdetail) - Test:
tests/Feature/Admin/HostNamingTest.php
Schnittstellen:
- Verbraucht:
App\Support\HostName::fqdn(string $name): stringaus Aufgabe 1 - Erzeugt: Ansichtsvariable
$fqdninlivewire.admin.host-detail
- Schritt 1: Den fehlschlagenden Test schreiben
Ans Ende von tests/Feature/Admin/HostNamingTest.php anfügen:
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
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' => …):
'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:
{{-- 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:
<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':
'fqdn' => 'Adresse',
'fqdn_hint' => 'Proxmox-Oberfläche — antwortet nur aus dem WireGuard-Tunnel.',
lang/en/hosts.php, an derselben Stelle:
'fqdn' => 'Address',
'fqdn_hint' => 'Proxmox web UI — answers from inside the WireGuard tunnel only.',
- Schritt 6: Testlauf
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
docker compose exec -u 1000:1000 -T app php artisan test
Erwartet: grün.
- Schritt 8: Committen
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
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:
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-1bleibt 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_idbleibt. Das ist die Altlast aus der öffentlichen Hetzner-Zone, dieclupilot:prune-host-dnsaufräumt — eine andere Sache als der Name.- Hosts ohne DNS-Namen werden nicht umbenannt. Wer noch im Onboarding steckt und nie bis
RegisterHostDnskam, trägt keine Nummer, die sich übertragen ließe. Auf dieser Installation gibt es keinen solchen.