Cloud-init¶
ProxOps models PVE's cloud-init surface on VMs and TemplateVMs: the
drive (a images-content volume claiming ide2) plus the data
keys PVE feeds to cloud-init — ciuser, sshkeys, nameserver,
searchdomain, ipconfig<N>.
The split matters:
- Drive is a storage-backed volume: ProxOps writes
ide2only into an empty slot; a live volume at a different pool is a non-destructive anomaly (moving a live volume is a storage migration, not a config write). - Data is plain
/config: ProxOps overwrites every key it declares on drift, and — forspec.cloneVMs — clears inherited-but-undeclared keys so a clone never boots with its template's identity. - The
ssh-keys: ["*"]sentinel means "PVE owns the live value": ProxOps does not writesshkeyswhile it is present.
The cloud-init drive needs a storage with images content; an ide2
drive on a storage without it will let the VM create but fail at
start ("storage does not support content-type 'images'").
The cloud-init hardware block:
| Field | Required | PVE wire | Semantics |
|---|---|---|---|
enabled |
yes (of the block) | 1 | Claim a cloud-init drive. |
storage |
yes | ide2=<storage>:cloudinit,size=… |
PVE storage with images content. |
The CD/DVD slot shifts to ide3 while cloud-init owns ide2 (demonstrated
coexistence on PVE 9.2; ProxOps writes direct IDE slot keys, never PVE's
cdrom= alias).
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 (inline in the manifest). 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. Mutually exclusive with ssh-key-refs (Validate() error when both are set). |
ssh-key-refs |
no | sshkeys |
M13.2: SOPS dot-paths (cloud-init.ssh-keys.<name>, plural). The resolved key material is concatenated in manifest order and written to PVE's sshkeys with the same percent-encoded shape. A missing ref, empty SOPS entry, or empty resolution set FAILS CLOSED before any PVE mutation — ProxOps never treats a missing ref as "no keys". No plaintext key value appears in the manifest; the refs are the committed desired state. |
ci-password-ref |
no | cipassword |
M13.2: SOPS dot-path (cloud-init.passwords.<name>, singular) resolving to PVE's cipassword. There is no plaintext ci-password manifest field. PVE 9.2 masks cipassword on read-back as ********** (probe-verified; plaintext unrecoverable) — so the Drift rule is two-step: when the live cipassword is ABSENT, ProxOps writes the resolved value once; when PRESENT (masked or not), ProxOps treats it as satisfied and does NOT rewrite or delete. A malformed ref, empty SOPS entry, or a name missing from the cluster's SOPS document fails closed on parse/resolve. |
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). ssh-key-refs and ci-password-ref are resolved
BEFORE this rule — a ref that points at an empty or missing SOPS entry
fails closed at resolve time (ProxOps cannot know the resolved value, so
ownership is "unresolvable" and the cycle reports the specific ref, not
a PVE key).
Adoption: PVE-side sshkeys are redacted to ["*"] in emitted
manifests (public-key material is treated as credential-adjacent) UNLESS
the cluster is SOPS-backed AND the live PVE sshkeys line is present
verbatim in the cluster's cloud-init.ssh-keys.<name> SOPS document —
in which case adopt emits the manifest with ssh-key-refs: [cloud-init.ssh-keys.<name>]
instead of the sentinel (the committed manifest names the SOPS ref, never the
key). Partial match is ALL-OR-NOTHING per resource (schema Validate rejects
mixing ssh-keys + ssh-key-refs), so a VM that carries more live keys than
the SOPS doc holds still adopts as ["*"] for the WHOLE VM. cipassword,
cicustom / ciupgrade are NOT adopted — PVE 9.2 masks
cipassword on read-back (plaintext unrecoverable; the ********** mask
is not a usable value) and cicustom/ciupgrade are 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.
Clone identity (TemplateVM spec.clone)¶
A PVE full clone copies the template's entire /config, including
the cloud-init DATA block — probe-verified PVE 9.2.2. Left alone, a
clone would boot with the template's hostname, keys and static IPs.
The post-clone config write therefore:
- overwrites every cloud-init DATA key the VM manifest declares;
- clears (PVE
delete=form) every inherited-but-undeclared DATA key; - never re-sends the cloud-init drive over the clone's inherited live
volume (it would make PVE's lvcreate fail on the existing
vm-<id>-cloudinitvolume — the drive is written only when the slot is empty).
The set/delete split is disjoint by construction (PVE 9.2 rejects setting and deleting the same key in one request) and is convergent: the same clearing rides the normal drift path, so a half-created clone (clone landed, config write failed) is repaired on the next cycle — the template's identity never leaks permanently.
Adoption of cloud-init¶
PVE-side sshkeys are redacted to the ["*"] sentinel in emitted
manifests (public-key material is credential-adjacent): the operator
fills in the real key(s) — or leaves the sentinel if PVE owns them —
before listing an adopted manifest in a composition. cipassword,
cicustom and ciupgrade are NOT adopted (secret / PVE-side-only):
they stay in the gap report. Non-IDE cloud-init volumes are PVE-owned
and are reported as a gap + live-only anomaly, never rewritten.