Hostnamen vergibt CluPilot: ein Name statt zweier, und der Zaehler ueberlebt das Loeschen

HostName::claim() haengt den Namen jetzt an die Rechenzentrums-Zeile
(next_host_number), nicht an MAX(hosts.name)+1: der Zaehler ueberlebt so
das Loeschen des zuletzt angelegten Hosts. hosts.dns_name faellt weg -
name ist ab jetzt der einzige Name, den Konsole, DNS, /etc/hosts und
Proxmox-Node teilen. RegisterHostDns veroeffentlicht nur noch, was
StartHostOnboarding beim Anlegen vergeben hat, statt selbst zu
nummerieren; PrepareBaseSystem baut den FQDN ueber HostName::fqdn()
statt ueber die nirgends konfigurierte clupilot.net.

Zwei Testdateien ausserhalb der Aufgabenliste (DatacenterTest,
HostTakeoverPageTest) setzten ->set('name', ...) auf HostCreate, das
Feld jetzt aber nicht mehr hat - im vollen Testlauf nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/neue-pakete
nexxo 2026-08-01 13:45:43 +02:00
parent ad79ce44be
commit d62a2c8ff8
21 changed files with 449 additions and 189 deletions

View File

@ -5,6 +5,7 @@ namespace App\Actions;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\Jobs\AdvanceRunJob;
use App\Support\HostName;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\DB;
@ -16,7 +17,7 @@ use Illuminate\Support\Facades\DB;
class StartHostOnboarding
{
/**
* @param array{name: string, datacenter: string, public_ip: string, root_password: string} $input
* @param array{datacenter: string, public_ip: string, root_password: string} $input
*/
public function run(array $input): Host
{
@ -24,7 +25,12 @@ class StartHostOnboarding
// leave a permanently pending host with no run.
[$host, $run] = DB::transaction(function () use ($input) {
$host = Host::create([
'name' => $input['name'],
// 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',

View File

@ -4,6 +4,7 @@ namespace App\Livewire\Admin;
use App\Actions\StartHostOnboarding;
use App\Support\HostEnrolment;
use App\Support\HostName;
use App\Support\HostTakeoverCommand;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
@ -27,9 +28,6 @@ class HostCreate extends Component
public ?string $createdName = null;
#[Validate('required|string|max:255')]
public string $name = '';
#[Validate('required|string|exists:datacenters,code,active,1')]
public string $datacenter = '';
@ -61,8 +59,15 @@ class HostCreate extends Component
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(),
]);

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'];
protected $fillable = ['code', 'name', 'facility', 'location', 'active', 'next_host_number'];
protected function casts(): array
{
return ['active' => 'boolean'];
return ['active' => 'boolean', 'next_host_number' => 'integer'];
}
public function scopeActive(Builder $query): Builder

View File

@ -22,7 +22,7 @@ class Host extends Model implements ProvisioningSubject
protected $fillable = [
'name', 'datacenter', 'cluster', 'public_ip', 'wg_ip', 'wg_pubkey',
'ssh_host_key', 'api_token_ref', 'total_gb', 'total_ram_mb', 'cpu_cores',
'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', 'dns_name', 'dns_record_id',
'cpu_weight', 'reserve_pct', 'pve_version', 'node', 'status', 'last_seen_at', 'dns_record_id',
];
protected $hidden = ['api_token_ref'];

View File

@ -80,11 +80,12 @@ class PurgeHost implements ShouldQueue
app(HetznerDnsClient::class)->deleteRecord($host->dns_record_id);
}
// The internal hostsdir entry (vpn-dns) — same reasoning as above,
// for the name every host has actually been getting since. The name
// lives on the row about to be deleted, so it has to come out first.
if (filled($host->dns_name)) {
app(HostDnsDirectory::class)->remove($host->dns_name);
// 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);
}
if (filled($host->wg_pubkey)) {

View File

@ -5,6 +5,7 @@ namespace App\Provisioning\Steps\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Ssh\RemoteShell;
use App\Support\HostName;
class PrepareBaseSystem extends HostStep
{
@ -20,7 +21,12 @@ class PrepareBaseSystem extends HostStep
$host = $this->host($run);
$this->keyLogin($this->shell, $host);
$fqdn = "{$host->name}.{$host->datacenter}.clupilot.net";
// 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);
$hostsLine = "{$host->public_ip} {$fqdn} {$host->name}";
$commands = [

View File

@ -2,24 +2,24 @@
namespace App\Provisioning\Steps\Host;
use App\Models\Host;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Dns\HostDnsDirectory;
use App\Support\Settings;
use Illuminate\Support\Facades\Cache;
use App\Support\HostName;
use Throwable;
/**
* Gives the host a name: fsn-01.node.clupilot.com.
*
* The entry points at the host's WireGuard address, NOT its public IP, and it
* is written to the vpn-dns container's --hostsdir resolvable only inside
* the tunnel rather than the public Hetzner zone. The name is for us, so an
* operator can ssh fsn-01.node. instead of memorising addresses; publishing
* it in PUBLIC DNS would hand every scanner the internal subnet and roughly
* how many hosts sit behind it, which is the one thing the whole network
* design avoids. See App\Services\Dns\HostDnsDirectory.
* The name itself is not chosen here it was assigned once, at creation
* (App\Support\HostName::claim, called from StartHostOnboarding), and this
* step only publishes it. This entry points at the host's WireGuard address,
* NOT its public IP, and it is written to the vpn-dns container's --hostsdir
* resolvable only inside the tunnel rather than the public Hetzner zone.
* The name is for us, so an operator can ssh fsn-01.node. instead of
* memorising addresses; publishing it in PUBLIC DNS would hand every scanner
* the internal subnet and roughly how many hosts sit behind it, which is the
* one thing the whole network design avoids. See App\Services\Dns\HostDnsDirectory.
*
* DNS is convenience, not a prerequisite for a working host: a failure here
* logs and moves on rather than stranding an otherwise finished onboarding.
@ -41,21 +41,12 @@ class RegisterHostDns extends HostStep
return StepResult::fail('The host has no management address to publish.');
}
// Reserved and persisted before anything is published: releasing the
// lock with only a candidate in hand let two concurrent onboardings pick
// the same name and overwrite each other's record.
$name = $host->dns_name ?: $this->reserveName($host);
// Die PLATTFORM-Zone, nicht die Kundenzone. Diese Zeile las bis hierher
// `provisioning.dns.zone` — die Zone der Kundeninstanzen —, und auf
// dieser Installation hieß ein Host damit `fsn-01.node.clupilot.cloud`.
// Der Kopfkommentar oben, die Prüfregel in Datacenters und der Test in
// ServicesTest sagen alle drei `.com`; nur diese Zeile sagte etwas
// anderes. Die zwei Zonen sind laut OfficialDomains ausdrücklich
// getrennt — eine Nextcloud ist fremde Software, bei der sich Fremde
// anmelden, und sie teilt sich deshalb keinen Cookie-Geltungsbereich mit
// dem Portal. Ein Hostname in dieser Zone hebt die Trennung nicht auf,
// aber er stellt sie in Frage, und der nächste Griff daneben ist teurer.
$fqdn = $name.'.node.'.config('provisioning.dns.platform_zone');
// 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);
@ -82,54 +73,4 @@ class RegisterHostDns extends HostStep
return StepResult::advance();
}
/**
* <datacenter>-<nn>, numbered per datacenter and never reused.
*
* The counter is stored rather than derived from the hosts that happen to
* exist: deriving it hands the highest number straight back out after that
* host is removed, so a cached name would resolve to a different machine.
*/
private function reserveName(Host $host): string
{
// Everything keys off the LABEL, not the raw code: two legacy codes can
// normalise to the same label (eu_west and eu-west), and separate
// counters would then hand both of them eu-west-01 — a unique-constraint
// failure that blocks onboarding rather than a DNS problem we can shrug off.
$label = $this->dnsLabel($host->datacenter);
return Cache::lock('host-dns-name:'.$label, 30)->block(10, function () use ($host, $label) {
$key = 'dns.sequence.'.$label;
$next = ((int) Settings::get($key, 0)) + 1;
// Never below what is already in use — a settings store that was
// wiped must not start handing out live names again. Matched on the
// name itself, so it covers every code sharing this label.
$inUse = Host::query()
->where('dns_name', 'like', $label.'-%')
->pluck('dns_name')
->map(fn (string $name) => (int) substr($name, strrpos($name, '-') + 1))
->max() ?? 0;
$next = max($next, $inUse + 1);
$name = sprintf('%s-%02d', $label, $next);
Settings::set($key, $next);
$host->update(['dns_name' => $name]); // reserved while still locked
return $name;
});
}
/**
* Datacenter codes created before the rule was tightened can hold characters
* a DNS label cannot; a name the provider rejects would leave the host
* nameless forever.
*/
private function dnsLabel(string $datacenter): string
{
$label = trim(preg_replace('/[^a-z0-9-]+/', '-', strtolower($datacenter)) ?? '', '-');
return $label !== '' ? $label : 'node';
}
}

114
app/Support/HostName.php Normal file
View File

@ -0,0 +1,114 @@
<?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)];
}
}

View File

@ -108,9 +108,7 @@ final class HostTakeoverCommand
*/
public static function fqdnFor(Host $host): string
{
$name = $host->dns_name ?: $host->name;
return $name.'.node.'.config('provisioning.dns.platform_zone');
return HostName::fqdn($host->name);
}
/** Die Präfixlänge des Tunnel-Subnetzes, damit `--wg-ip` vollständig ist. */

View File

@ -0,0 +1,116 @@
<?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');
});
}
};

