Release v1.3.88 — die Vorlage baut sich selbst
tests / pest (push) Failing after 9m17s Details
tests / assets (push) Successful in 22s Details
tests / release (push) Has been skipped Details

Der letzte Handgriff in der Host-Übernahme fällt weg. VerifyVmTemplate meldete
bisher nur, dass eine Vorlage fehlt, weil niemand entschieden hatte, was in die
goldene Vorlage gehört. Entschieden ist es längst und steht in
deploy/bootstrap/lib/template.sh — der neue Schritt BuildVmTemplate lädt genau
diese Datei auf den Host und führt sie dort aus, statt ihre Prüfungen ein
zweites Mal in PHP zu haben.

Er läuft abgekoppelt und wird abgefragt: Abbild laden und drei
virt-customize-Läufe brauchen zehn bis zwanzig Minuten, ein einzelner
SSH-Aufruf liefe gegen den Befehlszeitablauf von 2000 s. "Läuft noch" heißt
dabei, dass der Prozess lebt (kill -0 gegen die hinterlegte PID) — in der
Statusdatei steht "running" auch dann noch, wenn niemand mehr da ist, der sie
ändert.

Fünf Fehler, die dabei aufgefallen sind und Geld gekostet hätten:

- qm importdisk hängte die Platte unter ${storage}:vm-9000-disk-0 ein. Der Name
  gilt nur bei Block-Ablagen; auf einer Verzeichnis-Ablage heißt sie
  local:9000/vm-9000-disk-0.qcow2 — also genau auf dem per Debian aufgesetzten
  Proxmox, um das es hier geht. Jetzt qm set --import-from, und Proxmox
  benennt selbst.
- growpart war nie installiert. GrowGuestFilesystem ruft es auf, und es lief
  bisher, weil Debians Cloud-Abbild es zufällig mitbringt. Fiele es heraus,
  läge jedes gekaufte Kontingent über einem Dateisystem, das nie gewachsen ist.
  Jetzt ausdrücklich eingebaut und als vierte Falle nachgewiesen.
- local nimmt ab Werk keine Platten an. Ohne das stirbt nicht nur der Bau,
  RegisterCapacity meldet danach Kapazität 0: ein Host, der fertig aussieht und
  nie einen Kunden tragen kann. ensure_image_storage greift nur ein, wenn keine
  Ablage Platten annimmt, hängt images an die vorhandene Liste an statt sie zu
  ersetzen, und schreibt über pvesm set statt in die pmxcfs-Datei.
- Ein abgebrochener Download blieb unter dem Zielnamen liegen und wäre beim
  nächsten Lauf ungeprüft weiterbenutzt worden. Jetzt .part, umbenannt erst
  nach geprüfter Summe.
- VerifyVmTemplate und VmTemplateCheck fragten nur, ob VMID 9000 existiert. Ein
  abgebrochener Bau hinterlässt eine gewöhnliche VM mit dieser Nummer, und
  beide sagten dazu "passt" — der Fehler kam beim ersten bezahlten Klon zurück.
  Jetzt template: 1.

isTemplate() stellt zwei Anfragen, weil die falsche Antwort hier etwas
zerstört: false heißt "Vorlage fehlt", und der Bau fängt mit qm destroy --purge
an. Proxmox beantwortet die Konfiguration einer nicht vorhandenen VM mit 500 —
demselben Code wie einen Knoten in Not. Die VM-Liste klärt deshalb die
Abwesenheit, alles darunter wirft und landet im Wiederholungs-Zweig.

Aufgeben beendet erst die Prozessgruppe, dann räumt es auf, und gebaut wird nur
die Fehlliste: create_proxmox_template räumt eine VMID weg, bevor es sie
anlegt, also hätte "alles Verlangte" eine gesunde zweite Vorlage auf dem Weg
zerstört.

Geprüft: 2267 Tests grün, Pint sauber, sh -n über alle drei Shell-Dateien, die
storage.cfg-Auswertung gegen eine echte Beispieldatei durchgespielt, und jeder
Befehl, den der Schritt absetzt, geht durch sh -n — keine andere Prüfung führt
diese Shell je aus. Drei Codex-Runden (R15), alle Befunde behoben.

