Design Notes¶
The "why" behind decisions in How a Template Is Built and the Specification File Reference — verified findings, rejected alternatives, and the reasoning behind non-obvious choices. Neither of those two documents assumes you've read this one; this one assumes you've read (or are about to read) them, and exists so their own text can stay focused on what the tool does rather than why.
PVE token permissions¶
Why the ACL step is easy to miss¶
PVE API tokens are created with Privilege Separation enabled by
default, meaning the token starts out with no permissions of its own —
even a token for root@pam does not inherit root's rights automatically.
Without an explicit role grant, the token still authenticates fine (so
the token check passes),
but every actual operation gets rejected. The image upload in particular
fails in a very confusing way when the token lacks the needed rights:
instead of a clean 403, it can come back from pveproxy as a bogus "No
space left on device" once the file is more than a few hundred KB (a real
PVE-side bug in how it aborts an in-progress multipart upload, not
anything this tool does).
How the minimal privilege set was derived¶
Most of the privileges in PVE API token setup's
table were read directly out of each API call's own "Permissions" entry
in PVE's API viewer (its
apidoc.js data file specifically, since the viewer itself doesn't
render without JavaScript). That's accurate for the calls it documents
at all (VM.Audit, VM.Allocate, the Datastore.* privileges), but it
turns out to be incomplete for VM creation itself: the API viewer's
"Permissions" entry for POST /nodes/{node}/qemu only mentions
VM.Allocate, but qemu-server's actual handler for that call
(PVE::API2::Qemu's create_vm) additionally runs every key in the
request body through the same per-parameter permission check used by
the PUT (reconfigure) endpoint, $check_vm_modify_config_perm — this
applies just as much to a brand-new VM's initial config as to editing an
existing one. This was found the hard way, from a real 403 on VM
creation (Permission check failed (/vms/<vmid>, VM.Config.HWType)),
not predicted from the API viewer.
That check buckets each config key into one of several privileges, all
scoped to /vms/<vmid> same as VM.Allocate:
| Privilege | Keys |
|---|---|
VM.Config.HWType |
acpi, hotplug, kvm, machine, scsihw, smbios1, tablet, vga, watchdog, audio0, serialN/usbN set to socket/spice |
VM.Config.CPU |
cores, cpu, cpulimit, cpuunits, numa, smp, sockets, vcpus |
VM.Config.Memory |
memory, balloon, shares |
VM.Config.Network |
netN, ipconfigN |
VM.Config.Disk |
boot, bootdisk, vmstatestorage |
VM.Config.Options |
agent, autostart, bios, description, keyboard, localtime, migrate_downtime, migrate_speed, name, onboot, ostype, protection, reboot, startdate, tdf, template, tags (startup needs Sys.Modify instead) |
VM.Config.Cloudinit (or VM.Config.Network) |
cicustom, cipassword, citype, ciuser, nameserver, searchdomain, sshkeys |
Actual disk assignments (scsi0, efidisk0, and any other template.pve.parameters
disk key) aren't in any of these buckets — they go through
check_storage_access instead, which is what the Datastore.*
privileges below actually cover. Which of the table above a given spec
needs depends entirely on which keys template.pve.parameters sets;
in practice every one of the example specs touches
VM.Config.HWType, VM.Config.CPU, VM.Config.Memory,
VM.Config.Network, VM.Config.Disk, and VM.Config.Options at once
(machine/vga/scsihw/serial0, cores/cpu, memory, net0,
boot, and bios/tags/agent/the auto-filled description,
respectively), so the role below just grants all of them — see
One role covering every privilege
for why that's harmless even where a given spec doesn't need one of
them. VM.Config.Cloudinit is the only one worth leaving out by
default: it's only needed if template.pve.parameters sets cicustom,
cipassword, citype, ciuser, nameserver, searchdomain, or
sshkeys directly (ipconfigN alone doesn't need it — that one's
covered by VM.Config.Network already).
If net0's bridge is managed through PVE's SDN subsystem specifically
(not a plain, unmanaged Linux bridge like the example specs' vmbr0),
the token additionally needs SDN.Use on it.
Datastore.Allocate (needed to remove the uploaded image once the VM's
own disk has imported its content) is a slightly misleading name here:
PVE reuses the same privilege for creating/removing a storage
definition itself. Scoped to /storage/<import-storage> rather than
/storage, it only ever means the former in this tool's case.
One role covering every privilege is simplest to set up: PVE only ever checks the privileges relevant to what's actually being done at a given path, so a role carrying privileges that don't apply there is harmless — no need for three narrowly-tailored roles just because the privileges apply to three different kinds of path.
The example role is named template-builder, not pve-template-builder:
pveum rejects role IDs starting with pve (case-insensitively) — that
namespace is reserved for PVE's own built-in roles like PVEVMAdmin. The
token itself (root@pam!pve-template-builder) is unaffected, since token
IDs aren't under that restriction.
Why the /vms/<vmid> grant must come before the first build¶
This works even though vmid doesn't exist yet: PVE's ACLs are just
path-string rules, entirely independent of whether a resource currently
exists at that path — this is exactly how you pre-authorize a VMID
before creating it, and the only way this tool can ever create one at
all, since it's the one creating it.
What doesn't work is the reverse order: this tool's very first PVE API
call for any build, before it creates or touches anything, is checking
whether vmid already exists (GET .../qemu/{vmid}/config, needing
VM.Audit on /vms/<vmid>) — so without this grant already in place,
that check itself gets rejected, and the build fails immediately, before
ever reaching VM.Allocate.
Scoping the role to one specific vmid, rather than the whole /vms
subtree, means the token can never touch any other VM; managing more
than one vmid means either one acl modify line per vmid, or
granting at /vms instead to cover all of them at once.
Publish to Proxmox VE¶
Why the token is checked before anything expensive¶
Skipping this check would still fail eventually, but confusingly: an
authentication failure on the actual upload request doesn't come back as
a clean 401 — PVE accepts the connection and only then rejects it, but
by that point the client is already sending a multi-hundred-MB body, so
what actually surfaces is a low-level connection error (write: broken
pipe or similar) with no indication it was ever about authentication.
Why efidisk0 defaults to pre-enrolled-keys=0¶
This leaves the EFI variable store without a Platform Key enrolled,
which leaves Secure Boot unenforced — OVMF has nothing to verify a
bootloader's signature against, so it just boots whatever's there. This
matters because builder.steps produces an unsigned bootloader by
default (e.g. a plain bootctl install of systemd-boot): with the old
default of pre-enrolled-keys=1 (Secure Boot pre-armed with the
standard Microsoft/UEFI CA keys), such a bootloader fails to load at
all, with OVMF's BdsDxe reporting "Access Denied". Only set
efi-disk: {pre-enrolled-keys: true} if the image's bootloader is
actually signed for Secure Boot (e.g. systemd-boot-efi-signed
chain-loaded via shim, not just installed directly).
Why converting to a template is a separate API call¶
Easy to assume this is redundant with setting template: true in
parameters, but it isn't: that only sets metadata (prevents the VM
from starting, shows the template icon in the UI). The disks themselves
are still plain vm-<vmid>-disk-N volumes until the dedicated template
API call renames them into base volumes and creates the storage-level
snapshots a linked clone needs. Skip it, and cloning the "template"
later fails with something like Linked clone feature is not supported
for '<storage>:vm-<vmid>-disk-N' (scsi0) — the disk name in that error
is the tell: a properly converted template's disk would be named
base-<vmid>-disk-N.
Why import-storage and storage are separate fields¶
Even though they're often the same PVE storage in practice: a storage
needs the import content type to receive the uploaded image, and a
possibly different one needs the images content type to actually host
a VM's disks — PVE storages don't necessarily support both, and using
one where the other is required fails with a clean storage '<name>'
does not support vm images (or a similar import counterpart) from the
API.
Bootstrapping with a local package mirror¶
Setting cache makes the packages debootstrap needs get downloaded
once and reused across builds, instead of being fetched fresh from the
network every time. This is more involved than it sounds, because
debootstrap fetches packages itself rather than through apt, so
there's no existing package cache to just point it at — one has to be
built:
- Determine the package list:
debootstrap --no-check-gpg --print-debs <suite> <scratch-dir> <mirror>prints exactly the packages a real run would install, without installing anything.--no-check-gpgis used here specifically becausedebootstrapverifies theReleasefile's signature viasqv/gpgvas part of this, andsqv(at least the version this was tested against) prints the verifying key's fingerprint to stdout on success — which would otherwise corrupt the package list being parsed. This is safe: the actual package files are still verified against the trusted keyring by theapt-getcalls that follow. - Download those packages: an isolated
apt-get updatefollowed byapt-get download <packages...>. "Isolated" means a scratchsources.listcontaining only adeb [arch=<arch>] <mirror> <suite> <components>line, andDir::Etc::sourcelist,Dir::State::lists,Dir::CacheandDir::State::statusall pointed at scratch locations under<cache>/<mirror>/.build/(see step 3 for what<mirror>is), rather than the host's real apt configuration and state. TheDir::State::statusisolation (an empty file) matters in particular: without it, apt's candidate resolution considers whatever happens to already be installed on the build host, and a package installed there at a version that doesn't quite match the target suite's (e.g. the host tracking testing/unstable) fails to resolve to any download source at all. Ifmirroris recognized as a Debian or Ubuntu one, that scratch line also getssigned-by=<path>pointed at the matching keyring installed on the build host (not the target, which doesn't exist yet at this point); missing entirely, this fails with a clear error up front instead ofapt-get updateitself failing with an unrelated-looking "Missing key ..." once it can't verify theReleasefile it just downloaded. An unrecognizedmirrorgets nosigned-byat all, falling back to the host's own default apt trust store. - Sort the downloads into a proper mirror layout:
apt-movemoves the downloaded.debfiles into apool/hierarchy matching the real Debian archive layout, rooted at<cache>joined withmirror's own host and path (e.g.<cache>/archive.ubuntu.com/ubuntu) — not<cache>itself.pool/is shared and suite-blind by design (packages from every suite of the same origin are meant to accumulate there together, across builds), but two specs that happen to set the samecachedirectory for two different origins (e.g. one for Debian, one for Ubuntu) must never end up sharing onepool/— with no separation, the index regeneration in step 4 would index both archives' packages into the samePackagesfile, anddebootstrap's resolver can pick a same-named, same-arch package from the other distro entirely, with no version coordination between them at all. This was a real, observed bug: a Debianlibc6breaking an Ubuntu build'sbase-files. - Regenerate the package index: the
Packages/Packages.gz/Releasefilesapt-moveitself produces are immediately overwritten usingapt-ftparchive. This is necessary because the packagedapt-move(4.2.27 in Debian) only ever emits anMD5sum:field per package and noSHA256:section inRelease, while currentdebootstraprequires SHA256 checksums for both the index files themselves and each individual package — without this step, every single package would faildebootstrap's download validation ("was corrupt"), even though the file itself is fine. - Bootstrap from the local mirror: the real
debootstrapinvocation usesfile://<cache>/<mirror>as its mirror instead of the configured one, with--no-check-gpgadded (the local mirror has no signedReleasefile).
The scratch state (steps 1–2) lives under <cache>/<mirror>/.build, so
re-running a build against the same cache directory and the same
mirror reuses the already-fetched apt package indexes rather than
re-downloading them. Two different suites of the same mirror (e.g.
bookworm and trixie off deb.debian.org/debian) do share this same
pool/ — that's intentional, since packages common to both suites are
then only ever downloaded once.
ca-certificates and the HTTPS chicken-and-egg problem¶
When mirror is an https:// URL, ca-certificates is automatically
added to the effective --include list, if it isn't there already, to
avoid a chicken-and-egg problem: the base apt source (below) also points
at mirror, for later package steps to use — but a freshly
bootstrapped system has no CA trust store yet, so its own apt-get
can't establish a TLS connection to fetch ca-certificates, the one
package that would fix that. debootstrap itself doesn't hit this
problem because it fetches packages using the host's tools (already
trusted), not the chroot's — so including ca-certificates in the base
install sidesteps the problem entirely.
Base apt source Signed-By logic¶
The Signed-By: line in the automatically-written base apt source is
only included if mirror is recognized as a Debian or Ubuntu one
(matched by hostname) and the matching keyring file actually exists in
the target — which it does, since installing it is part of the base
debootstrap install for that distribution. Matching by mirror rather
than just using whichever keyring happens to be present matters in
principle (though the target only ever has the one distribution's
keyring installed in practice, so this is mostly future-proofing): if it
doesn't match, or the matching keyring isn't there, the entry falls back
on the trust debootstrap already established by installing the
archive's keyring into the target's /etc/apt/trusted.gpg.d, the same
way a plain deb <mirror> ... line in a classic sources.list would —
apt still honors that, just with a "Missing Signed-By" notice on every
invocation since it can no longer tell which keyring actually vouches
for this particular source.
Presets¶
Why ubuntu-serial targets noble, not the newest release¶
Verified directly against the archive: starting with 26.04 ("resolute"),
Ubuntu carries both the traditional GNU implementation of several core
tools (gnu-coreutils, sudo) and a Rust reimplementation
(rust-coreutils/coreutils-from-uutils, sudo-rs) side by side,
mid-transition, with both sides of each pair marked required/important
simultaneously and no Conflicts declared between the two coreutils
implementations. debootstrap's package selection is a priority-ordered
walk, not a real dependency solver the way apt is, so it ends up
trying to unpack both — and its raw second-stage dpkg unpack (unlike a
normal apt install, which runs full Replaces-aware conflict
resolution) fails outright the moment it hits a file both packages ship
(e.g. both providing a touch.1.gz man page symlink, pointing at
different targets).
There's no --exclude-based workaround either: debootstrap only
applies --exclude to its initial package list, not to packages its
own dependency resolution pulls back in afterward to satisfy some other
package's Depends/Pre-Depends — which is exactly what happens here
regardless. Ubuntu's own debootstrap package doesn't special-case this
yet either (checked directly: identical fallback logic to Debian's for
resolute).
noble predates this transition entirely, sidestepping the whole class
of problem — if a future preset needs a newer release once this settles
upstream, ubuntu-serial's suite/include/exclude fields are
exactly what to revisit.
Why ubuntu-serial includes fdisk, initramfs-tools, and excludes usrmerge¶
fdisk, for the shared boot-setup step's grow-root script, which
calls sfdisk. sfdisk lives in the fdisk package on both distros,
but Ubuntu ships it at Priority: optional (not installed by a plain
debootstrap unless asked for by name), while Debian's is Priority:
important (installed by default) — verified directly against both
archives' Packages indices.
initramfs-tools: Ubuntu's kernel package only Recommends it (as
one half of an alternative with linux-initramfs-tool), never Depends
on it, and debootstrap never installs Recommends. Debian's kernel
package Depends on it outright, so debian-serial needs no such
override. Without it, no initramfs ever gets built, and boot setup's own
kernel-install step later fails outright with no
/boot/initrd.img-$KVER to hand off — verified by bootstrapping a real,
minimal Ubuntu system and checking directly: the kernel package's own
postinst creates the top-level /boot/vmlinuz and /boot/initrd.img
symlinks unconditionally, but they point to a target that plain
debootstrap alone never actually produces.
usrmerge (excluded): it's the legacy transitional package for
migrating an existing system to a merged /usr, which a system
debootstrap creates fresh already is from the start, and recent Ubuntu
suites have dropped it from the archive entirely — a debootstrap too
old to know that still asks for it by name regardless, and fails with a
confusing "Unable to locate package usrmerge" rather than a clean
complaint about being out of date.
Why ubuntu-serial skips the signed systemd-boot-efi-amd64-signed package¶
Ubuntu dropped it from its archive. That's fine here regardless, since
efidisk0's own pre-enrolled-keys=0 default already leaves Secure
Boot unenforced for an unsigned bootloader either way — see
why efidisk0 defaults to pre-enrolled-keys=0
above.
Build progress output, in detail¶
The terminal UI's exact layout mechanics, for anyone modifying internal/ui:
if the header, every task/step and the (normally 8-line) scrolling log
window can't all fit the terminal at once, the log window is shrunk
first — down to as little as 30% of the space left after the header,
never below 2 lines — and only if the task list still doesn't fit even
then does it scroll too, keeping the currently running task/step
visible along with the following couple of tasks/steps where there's
room, so scrolling forward doesn't strand the current one right at the
bottom edge with nothing upcoming shown yet — with a blue ↑/↓ in place of
the first/last row, each followed by how many rows are scrolled out of
view that way (e.g. "↓ 3 more tasks"). The current task/step is
highlighted (bold text on a light yellow background, with a bold black
indicator). The header stays visible throughout the task list, and
through a short success summary or the start of a failure report — only
a long failure report (with its up to 20 log lines) can eventually
scroll it away, the same way any other lengthy terminal output would.