View File

@ -41,7 +41,7 @@ return [
'field' => [
'name' => 'Name',
'name_hint' => 'z. B. pve-fsn-4',
'name_hint' => 'Vergibt CluPilot fortlaufend. Erreichbar unter :fqdn',
'datacenter' => 'Rechenzentrum',
'public_ip' => 'Öffentliche IP',
'root_password' => 'Root-Passwort (einmalig)',

View File

@ -41,7 +41,7 @@ return [
'field' => [
'name' => 'Name',
'name_hint' => 'e.g. pve-fsn-4',
'name_hint' => 'Assigned by CluPilot in sequence. Reachable at :fqdn',
'datacenter' => 'Datacenter',
'public_ip' => 'Public IP',
'root_password' => 'Root password (one-time)',

View File

@ -54,12 +54,8 @@
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('hosts.takeover.form_sub') }}</p>
<x-ui.panel class="mt-4">
<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>
<x-ui.row :label="__('hosts.field.datacenter')" for="datacenter">
<select id="datacenter" wire:model="datacenter"
<select id="datacenter" wire:model.live="datacenter"
class="block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink transition">
{{-- The building too, where it is known: two
entries both reading "Falkenstein (fsn)" are
@ -73,8 +69,16 @@
@error('datacenter') <p class="mt-1 text-xs text-danger">{{ $message }}</p> @enderror
</x-ui.row>
<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>
<x-ui.row :label="__('hosts.field.public_ip')" for="public_ip">
<x-ui.input name="public_ip" wire:model="public_ip" inputmode="numeric" placeholder="203.0.113.10" />
<x-ui.input name="public_ip" wire:model="public_ip" inputmode="numeric" placeholder="203.0.113.10" autofocus />
</x-ui.row>
<x-ui.row :label="__('hosts.field.root_password')" :hint="__('hosts.field.root_password_hint')" for="root_password">

View File

@ -242,7 +242,7 @@ it('offers only known datacenter codes when adding a host', function () {
// unknown code rejected
Livewire::actingAs(admin(), 'operator')
->test(HostCreate::class)
->set('name', 'pve-x')->set('datacenter', 'ghost')->set('public_ip', '203.0.113.5')->set('root_password', 'supersecret')
->set('datacenter', 'ghost')->set('public_ip', '203.0.113.5')->set('root_password', 'supersecret')
->call('save')
->assertHasErrors(['datacenter']);
@ -258,7 +258,7 @@ it('rejects creating a host in an inactive datacenter', function () {
Livewire::actingAs(admin(), 'operator')
->test(HostCreate::class)
->set('name', 'pve-old')->set('datacenter', 'old')->set('public_ip', '203.0.113.6')->set('root_password', 'supersecret')
->set('datacenter', 'old')->set('public_ip', '203.0.113.6')->set('root_password', 'supersecret')
->call('save')
->assertHasErrors(['datacenter']);
});

View File

@ -49,7 +49,6 @@ it('creates a host and starts onboarding with an encrypted password', function (
Livewire::actingAs(admin(), 'operator')
->test(HostCreate::class)
->set('name', 'pve-fsn-7')
->set('datacenter', 'fsn')
->set('public_ip', '203.0.113.7')
->set('root_password', 'supersecret')
@ -60,7 +59,7 @@ it('creates a host and starts onboarding with an encrypted password', function (
->assertNoRedirect()
->assertSee('clupilot-bootstrap.sh');
$host = Host::query()->where('name', 'pve-fsn-7')->first();
$host = Host::query()->where('name', 'fsn-01')->first();
expect($host)->not->toBeNull()->and($host->status)->toBe('pending');
$run = ProvisioningRun::query()->where('subject_id', $host->id)->first();
@ -77,7 +76,6 @@ it('rejects a duplicate public ip', function () {
Livewire::actingAs(admin(), 'operator')
->test(HostCreate::class)
->set('name', 'pve-dup')
->set('datacenter', 'fsn')
->set('public_ip', '203.0.113.99')
->set('root_password', 'supersecret')
@ -88,11 +86,10 @@ it('rejects a duplicate public ip', function () {
it('validates the add-host form', function () {
Livewire::actingAs(admin(), 'operator')
->test(HostCreate::class)
->set('name', '')
->set('public_ip', 'not-an-ip')
->set('root_password', 'short')
->call('save')
->assertHasErrors(['name', 'public_ip', 'root_password']);
->assertHasErrors(['public_ip', 'root_password']);
});
it('retries a failed run from the detail page', function () {

View File

@ -0,0 +1,137 @@
<?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));
});

View File

@ -29,7 +29,6 @@ function createHostAs(): \Livewire\Features\SupportTesting\Testable
{
return Livewire::actingAs(Operator::factory()->role('Owner')->create(), 'operator')
->test(HostCreate::class)
->set('name', 'pve-fsn-2')
->set('datacenter', 'fsn')
->set('public_ip', '198.51.45.9')
->set('root_password', 'ein-langes-kennwort')
@ -93,7 +92,7 @@ it('keeps host names out of the customer zone', function () {
config()->set('provisioning.dns.zone', 'clupilot.cloud');
config()->set('provisioning.dns.platform_zone', 'clupilot.com');
$host = Host::factory()->create(['dns_name' => 'fsn-01']);
$host = Host::factory()->create(['name' => 'fsn-01']);
expect(App\Support\HostTakeoverCommand::fqdnFor($host))
->toBe('fsn-01.node.clupilot.com');
@ -136,7 +135,9 @@ it('shows a code that actually resolves to the new host', function () {
preg_match('/--code ([A-Za-z0-9]+)/', $command, $matches);
expect(HostEnrolment::claim($matches[1])?->name)->toBe('pve-fsn-2');
// Der Name kommt jetzt von CluPilot selbst, nicht mehr vom Formular — der
// erste Host in diesem (frischen) Testlauf für fsn heißt fsn-01.
expect(HostEnrolment::claim($matches[1])?->name)->toBe('fsn-01');
});
/**
@ -169,12 +170,11 @@ it('keeps the whole procedure visible after the host exists', function () {
it('refuses to create a host without the permission', function () {
Livewire::actingAs(Operator::factory()->create(), 'operator')
->test(HostCreate::class)
->set('name', 'pve-fsn-3')
->set('datacenter', 'fsn')
->set('public_ip', '198.51.45.10')
->set('root_password', 'ein-langes-kennwort')
->call('save')
->assertForbidden();
expect(Host::query()->where('name', 'pve-fsn-3')->exists())->toBeFalse();
expect(Host::query()->where('public_ip', '198.51.45.10')->exists())->toBeFalse();
});

View File

@ -96,7 +96,7 @@ it('cannot be walked out of its directory', function () {
* Die kopierte Zeile holt von diesem Hostnamen und legt den Code bei.
*/
it('points the command line at the files host, with the code', function () {
$host = Host::factory()->create(['dns_name' => 'fsn-01']);
$host = Host::factory()->create(['name' => 'fsn-01']);
$enrolment = HostEnrolment::issueWithKeys($host);
$command = HostTakeoverCommand::for($host, $enrolment);

View File

@ -48,7 +48,6 @@ it('drives a fresh host all the way to active (mocked)', function () {
$s['pve']->templateVmids[] = 9000; // …and it really is a template (template: 1)
$host = app(StartHostOnboarding::class)->run([
'name' => 'pve-fsn-9',
'datacenter' => 'fsn',
'public_ip' => '203.0.113.9',
'root_password' => 'rootpw',
@ -120,7 +119,6 @@ it('does not duplicate external resources when a step re-runs after a crash', fu
$s['pve']->templateVmids[] = 9000; // …and it really is a template (template: 1)
$host = app(StartHostOnboarding::class)->run([
'name' => 'pve-fsn-10',
'datacenter' => 'fsn',
'public_ip' => '203.0.113.10',
'root_password' => 'rootpw',

View File

@ -1340,14 +1340,14 @@ it('gives the host a name that points at the tunnel, not at its public address',
'datacenter' => 'fsn',
'wg_ip' => '10.66.0.11',
'public_ip' => '203.0.113.11',
'dns_name' => null,
'name' => 'fsn-01',
]);
$run = ProvisioningRun::factory()->forHost($host)->create(['pipeline' => 'host']);
$result = (new RegisterHostDns($s['hostDns']))->execute($run);
expect($result->type)->toBe('advance')
->and($host->fresh()->dns_name)->toBe('fsn-01');
->and($host->fresh()->name)->toBe('fsn-01');
// The management address: publishing a Proxmox host's public IP would hand
// every scanner a target. Written to the internal hostsdir, not upserted
@ -1364,7 +1364,7 @@ it('never reaches the public Hetzner DNS client for a host name', function () {
// step and confirming this test goes red before reverting it.
$s = fakeServices();
$host = Host::factory()->active()->create([
'datacenter' => 'fsn', 'wg_ip' => '10.66.0.12', 'dns_name' => null,
'datacenter' => 'fsn', 'wg_ip' => '10.66.0.12', 'name' => 'fsn-02',
]);
$run = ProvisioningRun::factory()->forHost($host)->create(['pipeline' => 'host']);
@ -1374,36 +1374,10 @@ it('never reaches the public Hetzner DNS client for a host name', function () {
->and($s['dns']->values)->toBe([]);
});
it('numbers hosts per datacenter and never reuses a number', function () {
$s = fakeServices();
$names = [];
foreach (['fsn', 'fsn', 'hel'] as $i => $dc) {
$host = Host::factory()->active()->create([
'datacenter' => $dc, 'wg_ip' => '10.66.0.'.(20 + $i), 'dns_name' => null,
]);
$run = ProvisioningRun::factory()->forHost($host)->create(['pipeline' => 'host']);
(new RegisterHostDns($s['hostDns']))->execute($run);
$names[] = $host->fresh()->dns_name;
}
expect($names)->toBe(['fsn-01', 'fsn-02', 'hel-01']);
// Removing the HIGHEST one is the dangerous case: deriving the number from
// live hosts would hand fsn-02 straight back out, and a cached name would
// then point at a different machine.
Host::query()->where('dns_name', 'fsn-02')->delete();
$host = Host::factory()->active()->create(['datacenter' => 'fsn', 'wg_ip' => '10.66.0.30', 'dns_name' => null]);
$run = ProvisioningRun::factory()->forHost($host)->create(['pipeline' => 'host']);
(new RegisterHostDns($s['hostDns']))->execute($run);
expect($host->fresh()->dns_name)->toBe('fsn-03');
});
it('retries a failed DNS registration before giving up on the name', function () {
$s = fakeServices();
$s['hostDns']->failWrite = true;
$host = Host::factory()->active()->create(['datacenter' => 'fsn', 'wg_ip' => '10.66.0.40', 'dns_name' => null]);
$host = Host::factory()->active()->create(['datacenter' => 'fsn', 'wg_ip' => '10.66.0.40', 'name' => 'fsn-04']);
$run = ProvisioningRun::factory()->forHost($host)->create(['pipeline' => 'host', 'attempt' => 0]);
$step = new RegisterHostDns($s['hostDns']);
@ -1422,12 +1396,12 @@ it("takes the host's internal DNS entry with it when the host is purged", functi
$s = fakeServices();
$host = Host::factory()->active()->create([
'datacenter' => 'fsn', 'wg_ip' => '10.66.0.50', 'dns_name' => null,
'datacenter' => 'fsn', 'wg_ip' => '10.66.0.50', 'name' => 'fsn-05',
]);
$run = ProvisioningRun::factory()->forHost($host)->create(['pipeline' => 'host']);
(new RegisterHostDns($s['hostDns']))->execute($run);
$name = $host->fresh()->dns_name;
$name = $host->name;
expect($s['hostDns']->ips)->toHaveKey($name);
(new PurgeHost($host->uuid))->handle();
@ -1441,7 +1415,7 @@ it('keeps the host until its internal DNS entry is really gone', function () {
$s = fakeServices();
$host = Host::factory()->active()->create([
'datacenter' => 'hel', 'wg_ip' => '10.66.0.60', 'dns_name' => null,
'datacenter' => 'hel', 'wg_ip' => '10.66.0.60', 'name' => 'hel-01',
]);
$run = ProvisioningRun::factory()->forHost($host)->create(['pipeline' => 'host']);
(new RegisterHostDns($s['hostDns']))->execute($run);
@ -1473,7 +1447,7 @@ it('still removes a legacy public Hetzner record when a pre-fix host is purged',
$recordId = $s['dns']->upsertRecord('fsn-legacy.node.clupilot.cloud', 'A', '10.66.0.55');
$host = Host::factory()->active()->create([
'datacenter' => 'fsn', 'wg_ip' => '10.66.0.55',
'dns_name' => 'fsn-legacy', 'dns_record_id' => $recordId,
'name' => 'fsn-legacy', 'dns_record_id' => $recordId,
]);
expect($s['dns']->records)->toHaveKey('fsn-legacy.node.clupilot.cloud');
@ -1483,48 +1457,11 @@ it('still removes a legacy public Hetzner record when a pre-fix host is purged',
expect($s['dns']->records)->not->toHaveKey('fsn-legacy.node.clupilot.cloud');
});
it('builds a valid DNS label even from an awkward datacenter code', function () {
$s = fakeServices();
// Inserted directly: the console now rejects this shape, but rows created
// before the rule was tightened still exist.
Datacenter::factory()->create(['code' => 'eu_west', 'name' => 'EU West']);
$host = Host::factory()->active()->create([
'datacenter' => 'eu_west', 'wg_ip' => '10.66.0.70', 'dns_name' => null,
]);
$run = ProvisioningRun::factory()->forHost($host)->create(['pipeline' => 'host']);
(new RegisterHostDns($s['hostDns']))->execute($run);
$name = $host->fresh()->dns_name;
expect($name)->toBe('eu-west-01')
->and($name)->toMatch('/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/');
});
it('does not hand the same name to two datacenter codes that look alike', function () {
$s = fakeServices();
Datacenter::factory()->create(['code' => 'eu_west', 'name' => 'EU West (alt)']);
Datacenter::factory()->create(['code' => 'eu-west', 'name' => 'EU West']);
$names = [];
foreach (['eu_west', 'eu-west'] as $i => $dc) {
$host = Host::factory()->active()->create([
'datacenter' => $dc, 'wg_ip' => '10.66.0.'.(80 + $i), 'dns_name' => null,
]);
$run = ProvisioningRun::factory()->forHost($host)->create(['pipeline' => 'host']);
(new RegisterHostDns($s['hostDns']))->execute($run);
$names[] = $host->fresh()->dns_name;
}
// Separate counters per raw code would have produced eu-west-01 twice and
// failed the unique constraint mid-onboarding.
expect($names)->toBe(['eu-west-01', 'eu-west-02']);
});
it('sees an internal DNS entry registered while the purge was waiting', function () {
$s = fakeServices();
$host = Host::factory()->active()->create([
'datacenter' => 'fsn', 'wg_ip' => '10.66.0.90', 'dns_name' => null,
'datacenter' => 'fsn', 'wg_ip' => '10.66.0.90', 'name' => 'fsn-06',
]);
$purge = new PurgeHost($host->uuid);
@ -1532,7 +1469,7 @@ it('sees an internal DNS entry registered while the purge was waiting', function
// copy it loaded first still says there is no entry to remove.
$run = ProvisioningRun::factory()->forHost($host)->create(['pipeline' => 'host']);
(new RegisterHostDns($s['hostDns']))->execute($run);
$name = $host->fresh()->dns_name;
$name = $host->name;
$purge->handle();

View File

@ -366,7 +366,7 @@ it('treats removing a name that was never written as done (FileHostDnsDirectory)
$dir = sys_get_temp_dir().'/clupilot-dns-hosts-test-'.uniqid();
config()->set('provisioning.dns.hosts_dir', $dir);
// Mirrors PurgeHost's guard (filled($host->dns_name)) not always holding
// Mirrors PurgeHost's guard (filled($host->name)) not always holding
// — a host that never got a name still has to purge cleanly.
(new FileHostDnsDirectory)->remove('never-written');
})->throwsNoExceptions();