Nicht geprüft: nichts davon lief je gegen echte Hardware. Die erste Übernahme
auf einem Proxmox-Host ist die Abnahme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
main v1.3.88
nexxo 2026-08-01 05:01:44 +02:00
parent 97ac6c04e8
commit db125781ed
19 changed files with 1499 additions and 67 deletions

View File

@ -1 +1 @@
1.3.87
1.3.88

View File

@ -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<int, int>
*/
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();
}
}

View File

@ -0,0 +1,344 @@
<?php
namespace App\Provisioning\Steps\Host;
use App\Models\Host;
use App\Models\PlanVersion;
use App\Models\ProvisioningRun;
use App\Provisioning\StepResult;
use App\Services\Proxmox\ProxmoxClient;
use App\Services\Ssh\RemoteShell;
use Illuminate\Support\Carbon;
use Throwable;
/**
* Builds the golden VM template on the host, so that a takeover finishes
* without a single manual step.
*
* This is the other half of VerifyVmTemplate, which deliberately only REPORTED
* a missing template because what goes into the golden image was a product
* decision nobody had made yet. It has been made since, and it is written down
* in deploy/bootstrap/lib/template.sh 252 lines that check three traps, each
* of which cost a paid order once.
*
* Those lines are SHIPPED, never re-implemented here. A PHP copy of the same
* checks would be two installations to keep level with every Proxmox release,
* and the second one only ever reveals itself to whoever runs it. So this step
* uploads template.sh verbatim, next to its driver and the compose file it
* bakes in, and runs it.
*
* ---------------------------------------------------------------------------
* Why it runs detached
* ---------------------------------------------------------------------------
*
* Downloading the cloud image and three virt-customize passes take ten to
* twenty minutes. The per-command SSH timeout is 2000s and the queue job's is
* 2100s: a synchronous call would be racing both, and losing that race in the
* middle of virt-customize leaves a half-built image with nobody able to say
* so. Instead the script is started with nohup and this step comes back every
* 30 seconds each poll is its own short job, and poll() does not consume the
* retry budget.
*
* The pid is what makes that honest. `state` on its own says `running` forever
* once the script dies, because the only process that would have changed it is
* the one that is gone. So "still running" is decided by `kill -0` against the
* recorded pid, never by the word in the file a dead build fails at once
* instead of being polled against for three quarters of an hour, and a live one
* is never started a second time (two virt-customize on one file, both calling
* `qm create 9000`).
*/
class BuildVmTemplate extends HostStep
{
/** Where the script, its assets and its status files live on the host. */
public const WORK_DIR = '/var/lib/clupilot/template-build';
/** Long enough for a slow mirror and an emulated apt; the step's own deadline is shorter. */
private const DEADLINE_MINUTES = 45;
public function __construct(private RemoteShell $shell, private ProxmoxClient $pve) {}
public function key(): string
{
return 'build_vm_template';
}
public function maxDuration(): int
{
// Above DEADLINE_MINUTES so the step's own deadline decides the failure
// and can say what went wrong, rather than the generic step timeout —
// which would additionally spend a retry. Same shape as
// RebootIntoPveKernel.
return 3600;
}
public function execute(ProvisioningRun $run): StepResult
{
$host = $this->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<int, int> $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<int, int> $required
* @return array<int, int>
*/
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<int, int> $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" </dev/null >> "$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 1020 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));
}
}

View File

@ -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<int, int>
*/
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();
}
}

View File

@ -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<int, int>
*/
public array $templateVmids = [];
public function isTemplate(string $node, int $vmid): bool
{
return in_array($vmid, $this->templateVmids, true);
}
/** @var array<int, int> */
public array $deletedVmids = [];

View File

@ -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();

View File

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

View File

@ -24,11 +24,7 @@ final class VmTemplateCheck
/** @return array<string, mixed> */
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;
}

View File

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

View File

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

View File

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

View File

@ -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: "<name> <typ> <inhalt> <pfad>".
# 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 \

View File

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

View File

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

View File

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

View File

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

View File

@ -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 () {

View File

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

View File

@ -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']);