CluPilotCloud/app/Support/HostName.php

133 lines
5.2 KiB
PHP

<?php
namespace App\Support;
use App\Models\Datacenter;
use App\Models\Host;
use Illuminate\Support\Facades\DB;
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
{
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) {
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 — 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.
*
* @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)];
}
}