diff --git a/VERSION b/VERSION index 4ba0b73..cac2dec 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.87 +1.3.88 diff --git a/app/Models/PlanVersion.php b/app/Models/PlanVersion.php index 92b3fca..8b0681d 100644 --- a/app/Models/PlanVersion.php +++ b/app/Models/PlanVersion.php @@ -173,4 +173,36 @@ class PlanVersion extends Model 'features' => $this->features ?? [], ]; } + + /** + * Every VM template a currently sellable version points at. + * + * Published versions whose window has not CLOSED — deliberately not + * scopeAvailable(): a version scheduled to launch next week is not + * "available" yet, and leaving its template out would move the gap to its + * first order, which is the one place nobody is watching. A closed window + * is different: nobody can buy it any more, so its template is nobody's + * problem. + * + * Lives here because three callers ask exactly this — BuildVmTemplate + * builds them, VerifyVmTemplate refuses to finish a host without them, and + * VmTemplateCheck answers the same question before a purchase. Three copies + * of the window logic would drift, and the one that drifted would be found + * by a customer. + * + * @return array + */ + public static function requiredTemplateVmids(): array + { + return static::query() + ->whereNotNull('published_at') + ->whereNotNull('template_vmid') + ->where(fn ($q) => $q->whereNull('available_until')->orWhere('available_until', '>', now())) + ->pluck('template_vmid') + ->map(fn ($vmid) => (int) $vmid) + ->unique() + ->sort() + ->values() + ->all(); + } } diff --git a/app/Provisioning/Steps/Host/BuildVmTemplate.php b/app/Provisioning/Steps/Host/BuildVmTemplate.php new file mode 100644 index 0000000..30ec1a5 --- /dev/null +++ b/app/Provisioning/Steps/Host/BuildVmTemplate.php @@ -0,0 +1,344 @@ +host($run); + $required = PlanVersion::requiredTemplateVmids(); + + // An installation that takes over its first host before it publishes a + // plan needs no template. The same real state VerifyVmTemplate lets + // through, and inventing a requirement here would contradict it. + if ($required === []) { + return StepResult::advance(); + } + + try { + $missing = $this->templatesStillMissing($host, $required); + + if ($missing === []) { + return StepResult::advance(); + } + } catch (Throwable $e) { + // VerifyProxmoxApi passed one step ago; a hiccup here is transient. + // isTemplate() throws rather than answering "absent" for anything + // short of a successful listing, precisely so this branch — and not + // a destructive rebuild — is where an outage lands. + return StepResult::retry(15, 'could not ask Proxmox about the VM templates: '.$e->getMessage()); + } + + $this->keyLogin($this->shell, $host); + $status = $this->readStatus(); + + // Nothing on the host is cleaned up when a run ends, so a status file + // can be older than this run. Read as this run's own, a leftover `ok` + // says "the build I started finished without producing what I asked + // for" — and fails a host that only needed a build to be started. That + // is the ordinary case the day the catalogue gains a second template. + // + // The deadline in the run context is the only thing that distinguishes + // them: it is written when THIS run starts a build, and cleared again + // by giveUp(). + if ($run->context('template_build_deadline') === null) { + return $this->adoptOrStart($run, $missing, $status); + } + + return match ($status['state']) { + '' => $this->giveUp( + $run, + 'The template build was started but left no status behind, so it never really began. '. + 'Check the free space and '.self::WORK_DIR.' on the host, then retry.' + ), + 'running' => $this->whileRunning($run, $status), + 'ok' => $this->giveUp( + $run, + 'The template build reported success, but VMID '.implode(', ', $missing). + ' is still not a template through the Proxmox API — so a clone would fail. '. + 'Check '.self::WORK_DIR.'/build.log on the host.' + ), + 'failed' => $this->giveUp($run, 'The template build failed: '.($status['note'] ?: 'no reason recorded').'.'), + default => $this->giveUp($run, 'The template build left an unreadable state ("'.$status['state'].'").'), + }; + } + + /** + * What to do about a status this run did not write. + * + * Everything except a LIVE build is simply overwritten by starting a new + * one: a finished or failed verdict from an earlier run answers a question + * nobody asked here, and its files are replaced by the start command. + * + * A live build is the one exception, and it has to be, or the collision + * arrives from the other side: two drivers on one build.qcow2, both calling + * `qm destroy --purge` and `qm create` on the same VMIDs. It is building the + * same thing this run wants, so it is adopted — with a deadline of its own, + * so an adopted build cannot be polled forever either. + * + * @param array $missing + * @param array{state:string, alive:string, phase:string, note:string} $status + */ + private function adoptOrStart(ProvisioningRun $run, array $missing, array $status): StepResult + { + if ($status['state'] === 'running' && $status['alive'] === 'yes') { + $run->mergeContext([ + 'template_build_deadline' => now()->addMinutes(self::DEADLINE_MINUTES)->toIso8601String(), + ]); + + return StepResult::poll(30, 'building the VM template: '.($status['phase'] ?: 'running')); + } + + return $this->start($run, $missing); + } + + /** + * The required VMIDs that are NOT already a real template on this node. + * + * A list rather than a yes/no, because that list is what gets built — and + * create_proxmox_template purges a VMID before it creates it. Handing it + * every required id when only one of them is missing destroys the healthy + * templates on the way past, and a rebuild that then fails leaves the host + * with fewer templates than it started with. + * + * isTemplate rather than vmExists, and that is the whole point of asking: + * an abandoned build leaves an ordinary VM numbered 9000 behind. Skipping + * on the strength of its existence would adopt the leftover as finished, + * and the gap would surface again at the first paid clone. + * + * @param array $required + * @return array + */ + private function templatesStillMissing(Host $host, array $required): array + { + $client = $this->pve->forHost($host); + $node = $client->listNodes()[0]['node'] ?? 'pve'; + + return array_values(array_filter( + $required, + fn (int $vmid) => ! $client->isTemplate($node, $vmid), + )); + } + + /** @param array $missing */ + private function start(ProvisioningRun $run, array $missing): StepResult + { + $this->shell->putFile(self::WORK_DIR.'/template.sh', $this->asset('lib/template.sh')); + $this->shell->putFile(self::WORK_DIR.'/template-run.sh', $this->asset('lib/template-run.sh')); + $this->shell->putFile(self::WORK_DIR.'/docker-compose.yml', $this->asset('assets/docker-compose.yml')); + + // Only what is actually missing, and only what the catalogue asks for. + // Not a number baked into the script: an installation that repoints a + // plan at another template would otherwise keep building the old one. + $this->shell->putFile(self::WORK_DIR.'/env', implode("\n", [ + '# Written by CluPilot at the start of every build. Do not edit.', + 'CLUPILOT_WORK_DIR='.self::WORK_DIR, + 'CLUPILOT_TEMPLATE_VMIDS="'.implode(' ', $missing).'"', + '', + ])); + + $run->mergeContext([ + 'template_build_deadline' => now()->addMinutes(self::DEADLINE_MINUTES)->toIso8601String(), + ]); + + // `setsid` puts the build in a session of its own, so the pid the script + // records is also its PROCESS GROUP id — which is what makes giving up + // possible later. Killing the driver alone would leave the + // virt-customize it is waiting on chewing through the image. + // + // The pid therefore has to come from the script (`$$`), not from `$!` + // here: with setsid in between, `$!` can be a process that is already + // gone. Waiting for the file closes the gap that creates — `state` is + // written by this command, so a poll arriving before the script had a + // chance to record itself would otherwise read running-but-not-alive + // and declare a healthy build dead. + $this->shell->run(implode("\n", [ + ': clupilot-template-start', + 'W='.self::WORK_DIR, + 'mkdir -p "$W"', + "printf 'running' > \"\$W/state\"", + "printf 'starting' > \"\$W/phase\"", + ': > "$W/note"', + ': > "$W/pid"', + 'nohup setsid sh "$W/template-run.sh" > "$W/build.log" 2>&1 &', + 'i=0; while [ "$i" -lt 15 ] && [ ! -s "$W/pid" ]; do sleep 1; i=$((i + 1)); done', + ])); + + return StepResult::poll(30, 'building the VM template (this takes 10–20 minutes)'); + } + + /** @param array{state:string, alive:string, phase:string, note:string} $status */ + private function whileRunning(ProvisioningRun $run, array $status): StepResult + { + if ($status['alive'] !== 'yes') { + // A reboot, an OOM kill, an operator's Ctrl-C. The file says + // running and always will — there is nobody left to change it. + return $this->giveUp( + $run, + 'The template build is no longer running, but never reported a result — it was killed or the host '. + 'restarted. Check '.self::WORK_DIR.'/build.log and retry.' + ); + } + + $deadline = $run->context('template_build_deadline'); + if ($deadline !== null && now()->greaterThan(Carbon::parse($deadline))) { + return $this->giveUp( + $run, + 'The template build passed its deadline of '.self::DEADLINE_MINUTES.' minutes while still in phase "'. + ($status['phase'] ?: 'unknown').'". Check '.self::WORK_DIR.'/build.log on the host.' + ); + } + + return StepResult::poll(30, 'building the VM template: '.($status['phase'] ?: 'running')); + } + + /** + * Fail, and leave nothing behind that would make a retry read this verdict + * again — neither the status on the host nor the deadline in the run, which + * is what startOrGiveUp() reads to tell a first visit from a lost one. + * + * The verified cloud image is deliberately kept: it is the one expensive + * thing a retry does not have to redo, and it is only ever under its final + * name once its checksum has been confirmed. + */ + private function giveUp(ProvisioningRun $run, string $reason): StepResult + { + $run->forgetContext('template_build_deadline'); + + $tail = trim($this->shell->run(': clupilot-template-log'."\n".'tail -n 15 '.self::WORK_DIR.'/build.log 2>/dev/null')->stdout); + + // Stop it BEFORE the status files go, never after. Clearing them makes + // the step retryable, and a retry that meets a build still running gets + // two of them on one build.qcow2, both purging and recreating the same + // VMIDs. The deadline path reaches here with the process very much + // alive, which is exactly the case this covers. + // + // The whole process group (`-$P`), because the driver spends its time + // waiting on a virt-customize that would otherwise carry on alone. The + // cmdline guard is against a recycled pid: this file can be minutes old, + // and killing a stranger's process group would be a far worse bug than + // the one being handled. + $this->shell->run(implode("\n", [ + ': clupilot-template-reset', + 'W='.self::WORK_DIR, + 'P=$(cat "$W/pid" 2>/dev/null)', + 'if [ -n "$P" ] && kill -0 "$P" 2>/dev/null && tr "\\0" " " < /proc/"$P"/cmdline 2>/dev/null | grep -q template-run; then', + ' kill -TERM -"$P" 2>/dev/null || kill -TERM "$P" 2>/dev/null', + ' i=0; while [ "$i" -lt 10 ] && kill -0 "$P" 2>/dev/null; do sleep 1; i=$((i + 1)); done', + ' kill -KILL -"$P" 2>/dev/null || kill -KILL "$P" 2>/dev/null', + 'fi', + 'rm -f "$W/state" "$W/pid" "$W/note"', + ])); + + return StepResult::fail($tail === '' ? $reason : $reason.' Last lines: '.$tail); + } + + /** + * The four facts about a build in flight, in one round trip. + * + * `alive` is computed on the host because that is the only place the answer + * exists: the pid means nothing here. + * + * @return array{state:string, alive:string, phase:string, note:string} + */ + private function readStatus(): array + { + $out = $this->shell->run(implode("\n", [ + ': clupilot-template-status', + 'W='.self::WORK_DIR, + 'S=$(cat "$W/state" 2>/dev/null)', + 'P=$(cat "$W/pid" 2>/dev/null)', + 'A=no', + 'if [ -n "$P" ] && kill -0 "$P" 2>/dev/null; then A=yes; fi', + "printf 'state=%s\\n' \"\$S\"", + "printf 'alive=%s\\n' \"\$A\"", + "printf 'phase=%s\\n' \"\$(head -1 \"\$W/phase\" 2>/dev/null)\"", + "printf 'note=%s\\n' \"\$(head -1 \"\$W/note\" 2>/dev/null)\"", + ]))->stdout; + + $status = ['state' => '', 'alive' => 'no', 'phase' => '', 'note' => '']; + + foreach (preg_split('/\R/', $out) ?: [] as $line) { + [$key, $value] = array_pad(explode('=', $line, 2), 2, ''); + if (array_key_exists($key, $status)) { + $status[$key] = trim($value); + } + } + + return $status; + } + + /** The shipped file, read from the repo — never rendered from a string in here. */ + private function asset(string $relative): string + { + return (string) file_get_contents(base_path('deploy/bootstrap/'.$relative)); + } +} diff --git a/app/Provisioning/Steps/Host/VerifyVmTemplate.php b/app/Provisioning/Steps/Host/VerifyVmTemplate.php index 4f41a99..e7021ac 100644 --- a/app/Provisioning/Steps/Host/VerifyVmTemplate.php +++ b/app/Provisioning/Steps/Host/VerifyVmTemplate.php @@ -40,7 +40,7 @@ class VerifyVmTemplate extends HostStep public function execute(ProvisioningRun $run): StepResult { $host = $this->host($run); - $required = $this->requiredTemplateVmids(); + $required = PlanVersion::requiredTemplateVmids(); // A catalogue with nothing published yet requires no template. This is a // real state — an installation being set up for the first time onboards @@ -56,9 +56,14 @@ class VerifyVmTemplate extends HostStep $nodes = $client->listNodes(); $node = $nodes[0]['node'] ?? 'pve'; + // isTemplate, not vmExists. `template: 1` is the property a clone + // needs; the number on its own is not. A build that died halfway + // leaves an ordinary VM numbered 9000 behind, and an existence + // check calls that fine — which puts the gap back exactly where + // this step was written to take it away from: the first paid order. $missing = array_values(array_filter( $required, - fn (int $vmid) => ! $client->vmExists($node, $vmid), + fn (int $vmid) => ! $client->isTemplate($node, $vmid), )); } catch (Throwable $e) { // The API was answering one step ago; a hiccup here is transient. @@ -67,8 +72,8 @@ class VerifyVmTemplate extends HostStep if ($missing !== []) { return StepResult::fail( - 'This host has no VM template to clone from, so it could not serve a single order. Missing on node '. - $node.': VMID '.implode(', ', $missing).', which '. + 'This host has no VM template to clone from, so it could not serve a single order. Absent on node '. + $node.', or present but not marked as a template: VMID '.implode(', ', $missing).', which '. (count($missing) === 1 ? 'a published plan version points' : 'published plan versions point').' at. '. 'Copy the golden template onto this host (or restore it from another node) and retry — otherwise the '. 'first paid order placed here fails in clone_vm, after the customer has paid.' @@ -77,30 +82,4 @@ class VerifyVmTemplate extends HostStep return StepResult::advance(); } - - /** - * Every template a currently sellable plan version points at. - * - * Published versions whose window has not closed, rather than - * PlanVersion::available() — a version scheduled to launch next week is not - * "available" yet and its template gap would then surface at its first order - * instead of here, which is the whole failure this step removes. A closed - * window is different: nobody can buy it any more, so its template is not - * this host's problem. - * - * @return array - */ - private function requiredTemplateVmids(): array - { - return PlanVersion::query() - ->whereNotNull('published_at') - ->whereNotNull('template_vmid') - ->where(fn ($q) => $q->whereNull('available_until')->orWhere('available_until', '>', now())) - ->pluck('template_vmid') - ->map(fn ($vmid) => (int) $vmid) - ->unique() - ->sort() - ->values() - ->all(); - } } diff --git a/app/Services/Proxmox/FakeProxmoxClient.php b/app/Services/Proxmox/FakeProxmoxClient.php index 3107eec..c88042a 100644 --- a/app/Services/Proxmox/FakeProxmoxClient.php +++ b/app/Services/Proxmox/FakeProxmoxClient.php @@ -197,6 +197,20 @@ class FakeProxmoxClient implements ProxmoxClient return in_array($vmid, $this->clonedVmids, true) || in_array($vmid, $this->runningVmids, true); } + /** + * Deliberately NOT derived from clonedVmids: existing and being a template + * are the two different facts the real client tells apart, and a fake that + * conflated them would let a test pass that the API would not. + * + * @var array + */ + public array $templateVmids = []; + + public function isTemplate(string $node, int $vmid): bool + { + return in_array($vmid, $this->templateVmids, true); + } + /** @var array */ public array $deletedVmids = []; diff --git a/app/Services/Proxmox/HttpProxmoxClient.php b/app/Services/Proxmox/HttpProxmoxClient.php index 7bc7fc2..feac499 100644 --- a/app/Services/Proxmox/HttpProxmoxClient.php +++ b/app/Services/Proxmox/HttpProxmoxClient.php @@ -124,6 +124,38 @@ class HttpProxmoxClient implements ProxmoxClient return $this->http()->get("/nodes/{$node}/qemu/{$vmid}/status/current")->successful(); } + /** + * Two requests, because the wrong answer here DESTROYS something. + * + * BuildVmTemplate reads `false` as "no template" and starts a build whose + * script begins with `qm destroy --purge`. So "absent" and "I could not + * ask" must never look alike — and a status code cannot tell them apart: + * Proxmox answers the config of a VM that does not exist with 500, the same + * code it uses for a node in trouble. + * + * The VM list settles absence and nothing else: a vmid missing from a + * successful listing is genuinely not on this node. Everything short of a + * successful listing throws, straight into the caller's retry path, and the + * existing template lives. + * + * The config document then settles the property, because that is where + * `template` is authoritative — the list carries it too, but a field that + * silently changed shape would make every template look like an ordinary VM + * and rebuild it on sight. + */ + public function isTemplate(string $node, int $vmid): bool + { + $vms = $this->http()->get("/nodes/{$node}/qemu")->throw()->json('data', []); + + $present = collect($vms)->contains(fn ($vm) => (int) ($vm['vmid'] ?? 0) === $vmid); + + if (! $present) { + return false; + } + + return (int) $this->http()->get("/nodes/{$node}/qemu/{$vmid}/config")->throw()->json('data.template', 0) === 1; + } + public function deleteVm(string $node, int $vmid): void { $this->http()->asForm()->delete("/nodes/{$node}/qemu/{$vmid}", ['purge' => 1])->throw(); diff --git a/app/Services/Proxmox/ProxmoxClient.php b/app/Services/Proxmox/ProxmoxClient.php index 0a341a1..cdcf598 100644 --- a/app/Services/Proxmox/ProxmoxClient.php +++ b/app/Services/Proxmox/ProxmoxClient.php @@ -72,6 +72,17 @@ interface ProxmoxClient /** Whether a VM with this id already exists on the node. */ public function vmExists(string $node, int $vmid): bool; + /** + * Whether that id is a real TEMPLATE (`template: 1`), not merely a VM. + * + * A separate question from vmExists(), and the one that actually matters + * before a clone: an abandoned template build leaves an ordinary virtual + * machine numbered 9000 behind, and everything that asked "does it exist" + * called that fine — right up to the first paid order, which is where + * clone_vm then failed. + */ + public function isTemplate(string $node, int $vmid): bool; + public function deleteVm(string $node, int $vmid): void; public function guestAgentPing(string $node, int $vmid): bool; diff --git a/app/Services/Proxmox/VmTemplateCheck.php b/app/Services/Proxmox/VmTemplateCheck.php index c6c0ceb..24f6597 100644 --- a/app/Services/Proxmox/VmTemplateCheck.php +++ b/app/Services/Proxmox/VmTemplateCheck.php @@ -24,11 +24,7 @@ final class VmTemplateCheck /** @return array */ public function run(): array { - $required = PlanVersion::query() - ->whereNotNull('published_at') - ->whereNotNull('template_vmid') - ->where(fn ($q) => $q->whereNull('available_until')->orWhere('available_until', '>', now())) - ->pluck('template_vmid')->map(fn ($v) => (int) $v)->unique()->values()->all(); + $required = PlanVersion::requiredTemplateVmids(); // A catalogue with no published version requires no template. The // same case VerifyVmTemplate explicitly lets through. @@ -51,7 +47,12 @@ final class VmTemplateCheck foreach ($hosts as $host) { try { - if ($this->pve->forHost($host)->vmExists($host->node ?? 'pve', $vmid)) { + // isTemplate, not vmExists — the same correction made in + // VerifyVmTemplate, and the same hole: this check ran + // BEFORE a purchase, so an abandoned build numbered 9000 + // reported the catalogue sellable and the failure surfaced + // once the money had already moved. + if ($this->pve->forHost($host)->isTemplate($host->node ?? 'pve', $vmid)) { $found = true; break; } diff --git a/config/provisioning.php b/config/provisioning.php index 813d572..ea83881 100644 --- a/config/provisioning.php +++ b/config/provisioning.php @@ -39,6 +39,19 @@ return [ Host\ConfigureProxmox::class, Host\CreateAutomationToken::class, Host\VerifyProxmoxApi::class, + // Build first, then check — and both, not one of them. The build + // ships deploy/bootstrap/lib/template.sh onto the host and runs it + // there; the check afterwards asks the Proxmox API the same + // question the customer pipeline will ask, so a script that + // reported success while the API disagrees is caught here rather + // than in CloneVirtualMachine. + // + // Also before RegisterCapacity, and not only for tidiness: the + // build is what teaches a Debian-installed Proxmox that its storage + // may hold disks at all. Counted before that, capacity is 0 — a + // host that looks finished in the list and can never carry a + // customer. + Host\BuildVmTemplate::class, // Before the host is ever offered to a customer: a node with no // template to clone from cannot serve one, and finding that out in // CloneVirtualMachine means finding it out after somebody paid. diff --git a/deploy/bootstrap/clupilot-bootstrap.sh b/deploy/bootstrap/clupilot-bootstrap.sh index d91f543..0881dbc 100755 --- a/deploy/bootstrap/clupilot-bootstrap.sh +++ b/deploy/bootstrap/clupilot-bootstrap.sh @@ -808,12 +808,27 @@ section_template_built() { return 1 fi + # Ein per Debian aufgesetztes Proxmox hat nur `local`, und dessen + # Inhaltsliste enthält ab Werk keine Platten. Greift nur ein, wenn wirklich + # keine Ablage welche annimmt — siehe ensure_image_storage. + if ! ensure_image_storage; then + CLUPILOT_SECTION_NOTE='keine Ablage nimmt Platten auf, und die vorhandene ließ sich nicht erweitern' + return 1 + fi + _storage="$(detect_vm_storage)" if [ -z "$_storage" ]; then CLUPILOT_SECTION_NOTE='kein Proxmox-Speicher gefunden, der Platten aufnimmt' return 1 fi + # Ein df vorab ist billiger als ein Abbruch nach zwanzig Minuten mitten im + # virt-customize. + if ! check_build_space "$CLUPILOT_WORK_DIR" "$(storage_table | awk -v n="$_storage" '$1 == n { print $4 }')"; then + CLUPILOT_SECTION_NOTE="zu wenig Platz für Abbild, Arbeitskopie und importierte Platte (${CLUPILOT_TEMPLATE_MIN_FREE_MB} MB nötig)" + return 1 + fi + _image="${CLUPILOT_WORK_DIR}/${CLUPILOT_CLOUD_IMAGE}" if [ ! -f "$_image" ] && ! fetch_cloud_image "$_image"; then CLUPILOT_SECTION_NOTE='Debian-Cloud-Abbild nicht ladbar oder Prüfsumme falsch' diff --git a/deploy/bootstrap/lib/template-run.sh b/deploy/bootstrap/lib/template-run.sh new file mode 100644 index 0000000..53e3c43 --- /dev/null +++ b/deploy/bootstrap/lib/template-run.sh @@ -0,0 +1,215 @@ +#!/bin/sh +# shellcheck shell=sh +# +# Treiber für den Vorlagenbau, wenn er aus der CluPilot-Pipeline kommt. +# +# `App\Provisioning\Steps\Host\BuildVmTemplate` lädt diese Datei zusammen mit +# `template.sh` und `assets/docker-compose.yml` auf den Host und startet sie +# abgekoppelt. Die eigentliche Arbeit macht `template.sh` — hier steht nur, in +# welcher Reihenfolge, und wie der Fortschritt zurückgemeldet wird. +# +# --------------------------------------------------------------------------- +# Warum das Skript hochgeladen und nicht in PHP nachgebaut wird +# --------------------------------------------------------------------------- +# +# `template.sh` prüft drei Fallen, die je einen bezahlten Auftrag gekostet +# haben. Eine zweite Fassung davon in PHP wären zwei Installationen, die bei +# jeder Proxmox-Version nachgezogen werden müssten — und die zweite fiele erst +# auf, wenn jemand sie benutzt. Es gibt eine Fassung, und sie liegt eine Datei +# weiter. +# +# --------------------------------------------------------------------------- +# Rückmeldung +# --------------------------------------------------------------------------- +# +# Im Arbeitsverzeichnis: +# +# state running | ok | failed +# pid die PID dieses Skripts — und, weil es per `setsid` gestartet +# wird, zugleich die seiner PROZESSGRUPPE. Das ist der Unterschied +# zwischen „Aufgeben" und „Aufgeben, während der Bau weiterläuft": +# das Skript wartet die meiste Zeit auf ein virt-customize, und nur +# über die Gruppe ist das mit zu beenden. +# phase die laufende Phase, für die Fortschrittszeile in der Konsole +# note der Grund im Fehlerfall +# build.log alles +# +# `state` allein ist keine Aussage über den Lauf: stirbt das Skript, bleibt dort +# für immer `running` stehen, weil niemand mehr da ist, der es ändert. Deshalb +# fragt der Schritt zusätzlich `kill -0` gegen `pid` — „läuft noch" heißt, der +# Prozess lebt. + +set -u + +CLUPILOT_WORK_DIR="${CLUPILOT_WORK_DIR:-/var/lib/clupilot/template-build}" + +# Als ALLERERSTES, vor jedem Einlesen und jeder Prüfung: der Startbefehl wartet +# darauf, dass hier etwas steht, und bis dahin gilt der Bau als noch nicht +# angelaufen. `$$` und nicht `$!` auf der anderen Seite, weil `setsid` +# dazwischen liegt — und weil `setsid` daraus eine eigene Sitzung macht, ist +# diese Zahl zugleich die Prozessgruppe, an die ein Abbruch geschickt wird. +echo $$ > "${CLUPILOT_WORK_DIR}/pid" + +# --------------------------------------------------------------------------- +# Die vier Helfer, die template.sh erwartet +# --------------------------------------------------------------------------- +# +# Eigene, knappe Fassung statt `clupilot-bootstrap.sh` anzufassen: das ist der +# stillgelegte Weg, und diese vier sind curl-Hüllen ohne +# Entwurfsentscheidung. Die 252 Zeilen mit den Fallen bleiben die eine Fassung, +# und nur darum ging es. + +log() { + printf '%s %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$1" +} + +die() { + log "$1" + exit 1 +} + +http_get() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 --retry-delay 2 "$1" + else + wget -qO- "$1" + fi +} + +http_download() { + if command -v curl >/dev/null 2>&1; then + curl -fsSL --retry 3 --retry-delay 2 -o "$2" "$1" + else + wget -qO "$2" "$1" + fi +} + +# --------------------------------------------------------------------------- +# Rückmeldung +# --------------------------------------------------------------------------- + +phase() { + printf '%s' "$1" > "${CLUPILOT_WORK_DIR}/phase" + log "--- $1" +} + +fail() { + printf '%s' "$1" > "${CLUPILOT_WORK_DIR}/note" + printf 'failed' > "${CLUPILOT_WORK_DIR}/state" + log "ABBRUCH: $1" + exit 1 +} + +# Ein Abbruch, den keine Zeile hier abgefangen hat (kein Speicher, ein Signal), +# darf nicht als `running` liegen bleiben und den Schritt gegen eine Leiche +# pollen lassen. Der Schritt erkennt das zwar auch an `kill -0`, aber ein Grund +# ist besser als eine Vermutung. +on_exit() { + _code=$? + if [ "$_code" -ne 0 ] && [ "$(cat "${CLUPILOT_WORK_DIR}/state" 2>/dev/null)" = 'running' ]; then + printf 'Der Bau brach unerwartet ab (Rückgabewert %s); siehe build.log' "$_code" \ + > "${CLUPILOT_WORK_DIR}/note" + printf 'failed' > "${CLUPILOT_WORK_DIR}/state" + fi +} +trap on_exit EXIT + +# --------------------------------------------------------------------------- +# Ablauf +# --------------------------------------------------------------------------- + +cd "$CLUPILOT_WORK_DIR" || die "Arbeitsverzeichnis ${CLUPILOT_WORK_DIR} fehlt" + +# CLUPILOT_TEMPLATE_VMIDS und alles andere Veränderliche schreibt der Schritt. +# shellcheck source=/dev/null +. "${CLUPILOT_WORK_DIR}/env" +# shellcheck source=lib/template.sh +. "${CLUPILOT_WORK_DIR}/template.sh" + +CLUPILOT_TEMPLATE_VMIDS="${CLUPILOT_TEMPLATE_VMIDS:-9000}" + +log "Vorlagenbau für VMID(s): ${CLUPILOT_TEMPLATE_VMIDS}" + +# Der billigste Test zuerst. Ein df vorab ist billiger als ein Abbruch nach +# zwanzig Minuten mitten im virt-customize. +phase 'Platz prüfen' +check_build_space "$CLUPILOT_WORK_DIR" \ + || fail "Zu wenig Platz unter ${CLUPILOT_WORK_DIR} für Abbild, Arbeitskopie und importierte Platte (${CLUPILOT_TEMPLATE_MIN_FREE_MB} MB nötig)" + +phase 'libguestfs-tools einbauen' +export DEBIAN_FRONTEND=noninteractive +apt-get install -y libguestfs-tools >> "${CLUPILOT_WORK_DIR}/apt.log" 2>&1 \ + || fail 'libguestfs-tools ließ sich nicht installieren — ohne virt-customize entsteht keine Vorlage' + +phase 'Ablage für Platten sicherstellen' +ensure_image_storage \ + || fail 'Keine Ablage auf diesem Host nimmt Platten auf, und die vorhandene ließ sich nicht erweitern' + +_storage="$(detect_vm_storage)" +[ -n "$_storage" ] || fail 'kein Proxmox-Speicher gefunden, der Platten aufnimmt' +log "Ablage: ${_storage}" + +# Verzeichnis-Ablagen liegen auf einem Dateisystem, das `df` beantworten kann; +# Block-Ablagen haben keinen Pfad und werden übersprungen. +_storage_path="$(storage_table | awk -v n="$_storage" '$1 == n { print $4 }')" +check_build_space "$_storage_path" \ + || fail "Zu wenig Platz auf der Ablage ${_storage} (${_storage_path}) für die importierte Platte" + +_image="${CLUPILOT_WORK_DIR}/${CLUPILOT_CLOUD_IMAGE}" +if [ ! -f "$_image" ]; then + phase 'Cloud-Abbild laden' + fetch_cloud_image "$_image" \ + || fail 'Debian-Cloud-Abbild nicht ladbar oder Prüfsumme falsch' +else + log 'Cloud-Abbild liegt bereits geprüft vor' +fi + +# Falle 2, VOR dem Umbauen: taugt der Aufbau überhaupt? +phase 'Aufbau des Abbilds prüfen' +verify_image_layout "$_image" \ + || fail 'Der Aufbau des Cloud-Abbilds erfüllt Falle 2 nicht (LVM oder Wurzel nicht als letzte Partition)' + +# Umgebaut wird eine KOPIE. Das geprüfte Abbild bleibt unangetastet liegen, sonst +# erbt ein Wiederanlauf ein halb umgebautes Abbild und hält es für fertig +# geladen — `[ ! -f "$_image" ]` oben kann den Unterschied nicht sehen. +phase 'Arbeitskopie ziehen' +_build="${CLUPILOT_WORK_DIR}/build.qcow2" +rm -f "$_build" +cp "$_image" "$_build" || fail 'Arbeitskopie des Abbilds ließ sich nicht anlegen' + +_compose="${CLUPILOT_WORK_DIR}/docker-compose.yml" +[ -f "$_compose" ] || fail "docker-compose.yml fehlt unter ${CLUPILOT_WORK_DIR}" + +phase 'Abbild umbauen (Docker, Compose, Gastagent)' +customise_cloud_image "$_build" "$_compose" \ + || fail "Abbild ließ sich nicht umbauen; siehe ${CLUPILOT_WORK_DIR}/virt-customize.log" + +# Fallen 1, 3 und 4, NACH dem Umbauen: virt-customize meldet auch dann Erfolg, +# wenn apt ein Paket stillschweigend nicht installiert hat. +phase 'Abbildinhalt prüfen' +verify_image_contents "$_build" \ + || fail 'Das fertige Abbild erfüllt Falle 1, 3 oder 4 nicht — siehe build.log' + +for _vmid in $CLUPILOT_TEMPLATE_VMIDS; do + phase "Vorlage ${_vmid} anlegen" + + # create_proxmox_template liest die VMID aus der globalen Variablen. + CLUPILOT_TEMPLATE_VMID="$_vmid" + + create_proxmox_template "$_build" "$_storage" \ + || fail "Vorlage ${_vmid} ließ sich nicht anlegen; siehe ${CLUPILOT_WORK_DIR}/qm.log" + + # Das Merkmal, nicht die Existenz. Eine VM, die zufällig so heißt, ist keine + # Vorlage. + template_is_really_a_template \ + || fail "VMID ${_vmid} existiert, meldet aber nicht template: 1" + + log "Vorlage ${_vmid} auf ${_storage} fertig, alle vier Fallen geprüft" +done + +rm -f "$_build" + +phase 'fertig' +printf 'ok' > "${CLUPILOT_WORK_DIR}/state" +log 'Vorlagenbau abgeschlossen' +exit 0 diff --git a/deploy/bootstrap/lib/template.sh b/deploy/bootstrap/lib/template.sh index 69c6915..03d8bd1 100644 --- a/deploy/bootstrap/lib/template.sh +++ b/deploy/bootstrap/lib/template.sh @@ -34,53 +34,205 @@ CLUPILOT_TEMPLATE_NAME='clupilot-nextcloud-vorlage' CLUPILOT_CLOUD_IMAGE_BASE="${CLUPILOT_CLOUD_IMAGE_BASE:-https://cloud.debian.org/images/cloud/trixie/latest}" CLUPILOT_CLOUD_IMAGE='debian-13-genericcloud-amd64.qcow2' +# Wieviel unter dem Arbeitsverzeichnis frei sein muss, in MB. Abbild, +# Arbeitskopie und die importierte Platte liegen gleichzeitig da, dazu die +# Appliance von virt-customize — ein Vielfaches der Abbildgröße. +CLUPILOT_TEMPLATE_MIN_FREE_MB="${CLUPILOT_TEMPLATE_MIN_FREE_MB:-12000}" + # Wohin die Vorlagenplatte kommt. `local-zfs` ist das, was der PVE-Installer auf # ZFS anlegt; `local-lvm` wäre es bei ext4. Zur Laufzeit gesucht statt geraten. +# +# Gefragt wird EINMAL nach der Menge der Ablagen, die Platten aufnehmen, und die +# Wunschkandidaten werden darin gesucht. Vorher fragte die Schleife +# `pvesm status --storage X --content images` nach dem Rückgabewert ab — der +# sagt aber nicht zuverlässig „diese Ablage nimmt Platten", und `local` hätte so +# durchgehen können, obwohl ein Import dorthin erst nach dem Kopieren scheitert. +# +# `$3 == "active"`: eine abgemeldete oder tote Netzablage steht weiterhin in der +# Liste und wäre die schlechteste denkbare Wahl. detect_vm_storage() { + _capable="$(pvesm status --content images 2>/dev/null | awk 'NR > 1 && $3 == "active" { print $1 }')" + + if [ -z "$_capable" ]; then + return 0 + fi + for _candidate in local-zfs local-lvm local; do - if pvesm status --storage "$_candidate" >/dev/null 2>&1; then - # Muss Platten aufnehmen können. `local` kann das oft NICHT — dort - # liegen nur Abbilder und Sicherungen, und ein `qm importdisk` - # dorthin scheitert erst nach dem Kopieren. - if pvesm status --storage "$_candidate" --content images >/dev/null 2>&1; then - printf '%s' "$_candidate" - return 0 - fi + if printf '%s\n' "$_capable" | grep -qx "$_candidate"; then + printf '%s' "$_candidate" + return 0 fi done - # Sonst der erste Speicher, der Platten aufnimmt. - pvesm status --content images 2>/dev/null | awk 'NR > 1 { print $1; exit }' + printf '%s' "$(printf '%s\n' "$_capable" | head -1)" +} + +# Eine Zeile je Ablage aus /etc/pve/storage.cfg: " ". +# Leere Felder als "-", damit die Spaltennummern stehen bleiben. +# +# Der Pfad ist überschreibbar, damit die Auswertung dieser Datei ohne einen +# Proxmox-Host durchgespielt werden kann — sie ist das Stück neuer Logik mit dem +# größten Anteil an awk und dem kleinsten an „sieht man sofort". +CLUPILOT_STORAGE_CFG="${CLUPILOT_STORAGE_CFG:-/etc/pve/storage.cfg}" + +storage_table() { + awk ' + function flush() { + if (name != "") { + printf "%s %s %s %s\n", name, type, + (content == "" ? "-" : content), (path == "" ? "-" : path) + } + } + /^[a-z]+:[[:space:]]/ { + flush() + type = substr($1, 1, length($1) - 1); name = $2; content = ""; path = "" + next + } + /^[[:space:]]+content[[:space:]]/ { content = $2; next } + /^[[:space:]]+path[[:space:]]/ { path = $2; next } + END { flush() } + ' "$CLUPILOT_STORAGE_CFG" 2>/dev/null +} + +# Sorgt dafür, dass überhaupt eine Ablage Platten annimmt — und greift NUR ein, +# wenn keine es tut. +# +# Ein per Debian aufgesetztes Proxmox hat nur `local`, eine Verzeichnis-Ablage, +# deren Inhaltsliste ab Werk keine Platten enthält. `detect_vm_storage` findet +# dann nichts und der Bau stürbe vor der ersten Zeile. Schlimmer ist der leise +# Teil: `RegisterCapacity` zählt später die Ablagen, die Platten aufnehmen, und +# meldete Kapazität 0 — ein Host, der in der Liste fertig aussieht und auf den +# nie ein Kunde gelegt werden kann. +# +# Zwei Dinge passieren hier bewusst NICHT: +# +# - Eine feste Inhaltsliste schreiben. Was jemand auf diesem Host ergänzt hat, +# wäre weg. Die vorhandene wird gelesen und `images` ANGEHÄNGT. +# - In /etc/pve/storage.cfg schreiben. Das ist pmxcfs; ein von Hand +# geschriebener Eintrag umgeht Prüfung und Neuladen. Gelesen wird die Datei, +# geschrieben wird über `pvesm set`. +# +# Ein Host mit local-lvm oder local-zfs kommt hier nie an: die nehmen Platten +# längst, `detect_vm_storage` liefert sie, und an `local` wird nicht gerührt. +ensure_image_storage() { + if [ -n "$(detect_vm_storage)" ]; then + return 0 + fi + + # `local` zuerst, sonst die erste Ablage einer Art, die Platten überhaupt + # tragen kann. Netzablagen (nfs/cifs) stehen bewusst nicht in der Liste: + # deren Inhaltsliste zu ändern ist eine Entscheidung über fremden Speicher. + _name="$(storage_table | awk ' + $2 ~ /^(dir|btrfs|zfspool|lvmthin|lvm)$/ { + if ($1 == "local") { print $1; found = 1; exit } + if (first == "") first = $1 + } + END { if (!found && first != "") print first } + ')" + + if [ -z "$_name" ]; then + log 'Keine Ablage auf diesem Host nimmt Platten auf, und keine, der es beizubringen wäre.' + return 1 + fi + + _content="$(storage_table | awk -v n="$_name" '$1 == n { print $3 }')" + if [ "$_content" = '-' ] || [ -z "$_content" ]; then + _new='images' + else + _new="${_content},images" + fi + + log "Ablage ${_name} nimmt keine Platten auf; Inhaltsliste wird erweitert: ${_new}" + + if ! pvesm set "$_name" --content "$_new" >> "${CLUPILOT_WORK_DIR}/pvesm.log" 2>&1; then + log "pvesm set ${_name} --content ${_new} fehlgeschlagen" + return 1 + fi + + # Nachgesehen, nicht aus dem Rückgabewert geschlossen. + if [ -z "$(detect_vm_storage)" ]; then + log "Ablage ${_name} nimmt auch nach der Erweiterung keine Platten auf" + return 1 + fi + + return 0 +} + +# Freier Platz auf jedem übergebenen Pfad, bevor irgendetwas geladen wird. +# +# Ein df vorab mit klarer Absage ist billiger als ein Abbruch nach zwanzig +# Minuten mitten im virt-customize. Pfade statt einer Ablagengröße, weil `df` +# in derselben Einheit antwortet, egal wen man fragt — die Spalten von +# `pvesm status` tun das nicht. +# +# Block-Ablagen (lvmthin, zfspool) haben keinen Pfad und werden nicht geprüft; +# dort scheitert der Import laut und sofort statt nach dem Kopieren. +check_build_space() { + for _path in "$@"; do + [ -z "$_path" ] && continue + [ "$_path" = '-' ] && continue + [ -d "$_path" ] || continue + + _free="$(df -Pm "$_path" 2>/dev/null | awk 'NR == 2 { print $4 }')" + + if [ -z "$_free" ]; then + log "Freier Platz unter ${_path} nicht feststellbar" + return 1 + fi + + if [ "$_free" -lt "$CLUPILOT_TEMPLATE_MIN_FREE_MB" ]; then + log "Zu wenig Platz unter ${_path}: ${_free} MB frei, ${CLUPILOT_TEMPLATE_MIN_FREE_MB} MB nötig." + return 1 + fi + + log "Platz unter ${_path}: ${_free} MB frei" + done + + return 0 } # Lädt das Cloud-Abbild und prüft es gegen Debians SHA512SUMS. +# +# Geladen wird nach `.part`, umbenannt erst nach geprüfter Summe. Vorher schrieb +# die Funktion direkt ans Ziel, und ein abgebrochener Lauf hinterließ eine +# abgeschnittene Datei — die der nächste Lauf wegen `[ ! -f "$_image" ]` +# ungeprüft weiterbenutzt hätte. Unter dem Zielnamen liegt damit nur, was die +# Prüfsumme bestanden hat. fetch_cloud_image() { _target="$1" + _part="${_target}.part" - if ! http_download "${CLUPILOT_CLOUD_IMAGE_BASE}/${CLUPILOT_CLOUD_IMAGE}" "$_target"; then + rm -f "$_part" + + if ! http_download "${CLUPILOT_CLOUD_IMAGE_BASE}/${CLUPILOT_CLOUD_IMAGE}" "$_part"; then log "Cloud-Abbild nicht ladbar: ${CLUPILOT_CLOUD_IMAGE_BASE}/${CLUPILOT_CLOUD_IMAGE}" + rm -f "$_part" return 1 fi _sums="$(http_get "${CLUPILOT_CLOUD_IMAGE_BASE}/SHA512SUMS" || true)" if [ -z "$_sums" ]; then log 'SHA512SUMS von cloud.debian.org nicht abrufbar' + rm -f "$_part" return 1 fi _expected="$(printf '%s\n' "$_sums" | awk -v n="$CLUPILOT_CLOUD_IMAGE" '$2 == n || $2 == "*" n { print $1; exit }')" if [ -z "$_expected" ]; then log "keine Prüfsumme für ${CLUPILOT_CLOUD_IMAGE}" + rm -f "$_part" return 1 fi - _actual="$(sha512sum "$_target" | awk '{ print $1 }')" + _actual="$(sha512sum "$_part" | awk '{ print $1 }')" if [ "$_actual" != "$_expected" ]; then log 'Prüfsumme des Cloud-Abbilds stimmt nicht' - rm -f "$_target" + rm -f "$_part" return 1 fi + mv "$_part" "$_target" || { log 'Abbild ließ sich nicht an seinen Platz schieben'; return 1; } + log 'Cloud-Abbild geladen, Prüfsumme stimmt' return 0 } @@ -125,9 +277,17 @@ customise_cloud_image() { _image="$1" _compose="$2" - # Ohne diese Zeile hängt jede Bereitstellung an WaitForGuestAgent bis zum - # Zeitablauf — Falle 3. - _packages='qemu-guest-agent,ca-certificates,curl,gnupg' + # `qemu-guest-agent`: ohne ihn hängt jede Bereitstellung an + # WaitForGuestAgent bis zum Zeitablauf — Falle 3. + # + # `cloud-guest-utils` bringt `growpart` — Falle 4. `GrowGuestFilesystem` + # ruft es auf, und installiert hat es nie jemand: es lief bisher, weil + # Debians Cloud-Abbild es zufällig mitbringt. Fiele es dort heraus, bekäme + # jeder Kunde stillschweigend ein Kontingent über ein Dateisystem, das nie + # gewachsen ist — bemerkt erst, wenn eine Platte voll ist, die laut Rechnung + # dreimal so groß sein sollte. Hier ausdrücklich installiert statt + # vorausgesetzt, und unten nachgewiesen statt geglaubt. + _packages='qemu-guest-agent,cloud-guest-utils,ca-certificates,curl,gnupg' if ! virt-customize -a "$_image" \ --install "$_packages" \ @@ -198,7 +358,15 @@ verify_image_contents() { fi fi - log 'Abbildinhalt in Ordnung: qemu-guest-agent, Compose-Plugin, user: www-data' + # FALLE 4. Siehe die Paketliste oben: das Kontingent, das der Kunde bezahlt + # hat, wird über ein Dateisystem geschrieben, das growpart vorher vergrößert + # haben muss. Fehlt es, tut die Vergrößerung still nichts. + if ! virt-ls -a "$_image" /usr/bin 2>/dev/null | grep -q '^growpart$'; then + log 'growpart ist NICHT im Abbild (Falle 4) — GrowGuestFilesystem liefe ins Leere und jedes gekaufte Kontingent läge über einem Dateisystem, das nie gewachsen ist' + return 1 + fi + + log 'Abbildinhalt in Ordnung: qemu-guest-agent, growpart, Compose-Plugin, user: www-data' return 0 } @@ -225,12 +393,21 @@ create_proxmox_template() { --vga serial0 \ >> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 || { log 'qm create fehlgeschlagen'; return 1; } - # Platte importieren und als scsi0 einhängen. - qm importdisk "$_vmid" "$_image" "$_storage" >> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 \ - || { log 'qm importdisk fehlgeschlagen'; return 1; } - - qm set "$_vmid" --scsi0 "${_storage}:vm-${_vmid}-disk-0,discard=on,ssd=1" \ - >> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 || { log 'Platte ließ sich nicht einhängen'; return 1; } + # Platte importieren UND einhängen, in einem Zug. + # + # Hier stand `qm importdisk` und danach ein `qm set --scsi0 + # ${_storage}:vm-${_vmid}-disk-0`. Dieser Datenträgername gilt nur bei + # BLOCK-Ablagen (local-lvm, local-zfs). Auf einer Verzeichnis-Ablage — genau + # der, die ensure_image_storage gerade erst möglich macht, und die einzige + # auf einem per Debian aufgesetzten Proxmox — heißt er + # `local:9000/vm-9000-disk-0.qcow2`. Das Skript legte die Platte also an und + # hängte sie unter einem Namen ein, den es dort nicht gibt. + # + # `--import-from` überlässt die Benennung Proxmox, das als einziges weiß, + # wie sie auf dieser Ablage lautet. + qm set "$_vmid" --scsi0 "${_storage}:0,import-from=${_image},discard=on,ssd=1" \ + >> "${CLUPILOT_WORK_DIR}/qm.log" 2>&1 \ + || { log 'Platte ließ sich nicht importieren'; return 1; } # cloud-init: darüber schreibt die Pipeline beim Klonen die .env in den Gast. qm set "$_vmid" --ide2 "${_storage}:cloudinit" --boot order=scsi0 \ diff --git a/docs/superpowers/specs/2026-08-01-vorlagenbau-design.md b/docs/superpowers/specs/2026-08-01-vorlagenbau-design.md new file mode 100644 index 0000000..e51e226 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-vorlagenbau-design.md @@ -0,0 +1,264 @@ +# Vorlagenbau in der Übernahme-Pipeline (`BuildVmTemplate`) + +*Entwurf, 1. August 2026* + +## Das Loch + +`VerifyVmTemplate` **meldet**, dass eine Vorlage fehlt, und baut ausdrücklich +keine. Das war richtig, solange niemand entschieden hatte, was in die goldene +Vorlage gehört — inzwischen ist das entschieden und steht in +`deploy/bootstrap/lib/template.sh`. Damit ist die Übernahme eines Hosts heute an +genau einer Stelle unfertig: der Betreiber muss die Vorlage von Hand bauen oder +von einem anderen Knoten kopieren, sonst bleibt der Lauf bei `verify_vm_template` +stehen. + +Das Abnahmeziel ist eine frische Debian-13-Maschine, Host anlegen, zusehen, +`active` — ohne einen Handgriff. Der Vorlagenbau ist der Schritt, der dazwischen +fehlt. + +## Die Entscheidung: das vorhandene Skript ausführen, nicht in PHP nachbauen + +`template.sh` sind 252 geprüfte Zeilen, die drei Fallen abdecken, die je einen +bezahlten Auftrag gekostet haben. Eine zweite Fassung in PHP wäre genau der +Fehler, den die Wege-Entscheidung vom 1. August vermeiden sollte: zwei +Installationen, die bei jeder Proxmox-Version nachgezogen werden müssen, und die +zweite fällt erst auf, wenn jemand sie benutzt. + +Also: **die Datei wortgleich per SSH auf den Host laden und dort ausführen.** +Was heute nur `clupilot-bootstrap.sh` konnte, kann danach die Pipeline — aus +derselben Datei. + +## Aufbau + +### 1. `deploy/bootstrap/lib/template.sh` — erweitert, nicht kopiert + +Fünf Änderungen an der einen Fassung. Der Rettungssystem-Weg erbt sie +automatisch, sollte er je wiederbelebt werden. + +**`ensure_image_storage()` — neu.** Auf einem per Debian aufgesetzten Proxmox +ist `local` eine Verzeichnis-Ablage, deren Inhaltsliste standardmäßig **keine +Platten** enthält. `detect_vm_storage()` findet dann nichts und der Bau stirbt, +bevor er anfängt — und `RegisterCapacity` meldete hinterher Kapazität 0. Das +wäre ein Host, der in der Liste fertig aussieht und auf den nie ein Kunde gelegt +werden kann: dieselbe Sorte Fehler wie die fehlende Vorlage, nur leiser. + +Die Funktion greift **nur ein, wenn keine Ablage Platten annimmt**: + +- Erst `detect_vm_storage()`. Liefert sie etwas, passiert nichts. Ein Host mit + `local-lvm` oder `local-zfs` kommt also gar nicht erst hierher — die nehmen + Platten längst, und an `local` wird dort nicht gerührt. +- Sonst: die **vorhandene** Inhaltsliste aus `/etc/pve/storage.cfg` lesen und + `images` **anhängen**. Eine feste Liste zu schreiben würde löschen, was + jemand auf diesem Host ergänzt hat. +- Geschrieben wird über **`pvesm set`**, nie in die Datei. `/etc/pve` ist + pmxcfs; ein von Hand geschriebener Eintrag umgeht Prüfung und Neuladen. + +**`check_build_space()` — neu.** Abbild, Arbeitskopie und importierte Platte +sind zusammen ein Vielfaches der Abbildgröße. Ein `df` vorab mit klarer Absage +ist billiger als ein Abbruch nach zwanzig Minuten mitten im `virt-customize`. + +**`fetch_cloud_image()` lädt nach `.part`** und benennt erst um, wenn die +SHA512-Summe stimmt. Bisher schrieb sie direkt ans Ziel; ein abgebrochener Lauf +hinterließ eine abgeschnittene Datei, die der nächste wegen `[ ! -f "$_image" ]` +ungeprüft weiterbenutzt hätte. + +**`customise_cloud_image()` installiert `cloud-guest-utils`** — +`GrowGuestFilesystem` ruft `growpart` auf, und installiert hat es nie jemand. Es +funktioniert heute, weil Debians Cloud-Abbild es zufällig mitbringt. Fällt es +dort einmal heraus, bekommt jeder Kunde stillschweigend ein Kontingent, das nie +angewendet wird — bemerkt wird das erst, wenn eine Platte voll ist, die laut +Rechnung dreimal so groß sein sollte. `verify_image_contents()` weist es danach +als **vierte Falle** nach. Installieren *und* nachweisen: `ping` war auch +„bringt das System doch mit", und `VM.Monitor` war auch „das gibt es doch". + +**`create_proxmox_template()` importiert über `qm set --import-from`** statt +`qm importdisk` mit anschließend geratenem Datenträgernamen. Der bisherige Code +hängte die Platte als `${storage}:vm-${vmid}-disk-0` ein — dieser Name gilt nur +bei **Block**-Speichern. Auf einer Verzeichnis-Ablage heißt der Datenträger +`local:9000/vm-9000-disk-0.qcow2`, und genau diese Ablage ist der Fall, den +`ensure_image_storage()` gerade erst möglich macht. Das Skript hätte die Platte +angelegt und dann unter einem Namen eingehängt, den es dort nicht gibt. +`--import-from` überlässt die Benennung Proxmox, das als einziges weiß, wie sie +auf dieser Ablage lautet. + +### 2. `deploy/bootstrap/lib/template-run.sh` — neu, der Treiber + +Der einzige Shell-Neubau. Er trägt: + +- einen knappen Vorspann mit den vier Helfern, die `template.sh` erwartet + (`log`, `die`, `http_download`, `http_get`). Bewusst eine eigene, kleine + Fassung statt `clupilot-bootstrap.sh` anzufassen: das ist der stillgelegte + Weg, und die Helfer sind curl-Hüllen ohne Entwurfsentscheidung — die 252 + Zeilen mit den Fallen bleiben die eine Fassung, und nur darum ging es. +- die Phasen in Reihe: Platz prüfen → `libguestfs-tools` → Ablage sichern → + Abbild laden → Aufbau prüfen (Falle 2) → **Arbeitskopie ziehen** → umbauen → + Inhalt prüfen (Fallen 1, 3, 4) → je VMID Vorlage anlegen → `template: 1` + nachweisen. +- die Statusdateien. + +**Die Arbeitskopie** ist der Grund, warum ein Wiederanlauf nichts erbt: das +geprüfte Abbild bleibt unangetastet liegen, umgebaut wird eine Kopie. Ein +Abbruch mitten im `virt-customize` hinterlässt sonst ein halb umgebautes Abbild, +das der nächste Lauf für fertig heruntergeladen hält. + +**Statusprotokoll** im Arbeitsverzeichnis `/var/lib/clupilot/template-build`: + +| Datei | Inhalt | +|---|---| +| `state` | `running` \| `ok` \| `failed` | +| `pid` | PID des Treibers | +| `phase` | laufende Phase, für die Fortschrittszeile im Protokoll | +| `note` | Grund im Fehlerfall | +| `build.log` | alles | + +### 3. `App\Provisioning\Steps\Host\BuildVmTemplate` + +Zwischen `VerifyProxmoxApi` und `VerifyVmTemplate`: bauen, dann prüfen. Vor +`RegisterCapacity`, damit die Ablagenerweiterung schon gewirkt hat, wenn die +Kapazität gezählt wird. + +Ablauf je Aufruf: + +1. Kein Katalog veröffentlicht → `advance()`. Dieselbe Ausnahme, die + `VerifyVmTemplate` schon macht: eine Installation, die ihren ersten Host + übernimmt, bevor sie Pakete veröffentlicht, verlangt keine Vorlage. +2. Welche der verlangten VMIDs melden **nicht** `template: 1`? Keine → `advance()`, + ohne etwas anzufassen. Die Abkürzung fragt bewusst nach dem Merkmal, nicht + nach der Existenz: ein abgebrochener Bau hinterlässt eine VM mit der Nummer + 9000, und eine Existenzprüfung sagte dazu „passt". +3. Gebaut wird **nur die Fehlliste**, nie alles Verlangte. `create_proxmox_template` + räumt eine VMID weg, bevor es sie anlegt — zwei Pakete mit zwei Vorlagen, von + denen eine steht, hieße die stehende auf dem Weg zu zerstören und bei einem + Fehlschlag ohne sie dazustehen. +4. Statusdateien lesen. Sie überleben den Lauf, der sie geschrieben hat, also + entscheidet zuerst: **hat dieser Lauf überhaupt etwas gestartet?** Das sagt + die Frist im Run-Kontext. + +**Status aus einem früheren Lauf** (keine Frist im Kontext): + +| `state` | Prozess | Ergebnis | +|---|---|---| +| `running` | lebt | **übernehmen** — `poll(30)`, eigene Frist setzen | +| alles andere | — | Dateien hochladen, `nohup setsid` starten, `poll(30)` | + +Ein `ok` von gestern beantwortet keine Frage, die dieser Lauf gestellt hat. Als +eigenes Ergebnis gelesen hieße es „der Bau, den ich angestoßen habe, ist fertig +geworden, ohne zu liefern, worum ich gebeten habe" — und das ist der gewöhnliche +Fall an dem Tag, an dem der Katalog eine zweite Vorlage bekommt. Ein *lebender* +Bau ist die Ausnahme: er baut dasselbe, also wird er übernommen statt daneben +ein zweiter gestartet. + +**Status aus diesem Lauf** (Frist gesetzt): + +| `state` | Prozess | Ergebnis | +|---|---|---| +| — | — | **`fail`** — der Start hat nicht gegriffen | +| `running` | lebt (`kill -0`) | `poll(30, phase)` | +| `running` | tot | **sofort `fail`** | +| `ok` | — | `fail` — das Skript meldet fertig, die API widerspricht | +| `failed` | — | `fail(note)` mit den letzten Protokollzeilen | + +Zeile 3 ist der Punkt, an dem beides schiefgeht, wenn man ihn weglässt: startet +der Schritt neu, obwohl noch ein Treiber läuft, laufen zwei `virt-customize` auf +derselben Datei und beide legen VMID 9000 an. Und pollt er weiter, weil in der +Datei `running` steht, obwohl der Prozess nach einem Neustart weg ist, wartet er +fünfundvierzig Minuten gegen eine Leiche. **„Läuft noch" heißt: der Prozess +lebt** — nicht: in der Datei steht `running`. + +Eigene Frist von 45 Minuten im Run-Kontext, `maxDuration()` 3600 darüber, damit +die eigene Frist zuerst greift und nicht der allgemeine Schritt-Zeitablauf — +dieselbe Form wie `RebootIntoPveKernel`. + +**Aufgeben beendet erst, dann räumt es auf.** Der Fristablauf trifft einen +Prozess, der noch läuft; nur die Statusdateien zu löschen machte den Schritt +wiederholbar und ließe den alten Bau weiterlaufen — dieselbe Kollision, von der +anderen Seite. Beendet wird die **Prozessgruppe** (`kill -TERM -$P`, dann +`-KILL`), weil der Treiber die meiste Zeit auf ein `virt-customize` wartet, das +sonst allein weitermahlt. Dass die PID zugleich die Prozessgruppe ist, ist der +Grund für `setsid` beim Start — und der Grund, warum das Skript seine PID selbst +per `$$` hinterlegt statt `$!` auf der Startseite: mit `setsid` dazwischen kann +`$!` auf einen Prozess zeigen, den es schon nicht mehr gibt. Der Startbefehl +wartet deshalb bis zu 15 Sekunden auf die Datei, sonst hielte der erste Poll +einen gesunden Bau für tot. Gegen eine wiederverwendete PID prüft der +Abbruch `/proc/$P/cmdline`. + +Beim `fail` verschwinden Statusdateien **und** die Frist im Kontext, damit ein +Wiederholen aus der Konsole wieder ein erster Besuch ist; das geprüfte Abbild +bleibt liegen und muss nicht neu geladen werden. + +### 4. `ProxmoxClient::isTemplate()` — neu + +Zwei Anfragen, weil die falsche Antwort hier **etwas zerstört**: `false` heißt +für `BuildVmTemplate` „Vorlage fehlt", und der Bau fängt mit `qm destroy +--purge` an. „Nicht da" und „konnte nicht fragen" dürfen sich deshalb nicht +ähnlich sehen — und am Rückgabecode sind sie nicht zu unterscheiden: Proxmox +beantwortet die Konfiguration einer nicht vorhandenen VM mit **500**, demselben +Code wie einen Knoten in Not. + +Also klärt die **VM-Liste** (`GET /nodes/{node}/qemu`) die Abwesenheit und sonst +nichts: fehlt die VMID in einer erfolgreichen Liste, ist sie wirklich nicht da. +Alles unterhalb einer erfolgreichen Liste **wirft** und landet im +Wiederholungs-Zweig des Aufrufers — die vorhandene Vorlage überlebt. Erst danach +klärt das **Konfigurationsdokument** (`GET …/{vmid}/config`) das Merkmal +`template`. + +Benutzt von `VerifyVmTemplate` **und** von `VmTemplateCheck`. Beide prüften nur +`vmExists`, beide stellen dieselbe Frage — die eine bei der Übernahme, die +andere vor dem Verkauf —, und beide hätten eine VM durchgelassen, die zufällig +9000 heißt. + +### 5. `PlanVersion::requiredTemplateVmids()` — neu + +Dieselbe Fensterlogik stand in `VerifyVmTemplate` und `VmTemplateCheck` und wäre +mit `BuildVmTemplate` zum dritten Mal abgeschrieben worden. Drei Kopien driften, +und die abgedriftete fände ein Kunde. + +## Was dieser Entwurf nicht tut + +- **`vmbr0` anlegen.** `qm create --net0 virtio,bridge=vmbr0` schreibt nur + Konfigurationstext und stört sich nicht an einer fehlenden Brücke. Die Brücke + ist ein eigener offener Punkt in `ConfigureProxmox`. +- **Die Proxmox-Version fixieren.** Eigener offener Punkt. +- **Den Rettungssystem-Weg wiederbeleben.** Er bleibt liegen; er erbt die + Korrekturen an `template.sh` nur, falls er je zurückkommt. + +## Prüfung + +Pest, vollständig mit Attrappen, wie der Rest der Pipeline: + +- Kein veröffentlichter Katalog → `advance`, nichts hochgeladen. +- Vorhandene echte Vorlage → `advance`, kein Start. +- VM 9000 existiert, ist aber **keine** Vorlage → es wird gebaut. +- Erster Aufruf lädt alle drei Dateien **wortgleich** hoch (Byte für Byte gegen + die Repo-Dateien geprüft) und startet abgekoppelt → `poll`. +- Zwei Vorlagen verlangt, eine steht → nur die fehlende landet in `env`. +- `running` + lebender Prozess → `poll`, **kein** zweiter Start. +- `running` + toter Prozess → `fail`, nicht `poll`. +- `failed` → `fail` mit dem Grund aus `note`; `state` wird entfernt. +- `ok`, aber `template: 1` fehlt → `fail`. +- Frist überschritten → `fail`, **und** die Prozessgruppe wird beendet. +- Start hinterließ keinen Status → `fail` statt endlos neu zu starten. +- Nach einem `fail` ist die Frist weg, damit „Wiederholen" wirklich neu anfängt. +- Status aus einem früheren Lauf: `ok` → neu bauen; lebendes `running` → + übernehmen statt danebenstarten. +- `isTemplate`: 502 → **wirft** (nicht „fehlt"); VMID nicht in der Liste → + `false`; `template: 1` → `true`; gewöhnliche VM mit der Nummer → `false`. +- `VerifyVmTemplate`/`VmTemplateCheck`: eine VM ohne `template: 1` besteht nicht + mehr. +- **Jeder Befehl, den der Schritt absetzt, geht durch `sh -n`.** Keine andere + Prüfung führt diese Shell je aus — ein verrutschtes Anführungszeichen käme + sonst zuerst auf einem echten Host zur Ausführung, und zwar in dem Schritt, + der VMs zerstört und neu anlegt. + +Dazu `sh -n` über alle drei Shell-Dateien, und die awk-Auswertung von +`storage.cfg` gegen eine echte Beispieldatei durchgespielt (`CLUPILOT_STORAGE_CFG` +ist genau dafür überschreibbar). + +## Was hier NICHT geprüft ist + +Nichts davon lief je gegen einen echten Proxmox-Host. Alle Tests sind Attrappen, +und die Stellen mit dem größten Risiko sind genau die, die eine Attrappe nicht +abbilden kann: `qm set --import-from` auf einer Verzeichnis-Ablage, `virt-customize` +unter PVE 9, `pvesm set` gegen pmxcfs, und ob `nohup setsid` den Bau wirklich +überleben lässt, wenn phpseclib den Kanal schließt. Die erste Übernahme auf +echter Hardware ist die Abnahme, nicht dieser Testlauf. diff --git a/lang/de/hosts.php b/lang/de/hosts.php index d4a810c..6fd850a 100644 --- a/lang/de/hosts.php +++ b/lang/de/hosts.php @@ -128,6 +128,7 @@ return [ 'configure_proxmox' => 'Proxmox konfigurieren', 'create_automation_token' => 'Automation-Token erstellen', 'verify_proxmox_api' => 'Proxmox-API prüfen', + 'build_vm_template' => 'VM-Vorlage bauen', 'verify_vm_template' => 'VM-Vorlage prüfen', 'register_host_dns' => 'Internen DNS-Namen registrieren', 'register_capacity' => 'Kapazität registrieren', diff --git a/lang/en/hosts.php b/lang/en/hosts.php index bef3870..dd500c4 100644 --- a/lang/en/hosts.php +++ b/lang/en/hosts.php @@ -128,6 +128,7 @@ return [ 'configure_proxmox' => 'Configure Proxmox', 'create_automation_token' => 'Create automation token', 'verify_proxmox_api' => 'Verify Proxmox API', + 'build_vm_template' => 'Build VM template', 'verify_vm_template' => 'Verify VM template', 'register_host_dns' => 'Register internal DNS name', 'register_capacity' => 'Register capacity', diff --git a/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php b/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php index 37c0e8c..d5680b5 100644 --- a/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php +++ b/tests/Feature/Provisioning/HostOnboardingEndToEndTest.php @@ -37,7 +37,8 @@ it('drives a fresh host all the way to active (mocked)', function () { $s['shell']->script('ls -1 /boot/vmlinuz-*-pve', CommandResult::success("/boot/vmlinuz-6.8.12-4-pve\n")); $s['shell']->script('ls -1 /boot/initrd.img-*-pve', CommandResult::success("/boot/initrd.img-6.8.12-4-pve\n")); $s['shell']->script('pvesh get /cluster/firewall/options', CommandResult::success('{"enable":1}')); - $s['pve']->clonedVmids[] = 9000; // the template the seeded catalogue sells + $s['pve']->clonedVmids[] = 9000; // the template the seeded catalogue sells + $s['pve']->templateVmids[] = 9000; // …and it really is a template (template: 1) $host = app(StartHostOnboarding::class)->run([ 'name' => 'pve-fsn-9', @@ -101,7 +102,8 @@ it('does not duplicate external resources when a step re-runs after a crash', fu $s['shell']->script('ls -1 /boot/vmlinuz-*-pve', CommandResult::success("/boot/vmlinuz-6.8.12-4-pve\n")); $s['shell']->script('ls -1 /boot/initrd.img-*-pve', CommandResult::success("/boot/initrd.img-6.8.12-4-pve\n")); $s['shell']->script('pvesh get /cluster/firewall/options', CommandResult::success('{"enable":1}')); - $s['pve']->clonedVmids[] = 9000; // the template the seeded catalogue sells + $s['pve']->clonedVmids[] = 9000; // the template the seeded catalogue sells + $s['pve']->templateVmids[] = 9000; // …and it really is a template (template: 1) $host = app(StartHostOnboarding::class)->run([ 'name' => 'pve-fsn-10', diff --git a/tests/Feature/Provisioning/HostStepsTest.php b/tests/Feature/Provisioning/HostStepsTest.php index 6db8db1..dad8f2f 100644 --- a/tests/Feature/Provisioning/HostStepsTest.php +++ b/tests/Feature/Provisioning/HostStepsTest.php @@ -3,10 +3,12 @@ use App\Models\Datacenter; use App\Models\Host; use App\Models\Operator; +use App\Models\PlanFamily; use App\Models\PlanVersion; use App\Models\ProvisioningRun; use App\Models\RunResource; use App\Provisioning\Jobs\PurgeHost; +use App\Provisioning\Steps\Host\BuildVmTemplate; use App\Provisioning\Steps\Host\CompleteHostOnboarding; use App\Provisioning\Steps\Host\ConfigureProxmox; use App\Provisioning\Steps\Host\ConfigureWireguard; @@ -26,6 +28,7 @@ use App\Services\Ssh\CommandResult; use App\Services\Ssh\FakeRemoteShell; use App\Services\Wireguard\WireguardHub; use Illuminate\Support\Facades\Crypt; +use Illuminate\Support\Facades\Process; use Illuminate\Support\Str; function hostRun(Host $host, array $context = []): ProvisioningRun @@ -775,12 +778,27 @@ it('refuses to finish a host that has no template to clone from', function () { it('advances once the template a published plan points at is on the node', function () { $s = fakeServices(); - $s['pve']->clonedVmids[] = 9000; // the fake's stand-in for "this VMID exists" + $s['pve']->clonedVmids[] = 9000; // the fake's stand-in for "this VMID exists" + $s['pve']->templateVmids[] = 9000; // …and this one for "it reports template: 1" $run = hostRun(Host::factory()->active()->create()); expect(app(VerifyVmTemplate::class)->execute($run)->type)->toBe('advance'); }); +it('refuses a virtual machine that merely carries the template vmid', function () { + // Existence was the wrong question. An abandoned build leaves a plain VM + // numbered 9000 behind, and a check that asks `vmExists` calls that fine — + // so the gap re-surfaces at the first PAID clone, which is the one failure + // this step exists to move forward in time. `template: 1` is a property + // only a real template has. + $s = fakeServices(); + $s['pve']->clonedVmids[] = 9000; // exists… + // …but templateVmids stays empty: it is not a template. + $run = hostRun(Host::factory()->active()->create()); + + expect(app(VerifyVmTemplate::class)->execute($run)->type)->toBe('fail'); +}); + it('still checks a version that is published but not on sale until next week', function () { // PlanVersion::available() would exclude it, and its template gap would then // surface at its first order rather than here — the whole failure this step @@ -804,6 +822,263 @@ it('requires no template on an installation that has not published a plan yet', expect(app(VerifyVmTemplate::class)->execute($run)->type)->toBe('advance'); }); +// --- BuildVmTemplate --- +// +// The step that closes the last manual handle in the takeover: VerifyVmTemplate +// reports a missing template, this one builds it — by shipping the SAME +// deploy/bootstrap/lib/template.sh that the rescue path uses, never a second +// copy of its checks in PHP. + +/** What the host answers when asked about a build in flight. */ +function scriptBuildStatus(FakeRemoteShell $shell, string $state, bool $alive = false, string $phase = '', string $note = ''): void +{ + $shell->script('clupilot-template-status', CommandResult::success( + "state={$state}\nalive=".($alive ? 'yes' : 'no')."\nphase={$phase}\nnote={$note}\n" + )); +} + +it('builds nothing on an installation that has not published a plan yet', function () { + $s = fakeServices(); + PlanVersion::query()->update(['published_at' => null]); + $run = hostRun(Host::factory()->active()->create()); + + expect(app(BuildVmTemplate::class)->execute($run)->type)->toBe('advance') + ->and($s['shell']->files())->toBe([]) + ->and($s['shell']->ran('clupilot-template-start'))->toBeFalse(); +}); + +it('leaves a template that is already built alone', function () { + $s = fakeServices(); + $s['pve']->templateVmids[] = 9000; + $run = hostRun(Host::factory()->active()->create()); + + // Twenty minutes and a `qm destroy --purge` is what NOT skipping costs, and + // this step runs again on every retry and every later maintenance run. + expect(app(BuildVmTemplate::class)->execute($run)->type)->toBe('advance') + ->and($s['shell']->ran('clupilot-template-start'))->toBeFalse(); +}); + +it('builds when the vmid exists but is not a template', function () { + // The half-built leftover. Asking `vmExists` here would inherit exactly the + // weakness VerifyVmTemplate just lost, one step earlier. + $s = fakeServices(); + $s['pve']->clonedVmids[] = 9000; + $run = hostRun(Host::factory()->active()->create()); + + expect(app(BuildVmTemplate::class)->execute($run)->type)->toBe('poll') + ->and($s['shell']->ran('clupilot-template-start'))->toBeTrue(); +}); + +it('ships the template script verbatim and starts it detached', function () { + $s = fakeServices(); + $run = hostRun(Host::factory()->active()->create()); + + $result = app(BuildVmTemplate::class)->execute($run); + $files = $s['shell']->files(); + + expect($result->type)->toBe('poll') + // Verbatim, byte for byte. The moment this is rendered from a PHP + // template instead of read from the file, there are two fassungen of + // the three traps again — the one thing the design forbids. + ->and($files['/var/lib/clupilot/template-build/template.sh'] ?? null) + ->toBe(file_get_contents(base_path('deploy/bootstrap/lib/template.sh'))) + ->and($files['/var/lib/clupilot/template-build/template-run.sh'] ?? null) + ->toBe(file_get_contents(base_path('deploy/bootstrap/lib/template-run.sh'))) + ->and($files['/var/lib/clupilot/template-build/docker-compose.yml'] ?? null) + ->toBe(file_get_contents(base_path('deploy/bootstrap/assets/docker-compose.yml'))) + // The VMIDs the catalogue actually asks for, not a number baked in here. + ->and($files['/var/lib/clupilot/template-build/env'] ?? '')->toContain('9000') + ->and($s['shell']->ran('nohup'))->toBeTrue(); +}); + +it('keeps polling while the build process is alive', function () { + $s = fakeServices(); + scriptBuildStatus($s['shell'], 'running', alive: true, phase: 'umbauen'); + $run = hostRun(Host::factory()->active()->create(), ['template_build_deadline' => now()->addMinutes(30)->toIso8601String()]); + + $result = app(BuildVmTemplate::class)->execute($run); + + // Starting a second one means two virt-customize on one file, and both of + // them calling `qm create 9000`. + expect($result->type)->toBe('poll') + ->and($result->reason)->toContain('umbauen') + ->and($s['shell']->ran('clupilot-template-start'))->toBeFalse(); +}); + +it('fails at once when the state file says running but the process is gone', function () { + // A reboot, an OOM kill, an operator's Ctrl-C. The file still says running + // and always will — nobody is left to change it. Polling on the strength of + // that word waits forty-five minutes against a corpse. + $s = fakeServices(); + scriptBuildStatus($s['shell'], 'running', alive: false); + $run = hostRun(Host::factory()->active()->create(), ['template_build_deadline' => now()->addMinutes(30)->toIso8601String()]); + + expect(app(BuildVmTemplate::class)->execute($run)->type)->toBe('fail'); +}); + +it('fails when a build that was started left no trace behind', function () { + // The start command writes `state` itself, in the same breath as launching, + // so an empty status on a LATER visit is not "not started yet" — it is a + // start that did not take (a full disk, a wiped work dir). Reading it as + // "not started yet" re-uploads and re-launches every thirty seconds for an + // hour, because poll() never spends the retry budget. + $s = fakeServices(); + $run = hostRun(Host::factory()->active()->create(), ['template_build_deadline' => now()->addMinutes(30)->toIso8601String()]); + + expect(app(BuildVmTemplate::class)->execute($run)->type)->toBe('fail') + ->and($s['shell']->ran('clupilot-template-start'))->toBeFalse(); +}); + +it('lets an operator retry a failed build from scratch', function () { + // giveUp() clears the deadline as well as the state — otherwise the check + // above turns every Retry after a failure into an instant second failure, + // without the host being touched at all. + $s = fakeServices(); + scriptBuildStatus($s['shell'], 'failed', note: 'kein Platz'); + $run = hostRun(Host::factory()->active()->create(), ['template_build_deadline' => now()->addMinutes(30)->toIso8601String()]); + + app(BuildVmTemplate::class)->execute($run); + + expect($run->fresh()->context('template_build_deadline'))->toBeNull(); +}); + +it('reports the reason the build itself wrote, and clears the state for a retry', function () { + $s = fakeServices(); + scriptBuildStatus($s['shell'], 'failed', note: 'kein Proxmox-Speicher gefunden, der Platten aufnimmt'); + $run = hostRun(Host::factory()->active()->create(), ['template_build_deadline' => now()->addMinutes(30)->toIso8601String()]); + + $result = app(BuildVmTemplate::class)->execute($run); + + expect($result->type)->toBe('fail') + ->and($result->reason)->toContain('Platten aufnimmt') + // Otherwise the operator's Retry reads the old verdict and fails again + // without the host being touched. + ->and($s['shell']->ran('clupilot-template-reset'))->toBeTrue(); +}); + +it('refuses to advance when the build says ok but no template is there', function () { + // The script's own last check and this one are deliberately both present: + // one asks the host, the other asks the API the customer pipeline will use. + $s = fakeServices(); + scriptBuildStatus($s['shell'], 'ok'); + $run = hostRun(Host::factory()->active()->create(), ['template_build_deadline' => now()->addMinutes(30)->toIso8601String()]); + + expect(app(BuildVmTemplate::class)->execute($run)->type)->toBe('fail'); +}); + +it('advances once the finished build is visible as a template through the api', function () { + $s = fakeServices(); + $s['pve']->templateVmids[] = 9000; + scriptBuildStatus($s['shell'], 'ok'); + $run = hostRun(Host::factory()->active()->create(), ['template_build_deadline' => now()->addMinutes(30)->toIso8601String()]); + + expect(app(BuildVmTemplate::class)->execute($run)->type)->toBe('advance'); +}); + +it('gives up on a build that outlives its deadline', function () { + $s = fakeServices(); + scriptBuildStatus($s['shell'], 'running', alive: true); + $run = hostRun(Host::factory()->active()->create(), ['template_build_deadline' => now()->subMinute()->toIso8601String()]); + + expect(app(BuildVmTemplate::class)->execute($run)->type)->toBe('fail'); +}); + +it('stops a build it is giving up on, instead of leaving it running', function () { + // Giving up while the process is still alive and only deleting the status + // files makes the run retryable while the old build carries on: two + // virt-customize on one build.qcow2, and both of them calling + // `qm destroy 9000 --purge` followed by `qm create 9000`. + $s = fakeServices(); + scriptBuildStatus($s['shell'], 'running', alive: true); + $run = hostRun(Host::factory()->active()->create(), ['template_build_deadline' => now()->subMinute()->toIso8601String()]); + + app(BuildVmTemplate::class)->execute($run); + + expect($s['shell']->ran('clupilot-template-reset'))->toBeTrue() + // The whole process group, not just the driver: killing `sh` alone + // leaves the virt-customize it is waiting on chewing on the image. + ->and($s['shell']->ran('kill -TERM'))->toBeTrue(); +}); + +it('ignores a finished status left behind by an earlier run', function () { + // Status files outlive the run that wrote them. A later run — a new + // template in the catalogue, a maintenance pass — finds `ok` on disk from + // the build that produced the OTHER template. Read as this run's own + // result, it says "the build I started finished without producing what I + // asked for", and fails a host that only needed a build to be started. + $s = fakeServices(); + scriptBuildStatus($s['shell'], 'ok'); + $run = hostRun(Host::factory()->active()->create()); // kein Termin: dieser Lauf hat nichts gestartet + + expect(app(BuildVmTemplate::class)->execute($run)->type)->toBe('poll') + ->and($s['shell']->ran('clupilot-template-start'))->toBeTrue(); +}); + +it('adopts a build another run already has in flight', function () { + // The one status from an earlier run that must NOT be overwritten: a live + // process building the very same thing. Starting beside it is the two-on- + // one-image collision again, arrived at from the other side. + $s = fakeServices(); + scriptBuildStatus($s['shell'], 'running', alive: true, phase: 'umbauen'); + $run = hostRun(Host::factory()->active()->create()); + + expect(app(BuildVmTemplate::class)->execute($run)->type)->toBe('poll') + ->and($s['shell']->ran('clupilot-template-start'))->toBeFalse() + // Adopted with a deadline of its own, so it cannot be polled forever. + ->and($run->fresh()->context('template_build_deadline'))->not->toBeNull(); +}); + +it('sends only shell that a shell can actually parse', function () { + // Every other test here reads these commands as strings — FakeRemoteShell + // never runs one. So a stray quote in the start or reset command would + // reach a real host as the first thing anybody executes, and the step it + // breaks is the one that destroys and rebuilds VMs. `sh -n` parses without + // executing, which is exactly the reassurance that is missing. + $s = fakeServices(); + $host = Host::factory()->active()->create(); + + app(BuildVmTemplate::class)->execute(hostRun($host)); // status + start + scriptBuildStatus($s['shell'], 'failed', note: 'nope'); + // Mit Termin, damit der Status als DIESES Laufs gelesen wird und der + // Aufgeben-Zweig (Protokoll lesen, Prozessgruppe beenden) wirklich läuft. + app(BuildVmTemplate::class)->execute( + hostRun($host, ['template_build_deadline' => now()->addMinutes(30)->toIso8601String()]) + ); // status + log + reset + + $checked = 0; + foreach ($s['shell']->recorded() as $command) { + expect(Process::input($command)->run('sh -n')->successful()) + ->toBeTrue("not valid POSIX shell:\n".$command); + $checked++; + } + + expect($checked)->toBeGreaterThanOrEqual(4); +}); + +it('rebuilds only the template that is actually missing', function () { + // Two published packages pointing at two different templates, one of them + // already built. Handing both VMIDs to the script destroys the healthy one + // on the way past — create_proxmox_template purges before it creates — and + // if the rebuild then fails, a template that was fine is simply gone. + $s = fakeServices(); + $family = PlanFamily::query()->create(['key' => 'zweite-vorlage', 'name' => 'Zweite Vorlage', 'tier' => 9]); + $version = $family->versions()->create([ + 'version' => 1, 'quota_gb' => 10, 'traffic_gb' => 100, 'seats' => 1, 'ram_mb' => 1024, + 'cores' => 1, 'disk_gb' => 20, 'performance' => 'standard', 'features' => [], 'available_from' => now(), + ]); + $version->update(['published_at' => now(), 'template_vmid' => 9001]); + + $s['pve']->templateVmids[] = 9000; // die gesunde, vorhandene Vorlage + $run = hostRun(Host::factory()->active()->create()); + + $result = app(BuildVmTemplate::class)->execute($run); + $env = $s['shell']->files()['/var/lib/clupilot/template-build/env'] ?? ''; + + expect($result->type)->toBe('poll') + ->and($env)->toContain('9001') + ->and($env)->not->toContain('9000'); +}); + // --- VerifyProxmoxApi --- it('advances when the api returns nodes', function () { diff --git a/tests/Feature/Provisioning/ServicesTest.php b/tests/Feature/Provisioning/ServicesTest.php index afe0bb7..2132f88 100644 --- a/tests/Feature/Provisioning/ServicesTest.php +++ b/tests/Feature/Provisioning/ServicesTest.php @@ -5,11 +5,14 @@ use App\Provisioning\Jobs\RemoveWireguardPeer; use App\Services\Dns\FileHostDnsDirectory; use App\Services\Dns\HttpHetznerDnsClient; use App\Services\Proxmox\FakeProxmoxClient; +use App\Services\Proxmox\HttpProxmoxClient; use App\Services\Ssh\CommandResult; use App\Services\Ssh\FakeRemoteShell; use App\Services\Wireguard\FakeWireguardHub; use App\Services\Wireguard\LocalWireguardHub; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\File; +use Illuminate\Support\Facades\Http; it('scripts remote command output and records calls (FakeRemoteShell)', function () { $shell = new FakeRemoteShell; @@ -108,6 +111,55 @@ it('reports node capacity for a host (FakeProxmoxClient)', function () { ->and($client->nodeStorage('pve')[0]['total'])->toBeGreaterThan(0); }); +// --- HttpProxmoxClient::isTemplate ------------------------------------------ +// +// Die eine Frage in diesem Client, deren falsche Antwort ETWAS ZERSTÖRT: +// BuildVmTemplate liest ein `false` als „Vorlage fehlt" und startet einen Bau, +// der mit `qm destroy --purge` anfängt. Eine Störung von zwanzig Sekunden darf +// deshalb niemals wie eine fehlende Vorlage aussehen. + +function proxmoxHost(): Host +{ + return Host::factory()->create(['wg_ip' => '10.66.0.5', 'api_token_ref' => 'automation@pve!clupilot=secret']); +} + +it('never mistakes a proxmox outage for a missing template', function () { + Http::fake(['*' => Http::response('gateway timeout', 502)]); + + // Muss WERFEN, nicht false liefern: der Aufrufer fängt Ausnahmen und + // wiederholt, und genau dieser Weg rettet die vorhandene Vorlage. + expect(fn () => app(HttpProxmoxClient::class)->forHost(proxmoxHost())->isTemplate('pve', 9000)) + ->toThrow(RequestException::class); +}); + +it('reports a vmid that is not on the node at all as no template', function () { + // Die Liste ist eindeutig, wo der Rückgabecode es nicht ist: Proxmox + // beantwortet die Konfiguration einer nicht vorhandenen VM mit 500, nicht + // mit 404 — „nicht da" und „kaputt" wären am Code nicht zu unterscheiden. + Http::fake(['*/nodes/pve/qemu' => Http::response(['data' => [['vmid' => 101, 'template' => 0]]])]); + + expect(app(HttpProxmoxClient::class)->forHost(proxmoxHost())->isTemplate('pve', 9000))->toBeFalse(); +}); + +it('confirms a template from the configuration document', function () { + Http::fake([ + '*/nodes/pve/qemu' => Http::response(['data' => [['vmid' => 9000]]]), + '*/nodes/pve/qemu/9000/config' => Http::response(['data' => ['template' => 1, 'name' => 'clupilot-nextcloud-vorlage']]), + ]); + + expect(app(HttpProxmoxClient::class)->forHost(proxmoxHost())->isTemplate('pve', 9000))->toBeTrue(); +}); + +it('reports an ordinary virtual machine carrying the vmid as no template', function () { + // Der abgebrochene Bau: VMID 9000 ist da, `template: 1` ist es nicht. + Http::fake([ + '*/nodes/pve/qemu' => Http::response(['data' => [['vmid' => 9000]]]), + '*/nodes/pve/qemu/9000/config' => Http::response(['data' => ['name' => 'halbfertig']]), + ]); + + expect(app(HttpProxmoxClient::class)->forHost(proxmoxHost())->isTemplate('pve', 9000))->toBeFalse(); +}); + // Der Hetzner-DNS-Client steht in HetznerCloudDnsTest — seit dem Umzug auf die // Cloud-API (RRSets statt einzelner Records) gehört dazu mehr als zwei Fälle. // Die alte Seitenlauf-Falle, die hier stand, kann es nicht mehr geben: es gibt diff --git a/tests/Feature/Readiness/ActiveChecksTest.php b/tests/Feature/Readiness/ActiveChecksTest.php index 99529f7..ecfc5de 100644 --- a/tests/Feature/Readiness/ActiveChecksTest.php +++ b/tests/Feature/Readiness/ActiveChecksTest.php @@ -336,7 +336,11 @@ it('is satisfied once the template actually exists on an active host', function Http::fake(); $pve = new FakeProxmoxClient; + // Present AND marked as a template — the two facts VmTemplateCheck now + // tells apart, because "exists" let an abandoned build pass for a + // finished one and the failure then surfaced after payment. $pve->clonedVmids = [4001]; + $pve->templateVmids = [4001]; app()->instance(ProxmoxClient::class, $pve); Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'pve']); @@ -377,7 +381,7 @@ it('does not confuse a silent host with a confirmed-missing template', function $silent = new class extends FakeProxmoxClient { - public function vmExists(string $node, int $vmid): bool + public function isTemplate(string $node, int $vmid): bool { throw new RuntimeException('proxmox unreachable'); } @@ -416,16 +420,16 @@ it('still confirms a template that a working host has, despite a silent one', fu $mixed = new class extends FakeProxmoxClient { - public function vmExists(string $node, int $vmid): bool + public function isTemplate(string $node, int $vmid): bool { if ($this->host?->node === 'silent-node') { throw new RuntimeException('proxmox unreachable'); } - return parent::vmExists($node, $vmid); + return parent::isTemplate($node, $vmid); } }; - $mixed->clonedVmids = [4005]; // what the WORKING host actually has + $mixed->templateVmids = [4005]; // what the WORKING host actually has app()->instance(ProxmoxClient::class, $mixed); Host::factory()->create(['status' => 'active', 'api_token_ref' => 'ref-1', 'node' => 'silent-node']);