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
parent
507636f38f
commit
fd216be623
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue