Fix-Runde 2: Warnung fuer nicht reparierte Hosts, case-sichere Vorabpruefung, wiederholbare Migration
Vier Befunde aus dem Abschluss-Review ueber d62a2c8/c815aee/11ba7ee: - Die Migration renamt nur Hosts mit dns_name; pve-fsn-1/pve-hel-1 blieben unrepariert und stumm. Sie werden jetzt gesammelt und gemeldet (Log + Konsole), ohne die Migration abzubrechen - diese Hosts laufen weiter. - Die Vorabpruefung verglich in PHP byteweise, die Spalte liegt auf utf8mb4_unicode_ci. Umgestellt auf GROUP BY/HAVING in SQL, damit dieselbe Kollation entscheidet, die spaeter den Unique-Index baut. - Ein Fehlschlag nach der Vorabpruefung liess einen zweiten Anlauf sofort an "Duplicate column name" sterben. Die beiden betroffenen Schema-Schritte stehen jetzt hinter Schema::hasColumn(), macht den Kommentar darueber wahr. - Seeder (DatabaseSeeder, DemoCustomerSeeder) sind auf pve-*-Namen sitzen geblieben, weil sie dns_name nie benutzt hatten. Auf fsn-01/hel-01 umgestellt, next_host_number entsprechend vorbelegt. Dazu vier Kleinigkeiten: ein Test nagelte den falschen Config-Schluessel fest (dns.zone statt platform_zone), HostName::claim() erzwingt jetzt wirklich eine Transaktion statt es nur zu verlangen, down() vergisst nicht mehr den Settings-Cache, und der Kommentar ueber HostName::free() nennt jetzt ehrlich die Einschraenkung auf einen einzelnen Thread. Alle vier Migrationslaeufe (Vorabpruefung-Kollision, Meldung fuer unreparierte Hosts, Fehlschlag-und-erneuter-Anlauf, Rueckbau mit Cache-Invalidierung) gegen echtes MariaDB auf einer Scratch-Datenbank geprueft, um die parallele Billing-Session nicht zu beruehren. Voller Testlauf: 2395 bestanden. Bericht mit allen Befehlen und Ausgaben unter .superpowers/sdd/2026-08-01-hostname-vergabe/final-fix-report.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/neue-pakete
parent
d6f2aa1c9e
commit
4cd848eead
|
|
@ -4,6 +4,7 @@ namespace App\Support;
|
|||
|
||||
use App\Models\Datacenter;
|
||||
use App\Models\Host;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
|
|
@ -49,6 +50,17 @@ final class HostName
|
|||
*/
|
||||
public static function claim(string $datacenterCode): string
|
||||
{
|
||||
if (DB::transactionLevel() === 0) {
|
||||
// Der Absatz oben VERLANGT eine Transaktion, erzwingt sie bisher
|
||||
// aber nicht: außerhalb einer Transaktion hält `lockForUpdate`
|
||||
// die Sperre nur bis zum Ende dieser einen Anfrage, nicht bis zum
|
||||
// `update()` weiter unten — zwei gleichzeitige Aufrufe holten
|
||||
// sich still dieselbe Nummer, ohne dass irgendwo ein Fehler
|
||||
// auftaucht. Lieber hier laut scheitern als dort leise falsch
|
||||
// vergeben.
|
||||
throw new RuntimeException('HostName::claim() muss innerhalb einer Transaktion aufgerufen werden.');
|
||||
}
|
||||
|
||||
$dc = Datacenter::query()->where('code', $datacenterCode)->lockForUpdate()->first();
|
||||
|
||||
if ($dc === null) {
|
||||
|
|
@ -95,7 +107,13 @@ final class HostName
|
|||
*
|
||||
* Nach unten geht es dabei nie: eine entfernte `fsn-02` wird nicht wieder
|
||||
* vergeben, weil der Zähler längst darüber steht. Dieses Weiterzählen ist
|
||||
* der Riegel, nicht der Zähler.
|
||||
* der Riegel, nicht der Zähler — aber nur einfädig. `claim()` sperrt die
|
||||
* ZEILE des Rechenzentrums, und `eu_west` und `eu-west` sind zwei
|
||||
* verschiedene Zeilen, die zur selben Bezeichnung normalisieren. Laufen
|
||||
* beide gleichzeitig durchs Onboarding, sperrt jeder Aufruf nur seine
|
||||
* eigene Zeile, beide sehen hier denselben Kandidaten frei, und wer den
|
||||
* `INSERT` als Zweiter absetzt, bekommt am `unique('name')`-Index eine
|
||||
* unbehandelte `QueryException` statt der nächsten Nummer.
|
||||
*
|
||||
* `%02d` füllt zweistellig auf und wächst ab hundert von selbst weiter.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@
|
|||
use App\Support\HostName;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Symfony\Component\Console\Output\ConsoleOutput;
|
||||
|
||||
/**
|
||||
* CluPilot vergibt die Hostnamen, nicht der Betreiber.
|
||||
|
|
@ -50,12 +53,20 @@ return new class extends Migration
|
|||
// fremden `dns_name` gleicht — und lässt die Datenbank unangetastet,
|
||||
// wenn es einen Treffer gibt: eine reine SELECT-Prüfung vor der
|
||||
// ersten Schreiboperation, beliebig oft wiederholbar.
|
||||
//
|
||||
// GROUP BY/HAVING statt `pluck()->countBy()`: Letzteres vergleicht in
|
||||
// PHP byteweise, die Spalte aber liegt auf `utf8mb4_unicode_ci` —
|
||||
// unempfindlich gegen Groß-/Kleinschreibung und Akzente. `Pve-Fsn-1`
|
||||
// und `pve-fsn-1` kämen an der PHP-Prüfung vorbei und kollidierten
|
||||
// erst am `unique('name')`-Index weiter unten — ausgerechnet der Fall,
|
||||
// den diese Prüfung abfangen soll. GROUP BY lässt dieselbe Kollation
|
||||
// entscheiden, die später den Index baut, statt in PHP etwas
|
||||
// nachzubilden, das von der Spalte abdriften kann.
|
||||
$duplicates = DB::table('hosts')
|
||||
->selectRaw("(CASE WHEN dns_name IS NOT NULL AND dns_name <> '' THEN dns_name ELSE name END) as future_name")
|
||||
->pluck('future_name')
|
||||
->countBy()
|
||||
->filter(fn (int $count) => $count > 1)
|
||||
->keys();
|
||||
->groupBy('future_name')
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->pluck('future_name');
|
||||
|
||||
if ($duplicates->isNotEmpty()) {
|
||||
throw new RuntimeException(
|
||||
|
|
@ -65,9 +76,19 @@ return new class extends Migration
|
|||
);
|
||||
}
|
||||
|
||||
Schema::table('datacenters', function (Blueprint $table) {
|
||||
$table->unsignedInteger('next_host_number')->default(1)->after('code');
|
||||
});
|
||||
// Hinter `hasColumn`, damit ein zweiter Anlauf nach einem Fehlschlag
|
||||
// WEITER UNTEN (am Riegel, oder an einem `dns_name`-Index, den eine
|
||||
// Rücksicherung nicht mitbrachte) nicht sofort an „Duplicate column
|
||||
// name 'next_host_number'" stirbt. MariaDB hat diese Spalte im ersten
|
||||
// Anlauf längst committet, ganz ohne Eintrag in der Migrationstabelle
|
||||
// — ein zweiter Versuch soll bis zum tatsächlichen Fehler durchlaufen,
|
||||
// nicht schon davor. Billig, und es hält die Zusage von oben ein:
|
||||
// beliebig oft wiederholbar.
|
||||
if (! Schema::hasColumn('datacenters', 'next_host_number')) {
|
||||
Schema::table('datacenters', function (Blueprint $table) {
|
||||
$table->unsignedInteger('next_host_number')->default(1)->after('code');
|
||||
});
|
||||
}
|
||||
|
||||
// Der systematische Name wird der Name. Hosts ohne DNS-Namen (noch im
|
||||
// Onboarding, nie so weit gekommen) behalten ihren — sie tragen keine
|
||||
|
|
@ -78,6 +99,42 @@ return new class extends Migration
|
|||
->where('dns_name', '<>', '')
|
||||
->update(['name' => DB::raw('dns_name')]);
|
||||
|
||||
// Wer nach der Übertragung oben immer noch nicht wie `<rz>-<nn>`
|
||||
// aussieht, hatte keinen `dns_name` zum Übertragen — der getippte
|
||||
// Name blieb stehen. Auf dieser Installation ist das kein
|
||||
// theoretischer Fall: `pve-fsn-1` und `pve-hel-1` tragen genau diesen
|
||||
// Namen, unverändert, und die Host-Detailseite zeigt seitdem einen
|
||||
// Link auf `pve-fsn-1.node.<zone>` — einen Namen, den nie jemand ins
|
||||
// DNS geschrieben hat.
|
||||
//
|
||||
// Kein Abbruch: diese Hosts laufen und sollen weiterlaufen, das
|
||||
// Namensfeld ist nur beim Anlegen entfallen, nicht rückwirkend
|
||||
// Pflicht geworden. Aber still bleiben darf das nicht — eine
|
||||
// Migration läuft im Container, ihr Protokoll liest niemand von
|
||||
// selbst nach, deshalb zusätzlich auf die Konsole, wo ein Betreiber
|
||||
// `artisan migrate` tatsächlich ansieht.
|
||||
$unrepaired = DB::table('hosts')->get()->filter(function ($host) {
|
||||
$label = HostName::label($host->datacenter);
|
||||
|
||||
return preg_match('/^'.preg_quote($label, '/').'-\d{2,}$/', (string) $host->name) !== 1;
|
||||
});
|
||||
|
||||
if ($unrepaired->isNotEmpty()) {
|
||||
$message = sprintf(
|
||||
'clupilot_vergibt_die_hostnamen: %d Host(s) tragen nach dieser Migration weiterhin einen '
|
||||
.'getippten statt eines systematischen Namens (kein dns_name zum Übertragen vorhanden): %s. '
|
||||
.'Sie laufen unverändert weiter — aber jede Adresse, die aus dem Namen gebildet wird '
|
||||
.'(Detailseite, DNS, /etc/hosts), zeigt auf einen Namen, den niemand ins DNS geschrieben hat. '
|
||||
.'Von Hand umbenennen: Zeile auf ein systematisches <rz>-<nn> setzen, next_host_number '
|
||||
.'entsprechend hochziehen, DNS und Proxmox-Node nachziehen.',
|
||||
$unrepaired->count(),
|
||||
$unrepaired->pluck('name')->implode(', '),
|
||||
);
|
||||
|
||||
Log::warning($message);
|
||||
(new ConsoleOutput)->writeln('<comment>'.$message.'</comment>');
|
||||
}
|
||||
|
||||
foreach (DB::table('datacenters')->get() as $dc) {
|
||||
$label = HostName::label($dc->code);
|
||||
|
||||
|
|
@ -106,13 +163,22 @@ return new class extends Migration
|
|||
|
||||
// Getrennte Aufrufe: Index löschen, Spalte löschen und Index anlegen in
|
||||
// einem Blueprint bringt SQLite (Testlauf) durcheinander.
|
||||
Schema::table('hosts', function (Blueprint $table) {
|
||||
$table->dropUnique(['dns_name']);
|
||||
});
|
||||
//
|
||||
// Beide hinter derselben `hasColumn`-Prüfung wie oben und aus
|
||||
// demselben Grund: scheitert der Riegel weiter unten, oder fehlt
|
||||
// einer Rücksicherung genau der `dns_name`-Index, den `dropUnique`
|
||||
// gleich sucht, ist `next_host_number` schon da, `dns_name` aber
|
||||
// noch — ein zweiter Versuch soll wieder bis hierher kommen, nicht
|
||||
// vorher an der Spalte scheitern.
|
||||
if (Schema::hasColumn('hosts', 'dns_name')) {
|
||||
Schema::table('hosts', function (Blueprint $table) {
|
||||
$table->dropUnique(['dns_name']);
|
||||
});
|
||||
|
||||
Schema::table('hosts', function (Blueprint $table) {
|
||||
$table->dropColumn('dns_name');
|
||||
});
|
||||
Schema::table('hosts', function (Blueprint $table) {
|
||||
$table->dropColumn('dns_name');
|
||||
});
|
||||
}
|
||||
|
||||
// Der Riegel — dank der Vorabprüfung oben ohne Überraschung, aber
|
||||
// trotzdem der riskanteste Schritt dieser Migration: der einzige, der
|
||||
|
|
@ -146,14 +212,24 @@ return new class extends Migration
|
|||
DB::table('hosts')->update(['dns_name' => DB::raw('name')]);
|
||||
|
||||
foreach (DB::table('datacenters')->get() as $dc) {
|
||||
$key = 'dns.sequence.'.HostName::label($dc->code);
|
||||
|
||||
DB::table('app_settings')->updateOrInsert(
|
||||
['key' => 'dns.sequence.'.HostName::label($dc->code)],
|
||||
['key' => $key],
|
||||
[
|
||||
'value' => json_encode(max(0, (int) $dc->next_host_number - 1)),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
);
|
||||
|
||||
// App\Support\Settings hält jeden gelesenen Wert unbegrenzt im
|
||||
// Cache und leert ihn nur bei seinem eigenen set(). Ohne diese
|
||||
// Zeile schriebe die Zeile darüber die Datenbank richtig zurück,
|
||||
// während Settings::get('dns.sequence.'.$label) — läse den
|
||||
// jemand vor diesem Rückbau schon einmal — weiter den alten Wert
|
||||
// aus dem Cache liefert.
|
||||
Cache::forget('app_setting:'.$key);
|
||||
}
|
||||
|
||||
Schema::table('datacenters', function (Blueprint $table) {
|
||||
|
|
|
|||
|
|
@ -47,15 +47,24 @@ class DatabaseSeeder extends Seeder
|
|||
],
|
||||
);
|
||||
|
||||
// Datacenters — hosts + orders pick their code.
|
||||
// Datacenters — hosts + orders pick their code. next_host_number
|
||||
// steht schon auf 2: die Demoflotte unten belegt die 01 in jedem
|
||||
// Rechenzentrum selbst, und ein Seed-Lauf ist kein Onboarding, das
|
||||
// HostName::claim durchläuft und den Zähler von selbst hochzöge.
|
||||
foreach ([['fsn', 'Falkenstein', 'DE'], ['hel', 'Helsinki', 'FI']] as [$dcCode, $dcName, $dcLocation]) {
|
||||
Datacenter::updateOrCreate(['code' => $dcCode], ['name' => $dcName, 'location' => $dcLocation, 'active' => true]);
|
||||
Datacenter::updateOrCreate(
|
||||
['code' => $dcCode],
|
||||
['name' => $dcName, 'location' => $dcLocation, 'active' => true, 'next_host_number' => 2],
|
||||
);
|
||||
}
|
||||
|
||||
// Demo fleet so the operator console hosts view has content locally.
|
||||
// Namen wie die Konsole sie selbst vergäbe (App\Support\HostName) —
|
||||
// seit der Hostnamen-Vergabe ist ein getippter `pve-…`-Name genau das
|
||||
// Gegenbeispiel zu der Regel, die diese Installation gerade lernt.
|
||||
$fleet = [
|
||||
['name' => 'pve-fsn-1', 'datacenter' => 'fsn', 'public_ip' => '203.0.113.11', 'wg_ip' => '10.66.0.2'],
|
||||
['name' => 'pve-hel-1', 'datacenter' => 'hel', 'public_ip' => '203.0.113.21', 'wg_ip' => '10.66.0.3'],
|
||||
['name' => 'fsn-01', 'datacenter' => 'fsn', 'public_ip' => '203.0.113.11', 'wg_ip' => '10.66.0.2'],
|
||||
['name' => 'hel-01', 'datacenter' => 'hel', 'public_ip' => '203.0.113.21', 'wg_ip' => '10.66.0.3'],
|
||||
];
|
||||
foreach ($fleet as $host) {
|
||||
Host::updateOrCreate(['name' => $host['name']], [
|
||||
|
|
@ -75,7 +84,7 @@ class DatabaseSeeder extends Seeder
|
|||
}
|
||||
|
||||
// A live customer (matched to the portal login) with an active instance.
|
||||
$fsn1 = Host::query()->where('name', 'pve-fsn-1')->first();
|
||||
$fsn1 = Host::query()->where('name', 'fsn-01')->first();
|
||||
$berger = Customer::updateOrCreate(
|
||||
['email' => 'kunde@clupilot.local'],
|
||||
['name' => 'Kanzlei Berger', 'locale' => 'de'],
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ class DemoCustomerSeeder extends Seeder
|
|||
);
|
||||
|
||||
$host = Host::query()->where('datacenter', $datacenter->code)->first()
|
||||
?? Host::factory()->create(['datacenter' => $datacenter->code, 'name' => 'pve-fsn-01', 'status' => 'active']);
|
||||
?? Host::factory()->create(['datacenter' => $datacenter->code, 'name' => 'fsn-01', 'status' => 'active']);
|
||||
|
||||
$order = Order::firstOrCreate(
|
||||
['customer_id' => $customer->id, 'type' => 'plan'],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,872 @@
|
|||
# Hostnamen-Vergabe — Umsetzungsplan
|
||||
|
||||
> **Für ausführende Agenten:** ERFORDERLICHE UNTER-SKILL: `superpowers:subagent-driven-development` (empfohlen) oder `superpowers:executing-plans`, um diesen Plan Aufgabe für Aufgabe umzusetzen. Die Schritte benutzen Checkbox-Syntax (`- [ ]`) zur Verfolgung.
|
||||
|
||||
**Ziel:** CluPilot vergibt den Hostnamen systematisch (`<rz>-<nn>`) an genau einer Stelle; das Namensfeld beim Anlegen entfällt, und Maschine, Proxmox-Node, DNS, `/etc/hosts` und Konsole benutzen denselben Namen.
|
||||
|
||||
**Architektur:** Eine neue Quelle `App\Support\HostName` bildet Bezeichnung, Nummer und FQDN. Der Zähler wohnt als `datacenters.next_host_number` in der Datenbank und überlebt das Löschen eines Hosts. Vergeben wird beim Anlegen innerhalb der bestehenden Transaktion in `StartHostOnboarding`, unter `lockForUpdate` auf die Rechenzentrums-Zeile. Die zweite Namensspalte `hosts.dns_name` entfällt; `hosts.name` trägt den Namen und bekommt einen eindeutigen Index.
|
||||
|
||||
**Tech-Stack:** Laravel 13.8, Livewire 3 (klassenbasiert, kein Volt), Pest, Tailwind v4, MariaDB 11.4 (Tests: SQLite in-memory).
|
||||
|
||||
## Verbindliche Rahmenbedingungen
|
||||
|
||||
- **Spec:** `docs/superpowers/specs/2026-08-01-hostname-vergabe-design.md`. Bei Abweichung: die Spec gewinnt, außer wo dieser Plan eine dort getroffene Entscheidung ausdrücklich korrigiert (siehe „Abweichungen von der Spec").
|
||||
- **`MAX(nummer) + 1` ist verboten.** Der Zähler muss die Löschung eines Hosts überleben. Abnahmepunkt 3 der Spec scheitert bei jeder Umsetzung, die das rechnet.
|
||||
- **Zweistellig aufgefüllt:** `fsn-03`, ab hundert natürlich weiter `fsn-100`. `sprintf('%s-%02d', …)` leistet beides ohne Sonderfall.
|
||||
- **Die Vorschau verbraucht den Zähler nicht.** Zwei gleichzeitig geöffnete Formulare zeigen dieselbe Nummer; der zweite bekommt beim Speichern die nächste. Keine Reservierung beim Öffnen.
|
||||
- **Plattform-Zone, nicht Kundenzone:** `config('provisioning.dns.platform_zone')` (`clupilot.com`), niemals `provisioning.dns.zone` (`clupilot.cloud`).
|
||||
- **R19 (Zeitzone), R20 (Bearbeiten im Modal), R23 (kein `wire:confirm`), R24 (Modal-Höhe)** aus `CLAUDE.md` gelten unverändert. Diese Aufgabe legt kein Modal an.
|
||||
- **R22:** Eine Prüfrunde, eine Fix-Runde, ein Re-Review über den Fix-Diff. Danach werden offene Befunde geparkt.
|
||||
- **Kommentare und Nutzertexte auf Deutsch**, im Ton der umliegenden Dateien: sie erklären *warum*, nicht *was*.
|
||||
- **Testlauf:** `docker compose exec -u 1000:1000 -T app php artisan test …` aus `/home/nexxo/clupilot`.
|
||||
|
||||
## Abweichungen von der Spec (vom Besitzer entschieden, 1. August 2026)
|
||||
|
||||
1. **`hosts.dns_name` entfällt.** Die Spec schweigt dazu. Die Spalte war die unsichtbare zweite Wahrheit — sie steht in keiner einzigen Ansicht, während Liste und Detailseite `hosts.name` zeigen. Genau daran ist der Fehler aufgefallen. `name` bekommt den systematischen Namen, `dns_name` wird nach dem Übertragen gelöscht.
|
||||
2. **`PrepareBaseSystem` wird doch angefasst.** Die Spec sagt „unverändert" und meint damit Zeile 27 (`hostnamectl`). Zeile 23 baut aber einen zweiten FQDN: `{name}.{datacenter}.clupilot.net` — und `clupilot.net` steht genau an dieser einen Stelle im ganzen Repo, in keiner Konfiguration. Das ist dieselbe Krankheit eine Ebene tiefer und wird mitrepariert.
|
||||
3. **Die Detailseite zeigt IP und Domain.** Zusätzlich verlangt: der FQDN steht als anklickbarer Link in der Ausstattungstafel, damit ein Klick auf der Proxmox-Oberfläche des Hosts landet.
|
||||
|
||||
## Dateien
|
||||
|
||||
| Datei | Zuständigkeit |
|
||||
|---|---|
|
||||
| `app/Support/HostName.php` **(neu)** | Die einzige Stelle, die Bezeichnung, Nummer und FQDN bildet. `label()`, `preview()`, `claim()`, `fqdn()`. |
|
||||
| `database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php` **(neu)** | Zähler anlegen und aus dem Bestand füllen, Namen übertragen, eindeutiger Index, `dns_name` löschen. |
|
||||
| `app/Models/Datacenter.php` | `next_host_number` in `$fillable` + Cast. |
|
||||
| `app/Models/Host.php` | `dns_name` aus `$fillable`. |
|
||||
| `app/Actions/StartHostOnboarding.php` | Vergibt den Namen selbst statt ihn entgegenzunehmen. |
|
||||
| `app/Livewire/Admin/HostCreate.php` | Namensfeld entfällt; die Seite zeigt den Namen vorher an. |
|
||||
| `resources/views/livewire/admin/host-create.blade.php` | Eingabezeile wird Anzeigezeile; RZ-Auswahl wird `.live`. |
|
||||
| `app/Support/HostTakeoverCommand.php` | `fqdnFor()` verweist auf `HostName::fqdn()` — keine eigene Ableitung mehr. |
|
||||
| `app/Provisioning/Steps/Host/RegisterHostDns.php` | Vergibt nichts mehr; schreibt `$host->name`. |
|
||||
| `app/Provisioning/Steps/Host/PrepareBaseSystem.php` | `/etc/hosts` bekommt denselben FQDN wie DNS. |
|
||||
| `app/Provisioning/Jobs/PurgeHost.php` | Räumt den Eintrag unter `name` statt unter `dns_name` weg. |
|
||||
| `app/Livewire/Admin/HostDetail.php` + `.blade.php` | Ausstattungstafel zeigt den FQDN als Link. |
|
||||
| `lang/de/hosts.php`, `lang/en/hosts.php` | `field.name_hint` neu, `detail.fqdn` neu. |
|
||||
| `tests/Feature/Admin/HostNamingTest.php` **(neu)** | Die fünf Abnahmepunkte der Spec. |
|
||||
| `tests/Feature/Provisioning/HostStepsTest.php` | Der Namensvergabe-Block zieht um; DNS-Schritt wird auf `name` geprüft. |
|
||||
| `tests/Feature/Admin/HostManagementTest.php`, `HostTakeoverPageTest.php`, `Host/FilesHostTest.php`, `Provisioning/HostOnboardingEndToEndTest.php` | `dns_name` / `set('name', …)` fallen weg. |
|
||||
|
||||
---
|
||||
|
||||
## Aufgabe 1: Ein Name, an einer Stelle
|
||||
|
||||
Zähler, Vergabe, Anlegen-Seite und alle Leser wandern gemeinsam. Sie lassen sich nicht trennen: sobald `dns_name` fällt, muss jeder Leser schon auf `name` zeigen, und sobald `StartHostOnboarding` vergibt, darf `RegisterHostDns` nicht mehr vergeben. Eine Zwischenstufe hätte zwei laufende Zähler.
|
||||
|
||||
**Dateien:**
|
||||
- Neu: `app/Support/HostName.php`
|
||||
- Neu: `database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php`
|
||||
- Neu: `tests/Feature/Admin/HostNamingTest.php`
|
||||
- Ändern: `app/Models/Datacenter.php:15`, `app/Models/Host.php:25`
|
||||
- Ändern: `app/Actions/StartHostOnboarding.php:18-31`
|
||||
- Ändern: `app/Livewire/Admin/HostCreate.php:30-31,62-69`
|
||||
- Ändern: `resources/views/livewire/admin/host-create.blade.php` (Zeile „Name" + `select`)
|
||||
- Ändern: `app/Support/HostTakeoverCommand.php:109-114`
|
||||
- Ändern: `app/Provisioning/Steps/Host/RegisterHostDns.php:36-135`
|
||||
- Ändern: `app/Provisioning/Steps/Host/PrepareBaseSystem.php:23`
|
||||
- Ändern: `app/Provisioning/Jobs/PurgeHost.php:86-88`
|
||||
- Ändern: `lang/de/hosts.php:43-44`, `lang/en/hosts.php:43-44`
|
||||
- Ändern: `tests/Feature/Provisioning/HostStepsTest.php:1329-1540`
|
||||
- Ändern: `tests/Feature/Admin/HostManagementTest.php:40-95`
|
||||
- Ändern: `tests/Feature/Admin/HostTakeoverPageTest.php:96`
|
||||
- Ändern: `tests/Feature/Host/FilesHostTest.php:99`
|
||||
- Ändern: `tests/Feature/Provisioning/HostOnboardingEndToEndTest.php:50,122`
|
||||
|
||||
**Schnittstellen:**
|
||||
- Erzeugt: `App\Support\HostName::label(string $datacenterCode): string`, `::preview(string $datacenterCode): string`, `::claim(string $datacenterCode): string`, `::fqdn(string $name): string`
|
||||
- Erzeugt: Spalte `datacenters.next_host_number` (unsigned int, Vorgabe 1)
|
||||
- Erzeugt: eindeutiger Index auf `hosts.name`
|
||||
- Entfernt: Spalte `hosts.dns_name`, `RegisterHostDns::reserveName()`, `RegisterHostDns::dnsLabel()`, Einstellungsschlüssel `dns.sequence.*`
|
||||
- Geändert: `StartHostOnboarding::run()` nimmt `array{datacenter, public_ip, root_password}` — **ohne** `name`
|
||||
|
||||
---
|
||||
|
||||
- [ ] **Schritt 1: Den fehlschlagenden Test schreiben**
|
||||
|
||||
Neue Datei `tests/Feature/Admin/HostNamingTest.php`. Das sind die fünf Abnahmepunkte der Spec, wörtlich übersetzt.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Actions\StartHostOnboarding;
|
||||
use App\Livewire\Admin\HostCreate;
|
||||
use App\Models\Datacenter;
|
||||
use App\Models\Host;
|
||||
use App\Provisioning\Jobs\PurgeHost;
|
||||
use App\Support\HostName;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function () {
|
||||
Queue::fake();
|
||||
fakeServices();
|
||||
// Die Zone wird hier festgenagelt statt aus der .env dieser Maschine
|
||||
// gelesen: `provisioning.dns.platform_zone` leitet sich sonst aus APP_URL
|
||||
// ab, und dieselbe Prüfung fiele auf einer richtig konfigurierten
|
||||
// Installation um. Geprüft wird die FORM des Namens, nicht die Domain
|
||||
// dieser einen Kiste.
|
||||
config()->set('provisioning.dns.platform_zone', 'clupilot.com');
|
||||
// Die Migration von `create_datacenters_table` legt fsn und hel schon an;
|
||||
// firstOrCreate steht hier, damit der Test nicht daran hängt.
|
||||
Datacenter::query()->firstOrCreate(['code' => 'fsn'], ['name' => 'Falkenstein']);
|
||||
});
|
||||
|
||||
/** Ein Host, der so angelegt wird, wie die Konsole es tut. */
|
||||
function onboard(string $dc = 'fsn'): Host
|
||||
{
|
||||
static $n = 0;
|
||||
$n++;
|
||||
|
||||
return app(StartHostOnboarding::class)->run([
|
||||
'datacenter' => $dc,
|
||||
'public_ip' => '203.0.113.'.$n,
|
||||
'root_password' => 'supersecret',
|
||||
]);
|
||||
}
|
||||
|
||||
// --- Abnahme 1 + 2 ---
|
||||
|
||||
it('vergibt den Namen selbst, fortlaufend je Rechenzentrum', function () {
|
||||
expect(onboard()->name)->toBe('fsn-01')
|
||||
->and(onboard()->name)->toBe('fsn-02');
|
||||
});
|
||||
|
||||
it('führt je Rechenzentrum einen eigenen Zähler', function () {
|
||||
Datacenter::query()->firstOrCreate(['code' => 'hel'], ['name' => 'Helsinki']);
|
||||
|
||||
expect(onboard('fsn')->name)->toBe('fsn-01')
|
||||
->and(onboard('hel')->name)->toBe('hel-01');
|
||||
});
|
||||
|
||||
// --- Abnahme 3: der Punkt, der die Sorgfalt trägt ---
|
||||
|
||||
it('gibt die Nummer eines entfernten Hosts nie wieder aus', function () {
|
||||
onboard(); // fsn-01
|
||||
$zweiter = onboard(); // fsn-02
|
||||
onboard(); // fsn-03
|
||||
|
||||
// Die HÖCHSTE zu entfernen ist der gefährliche Fall: MAX(nummer) + 1
|
||||
// reichte fsn-03 sofort wieder heraus, und ein zwischengespeicherter Name
|
||||
// zeigte danach auf eine andere Maschine.
|
||||
$zweiter->delete();
|
||||
|
||||
expect(onboard()->name)->toBe('fsn-04');
|
||||
});
|
||||
|
||||
it('überlebt auch das Entfernen des höchsten Hosts', function () {
|
||||
onboard(); // fsn-01
|
||||
$hoechster = onboard(); // fsn-02
|
||||
$hoechster->delete();
|
||||
|
||||
expect(onboard()->name)->toBe('fsn-03');
|
||||
});
|
||||
|
||||
// --- Abnahme 5 ---
|
||||
|
||||
it('zeigt den Namen vorher an, ohne ihn zu verbrauchen', function () {
|
||||
onboard(); // fsn-01 ist weg
|
||||
|
||||
$seite = Livewire::actingAs(admin(), 'operator')->test(HostCreate::class)
|
||||
->set('datacenter', 'fsn');
|
||||
|
||||
$seite->assertSee('fsn-02')->assertSee('fsn-02.node.clupilot.com');
|
||||
|
||||
// Zweimal ansehen verbraucht nichts.
|
||||
Livewire::actingAs(admin(), 'operator')->test(HostCreate::class)
|
||||
->set('datacenter', 'fsn')
|
||||
->assertSee('fsn-02');
|
||||
|
||||
expect(HostName::preview('fsn'))->toBe('fsn-02');
|
||||
});
|
||||
|
||||
it('gibt zwei gleichzeitig geöffneten Formularen zwei verschiedene Namen', function () {
|
||||
// Beide sehen fsn-01, gespeichert wird fsn-01 und fsn-02.
|
||||
expect(HostName::preview('fsn'))->toBe('fsn-01')
|
||||
->and(HostName::preview('fsn'))->toBe('fsn-01');
|
||||
|
||||
expect(onboard()->name)->toBe('fsn-01')
|
||||
->and(onboard()->name)->toBe('fsn-02');
|
||||
});
|
||||
|
||||
// --- Der Riegel ---
|
||||
|
||||
it('lässt zwei Hosts nicht denselben Namen tragen', function () {
|
||||
onboard();
|
||||
|
||||
expect(fn () => Host::factory()->create(['name' => 'fsn-01']))
|
||||
->toThrow(Illuminate\Database\QueryException::class);
|
||||
});
|
||||
|
||||
it('gibt zwei ähnlich geschriebenen Rechenzentrums-Codes nicht denselben Namen', function () {
|
||||
// Codes von vor der Verschärfung der Prüfregel: eu_west und eu-west werden
|
||||
// zur selben DNS-Bezeichnung, führen aber getrennte Zähler. Ohne das
|
||||
// Weiterzählen bekäme der zweite eu-west-01 und liefe in den Index.
|
||||
Datacenter::factory()->create(['code' => 'eu_west', 'name' => 'EU West (alt)']);
|
||||
Datacenter::factory()->create(['code' => 'eu-west', 'name' => 'EU West']);
|
||||
|
||||
expect(onboard('eu_west')->name)->toBe('eu-west-01')
|
||||
->and(onboard('eu-west')->name)->toBe('eu-west-02');
|
||||
});
|
||||
|
||||
it('zählt ab hundert ohne Sonderfall weiter', function () {
|
||||
Datacenter::query()->where('code', 'fsn')->update(['next_host_number' => 100]);
|
||||
|
||||
expect(onboard()->name)->toBe('fsn-100');
|
||||
});
|
||||
|
||||
// --- Ein Name, überall derselbe ---
|
||||
|
||||
it('nennt den Host im DNS so, wie die Konsole ihn nennt', function () {
|
||||
$host = onboard();
|
||||
|
||||
expect(HostName::fqdn($host->name))->toBe('fsn-01.node.clupilot.com')
|
||||
->and(App\Support\HostTakeoverCommand::fqdnFor($host))
|
||||
->toBe(HostName::fqdn($host->name));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Schritt 2: Testlauf, der scheitern muss**
|
||||
|
||||
```bash
|
||||
docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Admin/HostNamingTest.php
|
||||
```
|
||||
|
||||
Erwartet: FAIL — `Class "App\Support\HostName" not found`.
|
||||
|
||||
- [ ] **Schritt 3: `App\Support\HostName` anlegen**
|
||||
|
||||
Neue Datei `app/Support/HostName.php`:
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Models\Datacenter;
|
||||
use App\Models\Host;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Der Name eines Hosts — an dieser einen Stelle gebildet.
|
||||
*
|
||||
* Vorher gab es zwei: den getippten in `hosts.name`, den die Konsole zeigte,
|
||||
* und den aus Rechenzentrum und Nummer gebauten in `hosts.dns_name`, den das
|
||||
* DNS führte. Nur der zweite löste auf, und er stand in keiner einzigen
|
||||
* Ansicht. Der Betreiber rief den Namen auf, den er selbst vergeben hatte, und
|
||||
* stand vor einer Seite, die nicht lädt.
|
||||
*
|
||||
* Jetzt vergibt CluPilot ihn: `<rz>-<nn>`, fortlaufend je Rechenzentrum und
|
||||
* niemals wiederverwendet. Maschine, Proxmox-Node, DNS, /etc/hosts und Konsole
|
||||
* benutzen denselben.
|
||||
*/
|
||||
final class HostName
|
||||
{
|
||||
/**
|
||||
* Der Name, den der nächste Host in diesem Rechenzentrum bekäme.
|
||||
*
|
||||
* LIEST den Zähler, verbraucht ihn nicht: die Anlegen-Seite zeigt den Namen,
|
||||
* bevor gespeichert wird. Zwei gleichzeitig geöffnete Formulare zeigen
|
||||
* deshalb beide dieselbe Nummer, und der zweite bekommt beim Speichern die
|
||||
* nächste. Eine Reservierung beim Öffnen wäre die schlechtere Antwort — ein
|
||||
* abgebrochenes Formular hinterließe eine Lücke im Zähler, die niemand
|
||||
* wieder auffüllt.
|
||||
*/
|
||||
public static function preview(string $datacenterCode): string
|
||||
{
|
||||
$from = (int) (Datacenter::query()
|
||||
->where('code', $datacenterCode)
|
||||
->value('next_host_number') ?? 1);
|
||||
|
||||
return self::free(self::label($datacenterCode), $from)[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Vergibt den Namen und verbraucht die Nummer.
|
||||
*
|
||||
* Gehört in eine Transaktion: die Sperre auf die Rechenzentrums-Zeile hält
|
||||
* nur bis zu deren Ende, und ohne sie holten sich zwei gleichzeitige
|
||||
* Anlegen-Vorgänge dieselbe Nummer.
|
||||
*/
|
||||
public static function claim(string $datacenterCode): string
|
||||
{
|
||||
$dc = Datacenter::query()->where('code', $datacenterCode)->lockForUpdate()->first();
|
||||
|
||||
if ($dc === null) {
|
||||
throw new RuntimeException("Kein Rechenzentrum mit dem Code {$datacenterCode}.");
|
||||
}
|
||||
|
||||
[$number, $name] = self::free(self::label($datacenterCode), (int) $dc->next_host_number);
|
||||
|
||||
$dc->update(['next_host_number' => $number + 1]);
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* `fsn-03` → `fsn-03.node.clupilot.com`.
|
||||
*
|
||||
* Die PLATTFORM-Zone, nicht die Kundenzone: die zwei sind laut
|
||||
* OfficialDomains getrennt, und ein Host gehört auf die Seite der Plattform.
|
||||
*/
|
||||
public static function fqdn(string $name): string
|
||||
{
|
||||
return $name.'.node.'.config('provisioning.dns.platform_zone');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rechenzentrums-Codes von vor der Verschärfung der Prüfregel können
|
||||
* Zeichen tragen, die eine DNS-Bezeichnung nicht darf. Ein Name, den der
|
||||
* Anbieter ablehnt, ließe den Host für immer namenlos.
|
||||
*/
|
||||
public static function label(string $datacenterCode): string
|
||||
{
|
||||
$label = trim(preg_replace('/[^a-z0-9-]+/', '-', strtolower($datacenterCode)) ?? '', '-');
|
||||
|
||||
return $label !== '' ? $label : 'node';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ab dieser Nummer aufwärts der erste freie Name.
|
||||
*
|
||||
* Der Zähler allein reicht nicht: zwei Alt-Codes (`eu_west` und `eu-west`)
|
||||
* werden zur selben Bezeichnung und führen doch getrennte Zähler. Ohne
|
||||
* dieses Weiterzählen bekäme der zweite `eu-west-01` und liefe in den
|
||||
* eindeutigen Index — ein Abbruch beim Anlegen statt eines Namens.
|
||||
*
|
||||
* Nach unten geht es dabei nie: eine entfernte `fsn-02` wird nicht wieder
|
||||
* vergeben, weil der Zähler längst darüber steht. Dieses Weiterzählen ist
|
||||
* der Riegel, nicht der Zähler.
|
||||
*
|
||||
* `%02d` füllt zweistellig auf und wächst ab hundert von selbst weiter.
|
||||
*
|
||||
* @return array{0: int, 1: string}
|
||||
*/
|
||||
private static function free(string $label, int $from): array
|
||||
{
|
||||
$number = max(1, $from);
|
||||
|
||||
while (Host::query()->where('name', sprintf('%s-%02d', $label, $number))->exists()) {
|
||||
$number++;
|
||||
}
|
||||
|
||||
return [$number, sprintf('%s-%02d', $label, $number)];
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Schritt 4: Die Migration schreiben**
|
||||
|
||||
Neue Datei `database/migrations/2026_08_04_090000_clupilot_vergibt_die_hostnamen.php`. Der Dateiname sortiert hinter `2026_08_03_140000_say_where_a_proxy_host_came_from.php`, die bisher letzte.
|
||||
|
||||
```php
|
||||
<?php
|
||||
|
||||
use App\Support\HostName;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
/**
|
||||
* CluPilot vergibt die Hostnamen, nicht der Betreiber.
|
||||
*
|
||||
* Vorher führte diese Installation zwei Namen für dieselbe Maschine: den
|
||||
* getippten in `name` (den die Konsole zeigte) und den systematischen in
|
||||
* `dns_name` (den das DNS führte). Nur der zweite löste auf, und er stand in
|
||||
* keiner Ansicht.
|
||||
*
|
||||
* Der systematische gewinnt und wird DER Name. `pve-fns-1` heißt danach
|
||||
* `fsn-01` — die ZEILE wird umbenannt, die Maschine nicht: ein Proxmox-Node
|
||||
* lässt sich nachträglich nur mühsam umbenennen, und dass er anders heißt, ist
|
||||
* auf der Detailseite als eigenes Feld sichtbar.
|
||||
*
|
||||
* Der Zähler zieht von `app_settings` an die Rechenzentrums-Zeile um. Er darf
|
||||
* nicht aus den vorhandenen Hosts abgeleitet werden: `MAX(nummer) + 1` fällt
|
||||
* zurück, sobald ein Host entfernt wird, und die nächste Maschine bekäme den
|
||||
* Namen, der noch in Protokollen, Sicherungen und DNS-Zwischenspeichern steht.
|
||||
*/
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('datacenters', function (Blueprint $table) {
|
||||
$table->unsignedInteger('next_host_number')->default(1)->after('code');
|
||||
});
|
||||
|
||||
// Der systematische Name wird der Name. Hosts ohne DNS-Namen (noch im
|
||||
// Onboarding, nie so weit gekommen) behalten ihren — sie tragen keine
|
||||
// Nummer, die sich übertragen ließe, und das Weiterzählen in
|
||||
// HostName::free() geht an ihnen vorbei.
|
||||
DB::table('hosts')
|
||||
->whereNotNull('dns_name')
|
||||
->where('dns_name', '<>', '')
|
||||
->update(['name' => DB::raw('dns_name')]);
|
||||
|
||||
foreach (DB::table('datacenters')->get() as $dc) {
|
||||
$label = HostName::label($dc->code);
|
||||
|
||||
// Was schon vergeben ist — der Boden, unter den der Zähler nie darf.
|
||||
$inUse = DB::table('hosts')
|
||||
->where('name', 'like', $label.'-%')
|
||||
->pluck('name')
|
||||
->map(fn (string $name) => (int) substr($name, strrpos($name, '-') + 1))
|
||||
->max() ?? 0;
|
||||
|
||||
// Und was der alte Zähler schon ausgegeben HATTE. Ohne diesen Wert
|
||||
// ginge die Zusage „niemals wiederverwendet" beim Umzug verloren:
|
||||
// ein Host, der angelegt und wieder entfernt wurde, steht in keiner
|
||||
// Zeile mehr, aber sein Name steht noch in den Protokollen.
|
||||
$carried = (int) (json_decode(
|
||||
(string) DB::table('app_settings')->where('key', 'dns.sequence.'.$label)->value('value'),
|
||||
true,
|
||||
) ?? 0);
|
||||
|
||||
DB::table('datacenters')->where('id', $dc->id)
|
||||
->update(['next_host_number' => max($inUse, $carried) + 1]);
|
||||
}
|
||||
|
||||
// Der alte Zähler geht mit. Eine tote Einstellung, die noch wie eine
|
||||
// Quelle aussieht, ist genau das Problem, das diese Migration behebt.
|
||||
DB::table('app_settings')->where('key', 'like', 'dns.sequence.%')->delete();
|
||||
|
||||
// Getrennte Aufrufe: Index löschen, Spalte löschen und Index anlegen in
|
||||
// einem Blueprint bringt SQLite (Testlauf) durcheinander.
|
||||
Schema::table('hosts', function (Blueprint $table) {
|
||||
$table->dropUnique(['dns_name']);
|
||||
});
|
||||
|
||||
Schema::table('hosts', function (Blueprint $table) {
|
||||
$table->dropColumn('dns_name');
|
||||
});
|
||||
|
||||
// Ein Riegel, kein Ersatz für den Zähler.
|
||||
Schema::table('hosts', function (Blueprint $table) {
|
||||
$table->unique('name');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('hosts', function (Blueprint $table) {
|
||||
$table->dropUnique(['name']);
|
||||
});
|
||||
|
||||
Schema::table('hosts', function (Blueprint $table) {
|
||||
$table->string('dns_name')->nullable()->unique()->after('name');
|
||||
});
|
||||
|
||||
// Zurück in die zwei Namen: beide tragen ab hier denselben Wert. Die
|
||||
// getippten Namen von vorher sind fort — sie waren der Fehler.
|
||||
DB::table('hosts')->update(['dns_name' => DB::raw('name')]);
|
||||
|
||||
foreach (DB::table('datacenters')->get() as $dc) {
|
||||
DB::table('app_settings')->updateOrInsert(
|
||||
['key' => 'dns.sequence.'.HostName::label($dc->code)],
|
||||
[
|
||||
'value' => json_encode(max(0, (int) $dc->next_host_number - 1)),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Schema::table('datacenters', function (Blueprint $table) {
|
||||
$table->dropColumn('next_host_number');
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Schritt 5: Die Modelle nachziehen**
|
||||
|
||||
`app/Models/Datacenter.php` — Zeile 15 und der `casts()`-Rumpf:
|
||||
|
||||
```php
|
||||
protected $fillable = ['code', 'name', 'facility', 'location', 'active', 'next_host_number'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['active' => 'boolean', 'next_host_number' => 'integer'];
|
||||
}
|
||||
```
|
||||
|
||||
`app/Models/Host.php` — Zeile 25, `dns_name` fällt aus `$fillable`:
|
||||
|
||||
```php
|
||||
'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', 'dns_record_id',
|
||||
```
|
||||
|
||||
- [ ] **Schritt 6: `StartHostOnboarding` vergibt den Namen**
|
||||
|
||||
`app/Actions/StartHostOnboarding.php`, Zeilen 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.
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
# Hostnamen vergibt CluPilot, nicht der Betreiber
|
||||
|
||||
**Stand:** Entwurf, 1. August 2026
|
||||
**Auslöser:** gemessen auf `pve-fns-1`, dem ersten echten Host
|
||||
|
||||
---
|
||||
|
||||
## Das Problem, an dem es aufgefallen ist
|
||||
|
||||
Der Betreiber legte einen Host als `pve-fns-1` an. Die Konsole zeigte diesen
|
||||
Namen, `PrepareBaseSystem` setzte ihn als Hostnamen, und Proxmox übernahm ihn
|
||||
als Node-Namen.
|
||||
|
||||
`RegisterHostDns` trug aber etwas anderes ein:
|
||||
|
||||
```
|
||||
10.66.0.100 fsn-01.node.clupilot.com
|
||||
```
|
||||
|
||||
`HostTakeoverCommand::fqdnFor()` bildet den Namen **systematisch** aus
|
||||
Rechenzentrum und laufender Nummer — unabhängig davon, was eingegeben wurde.
|
||||
|
||||
Damit führt CluPilot zwei Namen für dieselbe Maschine, und **nur einer davon
|
||||
löst auf.** Der Betreiber rief den Namen auf, den er selbst vergeben hatte,
|
||||
und stand vor einer Seite, die nicht lädt. Nirgendwo in der Oberfläche stand,
|
||||
dass es einen zweiten gibt.
|
||||
|
||||
Das ist keine Anzeigeschwäche. Es sind zwei Wahrheiten über dieselbe Sache,
|
||||
und die zweite fällt erst auf, wenn jemand sie braucht.
|
||||
|
||||
## Die Entscheidung
|
||||
|
||||
**Der systematische Name gewinnt. Das Namensfeld entfällt.**
|
||||
|
||||
Der Betreiber wählt ein Rechenzentrum und trägt IP und Root-Passwort ein.
|
||||
Den Namen vergibt CluPilot:
|
||||
|
||||
```
|
||||
<rz>-<nn> fsn-03
|
||||
<rz>-<nn>.node.<zone> fsn-03.node.clupilot.com
|
||||
```
|
||||
|
||||
Ein Name, an einer Stelle gebildet, von Maschine, Proxmox-Node, DNS und
|
||||
Konsole gleichermaßen benutzt.
|
||||
|
||||
Begründung des Besitzers, wörtlich: *„das wäre sogar besser wie pve-fns-1 was
|
||||
ich eingetragen habe."* Ein selbst getippter Name ist eine Fehlerquelle ohne
|
||||
Gegenwert — er muss eindeutig sein, DNS-tauglich, und er sagt nichts, was das
|
||||
Rechenzentrum nicht schon sagt.
|
||||
|
||||
## Wie die Nummer vergeben wird
|
||||
|
||||
**Fortlaufend je Rechenzentrum, niemals wiederverwendet.**
|
||||
|
||||
Eine gelöschte `fsn-02` steht danach noch in Protokollen, in Sicherungen, in
|
||||
der Überwachungshistorie und im DNS-Zwischenspeicher. Bekäme die nächste
|
||||
Maschine denselben Namen, teilten sich zwei verschiedene Server einen Namen
|
||||
über die Zeit — und niemand könnte im Nachhinein sagen, welche gemeint war.
|
||||
|
||||
`MAX(nummer) + 1` **reicht dafür nicht**, weil `PurgeHost` die Zeile löscht und
|
||||
das Maximum damit zurückfällt. Der Zähler muss die Löschung überleben:
|
||||
|
||||
- Spalte `next_host_number` an `datacenters`, Vorgabe 1
|
||||
- Vergabe erhöht sie, Löschen eines Hosts fasst sie nicht an
|
||||
- Vergabe innerhalb der bestehenden Transaktion in `StartHostOnboarding`,
|
||||
mit Sperre auf die Rechenzentrums-Zeile (`lockForUpdate`)
|
||||
- Zusätzlich ein eindeutiger Index auf `hosts.name` — ein Riegel, kein Ersatz
|
||||
|
||||
Zweistellig aufgefüllt (`fsn-03`), ab hundert wächst es natürlich weiter
|
||||
(`fsn-100`). Kein Sonderfall nötig.
|
||||
|
||||
## Was sich ändert
|
||||
|
||||
| Ort | Heute | Danach |
|
||||
|---|---|---|
|
||||
| `Admin\HostCreate` | Feld „Name", Pflicht | entfällt; die Seite **zeigt**, welcher Name vergeben wird |
|
||||
| `StartHostOnboarding` | nimmt `name` entgegen | vergibt ihn selbst aus dem Rechenzentrum |
|
||||
| `HostTakeoverCommand::fqdnFor()` | bildet `<rz>-<nn>` eigenständig | `$host->name.'.node.'.$zone` — eine Zeile |
|
||||
| `RegisterHostDns` | eigene Ableitung | benutzt dieselbe Quelle |
|
||||
| `PrepareBaseSystem` | `hostnamectl set-hostname $host->name` | unverändert — der Name ist jetzt der richtige |
|
||||
| `datacenters` | — | Spalte `next_host_number` |
|
||||
| `hosts` | `name` frei | eindeutiger Index auf `name` |
|
||||
|
||||
`PrepareBaseSystem` bleibt bewusst unangetastet: Es setzte immer schon
|
||||
`$host->name`. Sobald der Name systematisch ist, stimmen Maschine, Node und DNS
|
||||
von selbst überein. Das ist der Beweis, dass die Reparatur an der richtigen
|
||||
Stelle sitzt — sie entfernt eine zweite Quelle, statt eine dritte einzuführen.
|
||||
|
||||
## Die Anlegen-Seite sagt den Namen vorher
|
||||
|
||||
Ein Name, der ohne Ankündigung entsteht, ist eine Überraschung. Die Seite
|
||||
zeigt ihn, sobald ein Rechenzentrum gewählt ist:
|
||||
|
||||
> Dieser Host wird **fsn-03** heißen und unter
|
||||
> `fsn-03.node.clupilot.com` erreichbar sein.
|
||||
|
||||
Vergeben wird er trotzdem erst beim Speichern — die Vorschau liest den Zähler,
|
||||
sie verbraucht ihn nicht. Zwei gleichzeitig geöffnete Formulare zeigen also
|
||||
beide `fsn-03`, und der zweite bekommt beim Speichern `fsn-04`. Das ist
|
||||
richtig so und darf nicht durch eine Reservierung „behoben" werden: ein
|
||||
abgebrochenes Formular hinterließe sonst eine Lücke im Zähler, die niemand
|
||||
wieder auffüllt.
|
||||
|
||||
## Der bestehende Host
|
||||
|
||||
`pve-fns-1` läuft, sein Proxmox-Node heißt so, und ein Node lässt sich
|
||||
nachträglich nur mühsam umbenennen.
|
||||
|
||||
**Die Zeile wird auf `fsn-01` umbenannt, die Maschine nicht.** Konsole und DNS
|
||||
stimmen damit überein; dass der Proxmox-Node anders heißt, ist sichtbar und
|
||||
schadet nicht — die Host-Detailseite führt „Node" ohnehin als eigenes Feld.
|
||||
|
||||
`datacenters.next_host_number` für `fsn` startet entsprechend bei 2.
|
||||
|
||||
## Abnahme
|
||||
|
||||
1. Host in `fsn` anlegen ohne Namensfeld → heißt `fsn-02`, DNS löst auf
|
||||
2. Zweiten anlegen → `fsn-03`, keine Kollision
|
||||
3. `fsn-02` entfernen, dritten anlegen → **`fsn-04`**, nicht `fsn-02`
|
||||
4. Auf der Maschine: `hostname` meldet denselben Namen wie das DNS
|
||||
5. Zwei Formulare gleichzeitig: beide zeigen `fsn-05`, gespeichert wird
|
||||
`fsn-05` und `fsn-06`
|
||||
|
||||
Punkt 3 ist der, der die Sorgfalt trägt. Er scheitert bei jeder Umsetzung, die
|
||||
`MAX(nummer) + 1` rechnet.
|
||||
|
||||
## Reihenfolge
|
||||
|
||||
Nach `vmbr0`, vor der vollständigen Abnahmeinstallation. Der Besitzer will die
|
||||
Namensvergabe im selben Durchlauf prüfen, in dem eine frische Maschine ohne
|
||||
einen Handgriff auf `active` geht.
|
||||
|
|
@ -1329,11 +1329,11 @@ it('marks the host active on completion', function () {
|
|||
it('publishes the name it was given, and points it at the tunnel, not at the public address', function () {
|
||||
// The zone is pinned here rather than read from the machine's own .env. It
|
||||
// used to assert the literal clupilot.com, which was true until somebody
|
||||
// set CLUPILOT_DNS_ZONE to clupilot.cloud on their installation — and then
|
||||
// this test failed on a correct configuration. What it is about is the
|
||||
// SHAPE of the name and which address it carries, not which domain this
|
||||
// particular box is configured for.
|
||||
config()->set('provisioning.dns.zone', 'clupilot.com');
|
||||
// set CLUPILOT_PLATFORM_ZONE to clupilot.cloud on their installation — and
|
||||
// then this test failed on a correct configuration. What it is about is
|
||||
// the SHAPE of the name and which address it carries, not which domain
|
||||
// this particular box is configured for.
|
||||
config()->set('provisioning.dns.platform_zone', 'clupilot.com');
|
||||
|
||||
$s = fakeServices();
|
||||
$host = Host::factory()->active()->create([
|
||||
|
|
|
|||
Loading…
Reference in New Issue