CluPilotCloud/docs/superpowers/plans/2026-08-01-hostname-vergabe.md

40 KiB
Raw Blame History

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

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\HostName anlegen

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: StartHostOnboarding vergibt den Namen

app/Actions/StartHostOnboarding.php, Zeilen 1831:

    /**
     * @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:

    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 4344:

        'name' => 'Name',
        'name_hint' => 'Vergibt CluPilot fortlaufend. Erreichbar unter :fqdn',

lang/en/hosts.php, Zeile 4344:

        '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:

    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:

    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:

        // 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:

        // 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' => 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:

    // 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 . 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:

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