Der Hostnamen-Zähler übersteht jetzt das Löschen eines Rechenzentrums

Der Zähler lag auf der Rechenzentrums-ZEILE (next_host_number). Ein leeres
Rechenzentrum liess sich löschen - bewusst so entschieden, was nichts mehr
hält, soll entfernbar bleiben -, aber die Zeile nahm den Zähler mit. Wer
denselben Code neu anlegte, bekam eine frische Zeile mit dem Schema-Default
1, und der nächste Host hiess wieder <code>-01, obwohl dieser Name schon in
alten Protokollen, Sicherungen und DNS-Zwischenspeichern auf eine ANDERE
Maschine zeigt.

Der Zähler zieht deshalb in eine eigene Tabelle host_name_sequences um,
geführt über den rohen Code statt über die id der Rechenzentrums-Zeile.
ConfirmDeleteDatacenter bleibt unangetastet: das Löschen war nie das
Problem, nur was es mitriss. Die Migration überträgt den Bestand (fsn/hel)
vor dem Löschen der alten Spalte und ist gegen echtes MariaDB in beide
Richtungen geprüft (hoch, Werte kontrolliert, zurück, wieder hoch).

Neuer Test in HostNamingTest stellt den ganzen Bruch nach: Rechenzentrum
anlegen, Host vergeben, Host entfernen, über den echten Bestätigungsdialog
löschen, mit demselben Code neu anlegen - der nächste Name bleibt fortlaufend
statt wieder bei 01 zu beginnen. Gegen den unveränderten Code lief er rot
(HostName::preview lieferte nbg-01 statt nbg-02).

Registereintrag "Ein Zähler kann durch Löschen eines Rechenzentrums
zurückfallen" gestrichen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 15:11:58 +02:00
parent 507636f38f
commit fd216be623
6 changed files with 182 additions and 33 deletions

View File

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

View File

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

View File

@ -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,

View File

@ -0,0 +1,94 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Der Hostnamen-Zähler überlebt das Löschen eines Rechenzentrums.
*
* Vorher lag `next_host_number` auf der Rechenzentrums-ZEILE. Ein leeres
* Rechenzentrum ließ sich löschen bewusst so entschieden, was nichts mehr
* hält, soll entfernbar bleiben , aber die Zeile nahm den Zähler mit. Wer
* denselben Code neu anlegte, bekam eine frische Zeile mit dem Schema-Default
* 1, und der nächste Host hieß wieder `<code>-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');
}
};

View File

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

View File

@ -1,6 +1,7 @@
<?php
use App\Actions\StartHostOnboarding;
use App\Livewire\Admin\ConfirmDeleteDatacenter;
use App\Livewire\Admin\HostCreate;
use App\Livewire\Admin\HostDetail;
use App\Models\Datacenter;
@ -8,6 +9,7 @@ use App\Models\Host;
use App\Support\HostName;
use App\Support\HostTakeoverCommand;
use Illuminate\Database\QueryException;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Queue;
use Livewire\Livewire;
@ -127,11 +129,52 @@ it('fällt auf eine gültige Bezeichnung zurück, wenn der Code keine hergibt',
});
it('zählt ab hundert ohne Sonderfall weiter', function () {
Datacenter::query()->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 () {