Reference: VM & TemplateVM¶
Machine resources ProxOps reconciles on PVE.
VM— a PVE qemu object ProxOps owns end-to-end: hardware, disks, networks, options, cloud-init data and power state.TemplateVM— akind: VMpromoted to a PVE template (template=1). Its schema surface is identical toVM(state must bestopped); ProxOps creates it withPOST /qemu+POST /qemu/{id}/templateand manages it through the standard/configsurface. A VM provisions from one withspec.clone(see Provisioning a VM from a TemplateVM).
Identity & id-space — (spec.node, spec.vmid). PVE's per-node integer
pool is shared by the qm and lxc kinds: a VM/TemplateVM and an
LXC/TemplateCT can never claim the same (node, vmid) in one cluster.
ProxOps never invents PVE ids.
VMs live under vm/; TemplateVMs under templatevm/.
kind: VM¶
| Field | Required | PVE wire | Semantics |
|---|---|---|---|
spec.node |
yes | — | Proxmox node hosting the VM. Node identity, never a DNS host. |
spec.vmid |
yes | vmid |
PVE VM ID. Pinned; PVE's per-node id space is one integer pool shared across VMs and LXCs. |
spec.state |
no (default started) |
power | started | stopped. Drift reconciles power. |
spec.memory |
yes | memory (MiB) |
e.g. 8GiB, 512MiB. PVE stores an integer MiB count. |
spec.cpu.type |
yes | cpu |
host, kvm64, x86-64-v2-Aes… |
spec.cpu.cores |
yes | cores |
PVE cores int. |
spec.disks |
≥1 | one scsiX/... per entry |
Ordered; see Disk. |
spec.networks |
≥1 | one netX per entry |
Ordered; see NIC. |
spec.hardware |
no | see Hardware | First-class hardware (bios, machine, display, cdrom/ISO, efi, cloud-init, serial, tpm). |
spec.options |
no | see Options | VM Options panel (onboot, protection, agent, …). |
spec.pve-name |
no | name |
Defaults to metadata.name. |
spec.pve-description |
no | description |
PVE description string. |
spec.tags |
no | tags |
PVE user tags; proxops is added automatically. |
spec.extra |
no | passthrough | Free-form PVE keys for unmodeled properties. |
spec.clone |
no | clone endpoint | A TemplateVM metadata.name. When set, the VM is provisioned by a PVE full clone of that template instead of a fresh create, then configured with this manifest's own values. See Provisioning from a TemplateVM below. A clone-backed VM MUST NOT declare spec.disks (it inherits the template's disk layout). |
Disk¶
| Field | Required | PVE wire | Semantics |
|---|---|---|---|
storage |
yes | <slot>=<storage>:<GiB> |
PVE storage backend id. |
size |
yes* | size in <slot> |
Human size (50GiB); the number after the pool is GiB on the PVE 9.2 wire. *Must be empty when image is set (an imported disk takes its size from the image). |
image |
no | <slot>=<storage>:0,import-from=<img-storage>:import/<filename> |
A DiskImage metadata.name. Seeds this disk at create from a downloadable cloud image via PVE 9's import-from (the size token is forced to 0). Creates a structured VM → DiskImage dependency: the image is downloaded on the VM's node before the VM is created. See kind: DiskImage. |
interface |
no (default scsi0) |
— | Explicit PVE slot (scsi0, scsi1, sata2, virtio0, …). |
controller |
no | VM-wide scsihw |
PVE scsihw value; first disk declaring it wins. |
iothread |
no | ,iothread=1 inline |
Dedicated I/O thread. |
discard |
no | ,discard=<ignore\|on> inline |
TRIM/discard passthrough (PVE 9.2: accepted on every bus). Omitted = not owned. |
ssd |
no | ,ssd=<0\|1> inline |
Advertise an SSD to the guest. scsi/sata/ide only — PVE 9.2 rejects ssd= on virtio/nvme (property is not defined in schema), so Validate fails closed. Tri-state: omitted = not owned; false = pinned ssd=0 (PVE retains an explicit 0 in the report). |
aio |
no | ,aio=<native\|threads\|io_uring> inline |
Async I/O engine (PVE 9.2: accepted on every bus). Omitted = not owned. |
mbcacheis not modelled: PVE 9.2's drive schema rejects it (probe- verified 400property is not defined in schema). It was removed from the PVE 9 docs; see GAPS.md.
Image-seeded disks are imported once (at create, or when the slot is
empty). PVE does not re-report the import-from option after create (the
/config shows a plain local-lvm:vm-N-disk-0,size=3G), and the volume size
comes from the image's virtual size, so ProxOps compares only the pool
on a live image-seeded disk. A live volume at a different pool is a
non-destructive anomaly (the data-loss guard), never a re-import.
NIC¶
| Field | Required | PVE wire | Semantics |
|---|---|---|---|
model |
yes (default virtio) |
netX |
virtio, e1000, rtl8139, vmxnet3… |
bridge |
yes | ,bridge=BRIDGE |
PVE bridge (e.g. vmbr0). |
mac |
no | model=MAC,… |
Pinned MAC; empty → PVE assigns one (not reported as drift). |
vlan |
no | ,tag=NN |
802.1Q tag (1–4094). PVE wire key is tag (probed PVE 9.2: vlan is rejected with “property is not defined in schema”). |
rate-limit |
no | ,rate=NN |
PVE MBit/s rate limit. |
firewall |
no | ,firewall=1 |
PVE per-NIC firewall. |
slot |
no | netN |
NIC slot (default: order). |
Hardware¶
| Field | Required | PVE wire | Semantics |
|---|---|---|---|
machine |
no | machine |
i440fx (default) | q35. Stop-required to change. |
bios |
no | bios |
seabios (default) | ovmf on PVE 9.2. Stop-required. |
display |
no | vga |
e.g. std. |
cdrom (block) |
no | ide2 / ide3 |
Three-state CD/DVD ownership (see below). |
cdrom.iso (attach) |
no | ide2/ide3 = <storage>:iso/<filename>,media=cdrom |
An ISO metadata.name. Attaches the ISO to the CD/DVD slot and creates a structured VM → ISO dependency: the VM is not created until the ISO has been downloaded on that node. |
cdrom.iso = none (detach) |
no | ide2/ide3 = none |
Detach sentinel. ProxOps owns the slot and writes none. Use to model "no CD ever", or remove a previously-attached ISO (change cdrom.iso: <name> → cdrom.iso: none, reconcile to detach). |
| (block absent) | no | — | ProxOps does not own the PVE IDE slot; PVE keeps its default. No dependency inferred. |
cdrom.media |
no | ,media= |
cdrom (default) | disk. |
efi-disk |
no | efidisk0 |
Valid with bios: ovmf. Owns pool + size; PVE volume name not owned. PVE clamps small EFI sizes to 4 MiB. template pins the OVMF vars type (efitype=: byos | 2m | 4m | 8m). secure-boot (enabled | disabled; omitted = not owned) maps to PVE's pre-enrolled-keys=<0\|1> token on efidisk0 — fully convergent (create + live-form toggle, stopped or running; enrollment takes effect at the guest's next boot). PVE 9.2 has no /qemu/{id}/security endpoint (probe-verified 501) and rejects a secure-boot= token (400); the earlier GAPS premise was wrong. PVE auto-adds an ms-cert= token when keys are enrolled: ProxOps treats it as PVE-owned (preserved verbatim on rewrites, never compared). |
cloud-init |
no | ide2 = <storage>:cloudinit,size=… |
When enabled, ProxOps claims ide2 for cloud-init and shifts the CD/DVD slot to ide3 (demonstrated coexistence on PVE 9.2). The storage MUST carry images content or the VM fails at start (see GAPS.md). |
tpm |
no | tpm0 |
version: v1.2 | v2.0 (default v2.0). With bios: ovmf + machine: q35. |
serial0 |
no | serial0 |
e.g. socket. |
sockets |
no | sockets |
CPU socket count (default 1). |
numa |
no | numa=1 |
Turn PVE NUMA on. |
pci-devices |
no | hostpci<N>=<bdf>[,pcie=N] |
M13.2: structured host PCI passthrough. Each entry has a slot (PVE's hostpci<N>, N 0–999), a device (PVE PCI BDF, domain:bus:slot[.func]; ProxOps lower-cases it on the wire so the canonical form is stable), and an optional pcie (renders the PVE pcie=1/pcie=0 token; omitted = ProxOps does not own the token). ProxOps validates slots (unique, well-formed) and BDF syntax on parse — an unparseable device or duplicate slot fails closed. Drift requires stop to add/change/remove a hostpci<N> (changing host PCI passthrough on a running guest is not a safe hot operation). A hostpci<N> present in PVE that the manifest does NOT declare is surfaced in the Drift anomaly report — ProxOps does NOT delete PVE-managed PCI (an operator-added passthrough must not disappear just because it is unstated in the manifest). PVE rejects hostpci for devices not in a PCI pool on most clusters ("only root can set 'hostpcix-vga, rombar, mdev). |
Options¶
| Field | Required | PVE wire | Semantics |
|---|---|---|---|
onboot |
no | onboot=1 |
Auto-start on node boot. |
startup |
no | startup |
PVE startup (e.g. order=10). |
protection |
no | protection=1 |
Blocks accidental destroy. |
agent |
no | agent=1 |
Virtio guest agent. |
acpi |
no | acpi=1 |
ACPI enabled. |
tablet |
no | tablet=1 |
Pointer integration. |
hotplug |
no | hotplug |
Comma list (disk,network,usb). |
boot-order |
no | boot=order=… |
Explicit PVE boot order. Unset → PVE default (not owned). |
nested-virt |
no | nestedvirt=1 |
Nested KVM. |
hidden |
no | hidden=1 |
Hide KVM from the guest. |
Cloud-Init Data¶
A ProxOps VM's top-level PVE keys ciuser, sshkeys, nameserver,
searchdomain, ipconfig<N> are modelled under spec.cloud-init-data:
| Field | Required | PVE wire | Semantics |
|---|---|---|---|
ci-user |
no | ciuser |
PVE cloud-init user. Empty = not owned. |
ssh-keys |
no | sshkeys |
PVE cloud-init public keys. Empty = not owned. A single "*" sentinel = PVE owns the live value; ProxOps does not write sshkeys. On the wire ProxOps percent-encodes the value and joins keys with %0A (PVE 9.2 requires the field value itself to be urlencoded — a raw key is rejected with "invalid urlencoded string"; probed on conformance-dev 2026-09-13). Drift compares the decoded key set, so a re-encode or key reorder is never drift. |
ssh-key-refs |
no | sshkeys |
M13.2: PLURAL list of SOPS dot-paths (cloud-init.ssh-keys.<name>), each resolved against the cluster's decrypted SOPS document. The resolved key material is concatenated in manifest order and written to PVE's sshkeys (same percent-encoded %0A-joined shape as ssh-keys). A missing ref, an empty SOPS entry, or an empty resolved set fails closed BEFORE any PVE mutation — ProxOps does NOT treat a missing ref as "no keys". Mutual exclusion: ssh-keys and ssh-key-refs are exclusive; both non-empty is a Validate() error. The sentinel "*" is only meaningful on the ssh-keys field (PVE owns the live keys). |
ci-password-ref |
no | cipassword |
M13.2: SINGULAR SOPS dot-path (cloud-init.passwords.<name>), resolved at reconcile time. There is no plaintext ci-password field. The resolved value is written to PVE's cipassword field on create and on update, but PVE 9.2 masks the value on read-back as ********** (probe-verified; the plaintext is unrecoverable). The Drift rule: ProxOps writes cipassword only when the live cipassword is ABSENT; when present (even as **********), ProxOps treats it as satisfied and does NOT rewrite. The live value is never compared, and ProxOps never delete=s it — an unowned or already-set cipassword is not a drift. A malformed ref (not starting with cloud-init.passwords.), an empty entry, or a missing SOPS name fails closed. |
nameservers |
no | nameserver (space-separated) |
PVE cloud-init DNS server CSV. Set-compared on /config vs. desired — order/duplicates are not semantics. |
search-domains |
no | searchdomain (space-separated) |
PVE cloud-init DNS search domain CSV. Same set semantics. |
ipconfigs |
no | ipconfig<N> (ip=<cidr>[,gw=<addr>]) |
PVE cloud-init static-IP. One entry per proxops-owned NIC; nic = PVE slot index. PVE's dhcp form is not modelled. |
Drift semantics: ProxOps owns a PVE key only when the desired field is
non-empty. Empty desired values mean "ProxOps does not write the PVE
key and does NOT surface drift for it" — so a PVE-side ciuser that
ProxOps has no way of knowing was set by qm set does not flap. The
ssh-keys: ["*"] sentinel is the same non-write shape: ProxOps does not
write the PVE sshkeys field while the sentinel is present; PVE's live
value survives. Mixing "*" with real keys fails Validate()
(ambiguous intent).
Adoption: PVE-side sshkeys are redacted to ["*"] in emitted
manifests (public-key material is treated as credential-adjacent).
M13.2: when the cluster is SOPS-backed AND the live PVE sshkeys line is
present VERBATIM in the cluster's SOPS document as cloud-init.ssh-keys.<name>,
adopt emits the manifest with ssh-key-refs (a SOPS dot-path) instead of
the sentinel — the committed manifest names the ref, never the key. When
nothing matches, the manifest keeps the "*" sentinel. cipassword,
cicustom / ciupgrade are NOT adopted (PVE 9.2 masks cipassword on
read-back; secret or PVE-side-only) and stay in the gap report.
proxops adopt --adopt-secrets additionally IMPORTS new SOPS names
(deterministic adopted-<fingerprint>); see adopt.md + sops-credentials.md.
Deliberately not modelled: replication jobs (source/destination/schedule
is a separate concern — a future dedicated resource), bootspeed, netboot
(rejected on PVE 9.2 config), and Secure Boot policy (separate PVE
/security endpoint) remain in spec.extra.
Drift semantics on disks/NICs/hardware: - Disks matched by slot, compared on pool + size (PVE-assigned volume names are never treated as drift). - NICs compared on model + bridge + (pinned MAC / vlan / rate / firewall when requested). - Power transitions are independent of config drift.
kind: TemplateVM¶
A PVE qemu object promoted to a PVE template (PVE template=1). ProxOps
owns the template lifecycle end-to-end: create + mark, config drift,
ownership-tag prune. The ProxOps schema surface is identical to
kind: VM (a TemplateVM manifest re-uses every spec field a VM
manifest supports, plus spec.state constrained to "stopped").
Differences from kind: VM:
| Concern | ProxOps behaviour |
|---|---|
spec.state |
MUST be stopped (or absent → "stopped"). PVE refuses to start a template (state: started fails Validate() at parse time). |
| PVE id space | PVE's per-node qm id space is shared with kind: VM — a TemplateVM and a VM cannot both claim the same (node, vmid). ProxOps enforces this as any other in-cluster id collision. |
| PVE /template endpoint | POST /qemu/{id}/template (mark). The planner emits a MarkTemplate action when a desired TemplateVM matches a PVE object at the same (node, vmid) that is NOT template-flagged. |
| PVE /untemplate endpoint | PVE 9.2 has no /qemu/{id}/untemplate endpoint (probe-verified HTTP 501 "not implemented" on conformance-dev 2026-09-11). The planner therefore surfaces a non-destructive anomaly when a ProxOps kind: VM desired matches a PVE-side template at the same (node, vmid): ProxOps will not attempt a kind-flip write. The operator either changes the manifest to kind: TemplateVM (the right ProxOps representation of PVE's state) or manually demotes the PVE object (qm from the PVE host, or PVE's Web UI). |
| Create | POST /qemu with start=0 + POST /qemu/{id}/template. The executor combines both into a single Create action. |
| Delete | DELETE /qemu/{id}. PVE accepts delete on a templated object. |
| Cloud-init | spec.cloud-init-data is fully supported (see kind: VM § Cloud-Init Data above), including M13.2 SOPS refs. PCI: spec.hardware.pci-devices is supported but a template with host PCI is unusual (a GPU-VM clone-source is a valid shape); M13.2 drift/stop rules apply. |
Adoption: PVE objects reporting template=1 produce kind: TemplateVM
manifests under templatevm/<cluster>/. M13.2: PVE-side sshkeys are
SOPS-matched to ssh-key-refs (verbatim match on the decrypted SOPS doc);
on no match they are redacted to ["*"] (see kind: VM § Cloud-Init
adoption). cipassword / cicustom remain in the gap report.
spec.hardware.pci-devices adopts from live hostpci<N> keys.
Provisioning a VM from a TemplateVM (spec.clone)¶
A kind: VM may name a kind: TemplateVM on spec.clone. ProxOps then
provisions the VM with PVE's full-clone endpoint instead of a fresh
create, and applies the VM's own configuration on top. The lifecycle is:
TemplateVM → clone (full=1) → configure (VM's own values) → Cloud-Init → optional start → reconcile
apiVersion: proxops/v1alpha1
kind: VM
metadata:
name: app-01
spec:
node: pve01
vmid: 9161
clone: golden-tpl # a TemplateVM metadata.name
state: started
memory: 2GiB # the VM's OWN value, not the template's
cpu: {type: host, cores: 2}
networks:
- model: virtio
bridge: vmbr0
hardware:
cloud-init: {enabled: true, storage: local-lvm}
cloud-init-data: # the VM's OWN identity — see below
ci-user: deploy
ipconfigs:
- nic: 0
ip: 192.168.0.50/24
gateway: 192.168.0.1
Rules¶
spec.disksMUST be empty. A clone inherits the template's disk layout; re-stating a create-form disk over a cloned live volume is the data-loss shape ProxOps forbids, so declaring both failsValidate()at parse time. The template owns the disk layout (includingscsihw— see the controller note below).- The clone source is the TemplateVM's own
spec.vmid, resolved through the structuredVM → TemplateVMedge. A manifest can never point a clone at an arbitrary PVE id: the target is named bymetadata.name, and its vmid comes from the TemplateVM's spec. A clone whose resolved source equals its own target vmid is rejected (would clone onto itself). - Missing / invalid references fail closed.
spec.clonenaming an unknown resource, a non-TemplateVM, or a template not placed on the VM's node is a parse error that aborts the cluster's cycle before any PVE call — ProxOps never partially converges a clone. - A clone is created once, never re-cloned. The clone runs only when the VM is absent from PVE. Drift is corrected through config writes; ProxOps never re-clones over a live VM (PVE also refuses a clone onto an existing id).
Identity: a clone must not keep the template's hostname / IP / keys¶
PVE's full clone copies the template's entire /config, including the
cloud-init DATA block (ciuser, sshkeys, ipconfig<N>, nameserver,
searchdomain) — probe-verified on conformance-dev PVE 9.2.2. Left alone, a
clone would boot with the template's identity. ProxOps therefore, in the
post-clone config write:
- overwrites every cloud-init DATA key the VM manifest declares (its own user, keys, static IP, DNS); and
- clears (via PVE's
delete=form-value) every inherited identity key the VM manifest does not declare, so an undeclaredsearchdomain/sshkeys/ipconfig<N>from the template never leaks into the guest.
The set map and the delete= list are disjoint by construction: PVE 9.2
rejects setting and deleting the same key in one request. The clearing is
convergent — it rides the normal Drift path too, so a clone whose create
failed partway (clone landed, config write did not) is repaired on the next
cycle rather than leaking the template's identity forever.
name is never leaked (the clone's own name= always wins), and PVE
regenerates vmgenid / smbios1 (UUID) per clone. tags are overwritten
with the VM's own (ownership tag included).
The cloud-init DRIVE is inherited, not rewritten¶
The cloud-init drive (ide2=<storage>:cloudinit) is a storage-backed
volume, so a clone gets its own copy from the template. Re-sending the
create-form drive over the clone's live volume makes PVE lvcreate a volume
that already exists and the task fails ("Logical Volume
vm-<id>-cloudinit already exists" — probe-verified 2026-09-15). ProxOps
therefore writes ide2 only into an empty slot (a template that carried
no drive); a live inherited drive is left as-is, and a drive on a different
pool than the manifest asks for is surfaced as a non-destructive anomaly
(moving a live volume is a storage migration, not a config write). The
cloud-init DATA keys above are always reconciled — they are plain config
values, not volumes.
Controller / boot caveat¶
A clone inherits the template's scsihw. Some cloud images (e.g. the Ubuntu
minimal images) ship an initramfs without PVE's default lsi53c897a
driver and kernel-panic ("VFS: Unable to mount root fs on
unknown-block(0,0)") unless the controller is virtio-scsi-single. Because a
clone-backed VM declares no disks, the controller is owned by the template
(set it on the template's spec.disks[].controller); the clone inherits it.
See GAPS.md.
Power¶
PVE's clone endpoint rejects start (probe-verified), so a clone-backed VM
with state: started is powered on by the planner's normal power step on the
following cycle — the same convergence guarantee as any other VM, one cycle
later than a plain create.