diff --git a/app/Models/Datacenter.php b/app/Models/Datacenter.php index 52dea9c..26b5f33 100644 --- a/app/Models/Datacenter.php +++ b/app/Models/Datacenter.php @@ -12,11 +12,11 @@ class Datacenter extends Model /** @use HasFactory<\Database\Factories\DatacenterFactory> */ use HasFactory, HasUuid; - protected $fillable = ['code', 'name', 'facility', 'location', 'active', 'next_host_number']; + protected $fillable = ['code', 'name', 'facility', 'location', 'active']; protected function casts(): array { - return ['active' => 'boolean', 'next_host_number' => 'integer']; + return ['active' => 'boolean']; } public function scopeActive(Builder $query): Builder diff --git a/app/Support/HostName.php b/app/Support/HostName.php index 805de4b..abeaf35 100644 --- a/app/Support/HostName.php +++ b/app/Support/HostName.php @@ -31,10 +31,15 @@ final class HostName * 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. + * + * Liest aus `host_name_sequences`, nicht mehr aus der Rechenzentrums-Zeile: + * der Zähler ist über den Code geführt und übersteht das Löschen und + * Neuanlegen einer Rechenzentrums-Zeile mit demselben Code (siehe die + * Migration, die ihn dorthin verschoben hat). */ public static function preview(string $datacenterCode): string { - $from = (int) (Datacenter::query() + $from = (int) (DB::table('host_name_sequences') ->where('code', $datacenterCode) ->value('next_host_number') ?? 1); @@ -44,9 +49,9 @@ final class HostName /** * 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. + * Gehört in eine Transaktion: die Sperre auf die Zeile in + * `host_name_sequences` 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 { @@ -61,15 +66,23 @@ final class HostName throw new RuntimeException('HostName::claim() muss innerhalb einer Transaktion aufgerufen werden.'); } - $dc = Datacenter::query()->where('code', $datacenterCode)->lockForUpdate()->first(); - - if ($dc === null) { + if (! Datacenter::query()->where('code', $datacenterCode)->exists()) { throw new RuntimeException("Kein Rechenzentrum mit dem Code {$datacenterCode}."); } - [$number, $name] = self::free(self::label($datacenterCode), (int) $dc->next_host_number); + // Der Zähler liegt in einer eigenen Tabelle, unabhängig von der + // Rechenzentrums-Zeile: ein gelöschtes und unter demselben Code neu + // angelegtes Rechenzentrum darf seinen Zähler nicht verlieren. + // insertOrIgnore, weil die Zeile für diesen Code schon bestehen kann + // — von einem früheren, seither gelöschten Rechenzentrum mit + // demselben Code — oder hier zum ersten Mal entsteht. + DB::table('host_name_sequences')->insertOrIgnore(['code' => $datacenterCode, 'next_host_number' => 1]); - $dc->update(['next_host_number' => $number + 1]); + $sequence = DB::table('host_name_sequences')->where('code', $datacenterCode)->lockForUpdate()->first(); + + [$number, $name] = self::free(self::label($datacenterCode), (int) $sequence->next_host_number); + + DB::table('host_name_sequences')->where('code', $datacenterCode)->update(['next_host_number' => $number + 1]); return $name; } @@ -108,11 +121,12 @@ 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 — 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 + * ZEILE in `host_name_sequences` über den ROHEN Code, und `eu_west` und + * `eu-west` sind zwei verschiedene Codes (und damit zwei verschiedene + * Zeilen dort), 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. diff --git a/app/Support/OpenWork.php b/app/Support/OpenWork.php index 6251f0d..c1efd29 100644 --- a/app/Support/OpenWork.php +++ b/app/Support/OpenWork.php @@ -70,13 +70,6 @@ final class OpenWork 'missing' => 'Die Migration ist gegen echtes MariaDB in allen Fehlerfällen geprüft — aber auf einer Wegwerf-Datenbank. Die Abnahme aus dem Entwurf (pve-fns-1 wird zu fsn-01, der fsn-Zähler springt auf 2) wurde auf dem Live-Server nie nachgesehen.', 'risk' => 'Hosts ohne DNS-Namen behalten ihren getippten Namen. Für die zeigt die Detailseite eine Adresse an, die nie jemand veröffentlicht hat.', ], - [ - 'title' => 'Ein Zähler kann durch Löschen eines Rechenzentrums zurückfallen', - 'state' => self::PARTIAL, - 'where' => 'App\\Livewire\\Admin\\ConfirmDeleteDatacenter', - 'missing' => 'Der Hostnamen-Zähler liegt auf der Rechenzentrums-Zeile. Ein leeres Rechenzentrum lässt sich löschen; wer denselben Code neu anlegt, beginnt wieder bei 01. Bewusst so entschieden — was nichts mehr hält, soll entfernbar bleiben.', - 'risk' => 'Enger Weg, aber er weicht die Zusage „eine Nummer wird nie wiederverwendet" auf.', - ], [ 'title' => 'Support-Mail geht nicht raus', 'state' => self::PARTIAL, diff --git a/database/migrations/2026_08_05_110000_der_hostnamen_zaehler_ueberlebt_das_loeschen.php b/database/migrations/2026_08_05_110000_der_hostnamen_zaehler_ueberlebt_das_loeschen.php new file mode 100644 index 0000000..ebd1e37 --- /dev/null +++ b/database/migrations/2026_08_05_110000_der_hostnamen_zaehler_ueberlebt_das_loeschen.php @@ -0,0 +1,94 @@ +-01`, obwohl dieser Name schon in + * alten Protokollen, Sicherungen und DNS-Zwischenspeichern auf eine ANDERE + * Maschine zeigt. + * + * Der Zähler zieht deshalb an einen Ort um, den das Löschen nicht mitnimmt: + * eine eigene Tabelle, geführt über den Code selbst (nicht über die id der + * Rechenzentrums-Zeile) — genau der Schlüssel, der beim Löschen und + * Neuanlegen unter demselben Code gleich bleibt. Das Löschen selbst ändert + * sich dadurch nicht: die Entscheidung, ein leeres Rechenzentrum entfernbar + * zu lassen, bleibt stehen, nur der Zähler bleibt jetzt liegen, wo das + * Löschen ihn nicht erreicht. + */ +return new class extends Migration +{ + public function up(): void + { + // hasTable-Wächter: ein zweiter Anlauf nach einem Fehlschlag weiter + // unten (am dropColumn, das echtes DDL ist und commitet, auch wenn + // die Migration insgesamt als nicht gelaufen gilt) darf nicht an + // „Table 'host_name_sequences' already exists" sterben. + if (! Schema::hasTable('host_name_sequences')) { + Schema::create('host_name_sequences', function (Blueprint $table) { + $table->string('code')->primary(); + $table->unsignedInteger('next_host_number')->default(1); + }); + } + + // Der Bestand zieht mit um — sonst fängt jedes vorhandene + // Rechenzentrum morgen wieder bei 01 an, und das wäre schlimmer als + // der Fehler, den diese Migration behebt. + // + // Hinter hasColumn, aus demselben Grund wie beim hasTable oben: ein + // zweiter Anlauf, dessen erster schon bis zum dropColumn kam, darf + // die Übertragung nicht wiederholen (und würde auch gar nicht mehr + // können — die Spalte ist dann bereits weg). + if (Schema::hasColumn('datacenters', 'next_host_number')) { + foreach (DB::table('datacenters')->get() as $dc) { + // max() statt schlicht überschreiben: ein hängender zweiter + // Anlauf kann auf einen bereits übertragenen (und seither von + // echten claim()-Aufrufen weitergezählten) Wert treffen — + // der darf nie zurückfallen. + $carried = (int) (DB::table('host_name_sequences') + ->where('code', $dc->code) + ->value('next_host_number') ?? 1); + + DB::table('host_name_sequences')->updateOrInsert( + ['code' => $dc->code], + ['next_host_number' => max($carried, (int) $dc->next_host_number)], + ); + } + + Schema::table('datacenters', function (Blueprint $table) { + $table->dropColumn('next_host_number'); + }); + } + } + + public function down(): void + { + if (! Schema::hasColumn('datacenters', 'next_host_number')) { + Schema::table('datacenters', function (Blueprint $table) { + $table->unsignedInteger('next_host_number')->default(1)->after('code'); + }); + } + + foreach (DB::table('datacenters')->get() as $dc) { + $sequence = (int) (DB::table('host_name_sequences') + ->where('code', $dc->code) + ->value('next_host_number') ?? 1); + + DB::table('datacenters')->where('id', $dc->id)->update(['next_host_number' => $sequence]); + } + + // Rechenzentren ohne eigene Zeile mehr (längst gelöscht, aber ihr + // Code hat noch eine Sequenz-Zeile) verlieren beim Rückbau ihren + // Zähler — das ist unvermeidlich: die Spalte, in die er zurück soll, + // gehört an eine Rechenzentrums-Zeile, und die gibt es nicht mehr. + Schema::dropIfExists('host_name_sequences'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 7716445..b253d60 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -11,6 +11,7 @@ use App\Models\Order; use App\Models\ProvisioningRun; use App\Models\User; use Illuminate\Database\Seeder; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Hash; class DatabaseSeeder extends Seeder @@ -49,21 +50,25 @@ class DatabaseSeeder extends Seeder // Datacenters — hosts + orders pick their code. foreach ([['fsn', 'Falkenstein', 'DE'], ['hel', 'Helsinki', 'FI']] as [$dcCode, $dcName, $dcLocation]) { - $datacenter = Datacenter::updateOrCreate( + Datacenter::updateOrCreate( ['code' => $dcCode], ['name' => $dcName, 'location' => $dcLocation, 'active' => true], ); - // next_host_number gehört NICHT ins zweite Argument von - // updateOrCreate(): das schriebe bei jedem erneuten Säen auf 2 - // zurück, auch auf einer Installation, die längst weiterzählt — - // genau die Wiederverwendung, gegen die dieser ganze Umbau - // gebaut wurde. max() hält stattdessen den höheren der beiden - // Werte: frisch angelegt steht die Spalte auf ihrem - // Schema-Default 1, max(1, 2) hebt sie auf 2 (die 01 ist unten + // Der Zähler liegt in `host_name_sequences`, geführt über den + // Code (siehe HostName), nicht an der Rechenzentrums-Zeile. + // max() statt schlicht setzen: das schriebe bei jedem erneuten + // Säen auf 2 zurück, auch auf einer Installation, die längst + // weiterzählt — genau die Wiederverwendung, gegen die dieser + // ganze Umbau gebaut wurde. Frisch angelegt gibt es noch keine + // Zeile (Vorgabe 1), max(1, 2) hebt sie auf 2 (die 01 ist unten // durch die Demoflotte belegt); bestand die Zeile schon und // zählte höher, bleibt sie stehen. - $datacenter->update(['next_host_number' => max($datacenter->next_host_number, 2)]); + $currentSequence = (int) (DB::table('host_name_sequences')->where('code', $dcCode)->value('next_host_number') ?? 1); + DB::table('host_name_sequences')->updateOrInsert( + ['code' => $dcCode], + ['next_host_number' => max($currentSequence, 2)], + ); } // Demo fleet so the operator console hosts view has content locally. diff --git a/tests/Feature/Admin/HostNamingTest.php b/tests/Feature/Admin/HostNamingTest.php index d9578a0..3b057b2 100644 --- a/tests/Feature/Admin/HostNamingTest.php +++ b/tests/Feature/Admin/HostNamingTest.php @@ -1,6 +1,7 @@ where('code', 'fsn')->update(['next_host_number' => 100]); + // Der Zähler liegt seit dem Umzug in einer eigenen Tabelle (siehe + // clupilot_der_hostnamen_zaehler_ueberlebt_das_loeschen) — nicht mehr + // auf der Rechenzentrums-Zeile. + DB::table('host_name_sequences')->updateOrInsert(['code' => 'fsn'], ['next_host_number' => 100]); expect(onboard()->name)->toBe('fsn-100'); }); +// --- Der Zähler übersteht das Löschen des Rechenzentrums --- + +it('gibt eine Nummer nicht wieder her, nur weil das Rechenzentrum zwischendurch gelöscht wurde', function () { + // Der Bruch aus dem Register: der Zähler lag auf der + // Rechenzentrums-ZEILE. Ein leeres Rechenzentrum lässt sich löschen — das + // bleibt bewusst so —, aber wer denselben Code neu anlegte, bekam eine + // frische Zeile mit dem Schema-Default 1 und der nächste Host hieß wieder + // fsn-01, obwohl dieser Name schon in alten Protokollen, Sicherungen und + // DNS-Zwischenspeichern auf eine ANDERE Maschine zeigt. + $dc = Datacenter::factory()->create(['code' => 'nbg', 'name' => 'Nürnberg']); + + $erster = onboard('nbg'); + expect($erster->name)->toBe('nbg-01'); + + // Rechenzentrum wird leer — das ist die Voraussetzung, unter der sich + // eine Rechenzentrums-Zeile heute löschen lässt. + $erster->delete(); + + // Löschen über den echten Weg (den Livewire-Bestätigungsdialog), nicht + // über $dc->delete(): genau diese Komponente stand im Registereintrag. + Livewire::actingAs(admin(), 'operator') + ->test(ConfirmDeleteDatacenter::class, ['uuid' => $dc->uuid]) + ->call('delete'); + expect(Datacenter::query()->where('code', 'nbg')->exists())->toBeFalse(); + + // Derselbe Code, eine neue Zeile — mit eigener uuid, eigener id. + Datacenter::factory()->create(['code' => 'nbg', 'name' => 'Nürnberg']); + + // Die Zusage: eine ausgegebene Nummer wird nie wiederverwendet. Beide + // Zusicherungen tragen den Fix — ohne ihn liefe preview() wieder auf + // next_host_number=1 der frischen Zeile zurück und beide fielen rot. + expect(HostName::preview('nbg'))->toBe('nbg-02'); + + $zweiter = onboard('nbg'); + expect($zweiter->name)->toBe('nbg-02') + ->and($zweiter->name)->not->toBe($erster->name); +}); + // --- Ein Name, überall derselbe --- it('nennt den Host im DNS so, wie die Konsole ihn nennt', function () {