# 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 18–31:

```php
    /**
     * @param  array{datacenter: string, public_ip: string, root_password: string}  $input
     */
    public function run(array $input): Host
    {
        // Host + run are created atomically; a partial insert would otherwise
        // leave a permanently pending host with no run.
        [$host, $run] = DB::transaction(function () use ($input) {
            $host = Host::create([
                // Den Namen vergibt CluPilot, nicht der Betreiber. Innerhalb
                // DIESER Transaktion, weil HostName::claim die
                // Rechenzentrums-Zeile sperrt und die Sperre nur bis zu deren
                // Ende hält — außerhalb bekämen zwei gleichzeitige
                // Anlegen-Vorgänge dieselbe Nummer.
                'name' => HostName::claim($input['datacenter']),
                'datacenter' => $input['datacenter'],
                'public_ip' => $input['public_ip'],
                'status' => 'pending',
            ]);
```

Und der Import oben: `use App\Support\HostName;`

- [ ] **Schritt 7: Die Anlegen-Seite sagt den Namen vorher**

`app/Livewire/Admin/HostCreate.php` — die zwei Zeilen 30–31 (`$name` samt `#[Validate]`) ersatzlos streichen, `use App\Support\HostName;` ergänzen, und `render()` erweitern:

```php
    public function render()
    {
        // Ein Name, der ohne Ankündigung entsteht, ist eine Überraschung. Die
        // Seite zeigt ihn, sobald ein Rechenzentrum gewählt ist — gelesen, nicht
        // reserviert (siehe HostName::preview).
        $preview = $this->datacenter === '' ? null : HostName::preview($this->datacenter);

        return view('livewire.admin.host-create', [
            'datacenters' => \App\Models\Datacenter::query()->active()->orderBy('name')->get(),
            'previewName' => $preview,
            'previewFqdn' => $preview === null ? null : HostName::fqdn($preview),
            'archiveUrl' => HostTakeoverCommand::archiveUrl(),
            'missingSettings' => HostTakeoverCommand::missingSettings(),
        ]);
    }
```

- [ ] **Schritt 8: Die Ansicht umbauen**

`resources/views/livewire/admin/host-create.blade.php`. Die bisherige Namenszeile

```blade
                        <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 43–44:

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

`lang/en/hosts.php`, Zeile 43–44:

```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 109–114. Der Kopfkommentar darüber (Zeilen 96–108) 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 84–88:

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