873 lines
40 KiB
Markdown
873 lines
40 KiB
Markdown
# 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.
|