Adoption (PVE → YAML)¶
proxops adopt reverse-engineers live
PVE objects into ProxOps YAML. It is READ-ONLY with respect to PVE —
it performs only GET requests and asserts zero PVE writes at the end of
the run — and requires an explicit cluster:
cd <gitops repo>
export SOPS_AGE_KEY_FILE=~/.local/share/proxops/<cluster>.age # outside the repo
proxops adopt --cluster conformance-dev
(Pre-M13.1 invocation still works: proxops adopt --cluster <name> --config clusters/<name>/config.yaml.)
What it does:
- uses that cluster's configured endpoint + node allowlist (or, without an
allowlist, PVE's
/cluster/nodeslisting); only allowlisted nodes are ever read — this is the cluster-isolation guarantee; - writes one manifest per live object under
<kind>/<cluster>/in the git work tree (VM, LXC, ISO, CTTemplate, TemplateVM, TemplateCT);importcontent (DiskImage) is not adopted — see Compatibility / Gaps; - surfaces unsupported PVE configuration explicitly (a
gapline per live key ProxOps does not model;INCOMPLETEfor generated manifests missing a value PVE cannot re-report, e.g. the LXCostemplate). PVE template VMs (template=1on atype=qmobject) are adopted askind: TemplateVMmanifests undertemplatevm/<cluster>/, and PVE template containers (template=1on atype=lxcobject) askind: TemplateCTmanifests undertemplatect/<cluster>/, both with the full lifecycle owned (create + mark, config drift, prune). PVE-sidesshkeysin the template's cloud-init are redacted to the["*"]sentinel so the operator fills in the real key(s) before apply;cipassword/cicustomstay as gap lines. A ProxOpskind: VM(orkind: LXC) desired against a live PVE-side template at the same(node, vmid)is surfaced as a non-destructive anomaly (no kind-flip write). - redacts sensitive PVE fields in the gap report:
sshkeysandcipasswordvalues are emitted as<redacted>(the field name still reports, so the operator knows ProxOps does not model it); - prints the exact
resources.yamllines to add. It does NOT modifyclusters/<cluster>/resources.yaml— listing the generated files is a deliberate, reviewable operator step.
M13.2 — cloud-init secrets (SSH keys + cipassword census + --adopt-secrets)¶
Adoption of cloud-init secret material is governed by these rules:
sshkeys (recoverable public keys)¶
- SOPS-backed cluster + exact match: if the cluster's
secrets.sops.yamlcarries acloud-init.ssh-keys.<name>entry whose value is the live PVEsshkeysline verbatim, the generated manifest usesssh-key-refs: [cloud-init.ssh-keys.<name>](the ref, never the key). The SOPS name is the existing operator-defined name (reused, not re-named). - SOPS-backed cluster + no match: the manifest keeps the M10
ssh-keys: ["*"]sentinel; asshkeyscensus line is emitted (adopt --adopt-secretswould import the key under a new deterministic name — see below). - Non-SOPS cluster: the manifest keeps the
["*"]sentinel; the PII is redacted in the gap report. - All-or-nothing partial match (fail-closed): if the live
sshkeysfield has TWO or more keys, ONE is in SOPS and ONE is not, the manifest uses the["*"]sentinel (not a partialssh-key-refslist) — a mixed manifest would rejectValidate()and the operator's intent is ambiguous. This matches the PVE wire (one urlencoded multi-line value, not one key per ref).
cipassword (masked by PVE; unrecoverable)¶
- PVE reports
cipasswordas**********whether or not the live value is set (probe-verified PVE 9.2.2: presence ≠ value; read-back does not disclose the plaintext).adoptcannot reverse-translate acipasswordinto a SOPS ref: the material is irrecoverable. - The adopt census reports how many VMs carry a live
cipasswordvalue (PVI: operators should manually map each such VM to aci-password-ref+ SOPS entry after review).cipassworditself stays a<redacted>gap.
proxops adopt --adopt-secrets (the flag)¶
- Adds live
sshkeysthat are NOT already in the cluster'scloud-init.ssh-keysSOPS block to that block, under a deterministic new nameadopted-<digest>where<digest>is 16 lowercase hex chars: the first 8 bytes ofsha256("sshpki\u0000<type>\u0000<blob>")(the key's OpenSSH type + base64 body; the comment column is EXCLUDED — so the same key under different labels dedupes to one SOPS entry, and rename-safe SOPS merges are stable). The manifest then references it ascloud-init.ssh-keys.adopted-<digest>. PVE reports the livesshkeyspercent-encoded (PVE 9.2's own encoding: space as%20, newline as%0A); adopt decodes it to plain key lines before matching against the SOPS doc (plain lines), so the SOPS entry value is a plain OpenSSH public-key line, never the PVE-encoded form. - Re-encrypts the SOPS file atomically and in place: a sidecar
<file>.proxops-tmpis written,sops --encrypt(age backend, recipients read back from the on-disk SOPS metadata so no operator is locked out), andrename(2)swaps it into place. All existing SOPS blocks (M9secrets:flat map + any othercloud-init.material) are preserved byte-for-byte. - Fails closed on any SOPS write failure: the original
.sops.yamlis never truncated or left half-written (the rename is atomic). The operator can re-run--adopt-secretsidempotently — a key that is already in the SOPS doc is NOT re-added. - The adopt run with
--adopt-secretsstill emits zero PVE writes (the census is read-only); the SOPS file is the only thing written, and only when there is something to import. - Plain
adopt(no flag) NEVER writes the SOPS file: the SOPS doc + manifest + git commit remain the operator's reviewable step.
PII guarantees¶
No PVE sshkeys or cipassword material (plaintext) is ever written to the
gap report, the INCOMPLETE / SKIPPED census, manifest YAML (except the
SOPS-matched ssh-key-refs dot-paths), or the CLI stdout. The SOPS
merge step only writes the encrypted sops.sops.yaml; the private age
key is never logged. Result.NewSSHKeys (in-memory map of SOPS name →
plaintext key) is ONLY handed to the encrypt step; it is not
serialised anywhere else.
Determinism¶
Two adopt runs against an unchanged PVE produce byte-identical
manifests and gap reports: the manifest set, filenames, field ordering,
gap ordering, and the INCOMPLETE/SKIPPED lists are all total-ordered. No
timestamps, no PVE-assigned randomness, no credentials appear in the
output. A second run therefore produces no meaningless git diff.
Production safety expectations¶
When the adopted cluster is a production PVE:
adoptis the only proxops command safe to run against it unattended: it issues GETs to/cluster/nodes,/nodes/{n}/{qemu,lxc},/nodes/{n}/storage,/nodes/{n}/storage/{s}/content,/nodes/{n}/qemu/{v}/config,/nodes/{n}/lxc/{c}/config— and nothing else. Post-run, the client's write counter must read 0 or the run aborts.- Do NOT run
apply/run/ a normal reconcile cycle against a freshly adopted production cluster. Adoption output is reviewed, completed (INCOMPLETE resources), and composed intoresources.yamlby a human first; only then isdiffused to verify zero unexpected drift. - The generated manifests are stripped of ProxOps's ownership tag from
spec.tags(adopt never invents tags); the tag is re-appended at create time, and on the first reconcile ProxOps claims the live object by adding that tag. Untagged live objects are never modified or deleted.
Round-trip verification¶
The acceptance round-trip is:
PVE -> adopt -> YAML -> (human review) -> clusters/<cluster>/resources.yaml
-> proxops diff -> zero unexpected drift
Every remaining drift line must map to a documented expectation:
update ... config drifton every adopted VM / LXC / TemplateVM / TemplateCT: the ownership-tag claim (PVE objects carry noproxopstag; ProxOps adds one when it manages an object). This is expected and is the first write the operator consciously approves — it is not applied bydiff.anomaly ... live-only disk slot scsiN=...-cloudinit,media=cdromon VMs whose cloud-init volume sits on a non-IDE slot (PVE 9.x places cloud-init onscsi1whenide2is not used): ProxOps does not own non-IDE cdrom slots; adopt documents them as a gap and leaves them PVE-managed.skipped (no proxops tag)for LXC resources whosespec.templatecould not be recovered (INCOMPLETE). PVE-sidetemplate=1objects are adopted askind: TemplateVMundertemplatevm/<cluster>/(qemu) orkind: TemplateCTundertemplatect/<cluster>/(container).
Live-only data disks are preserved: adopt interrogates the PVE /config report, so a live second data disk is represented in the adopted manifest rather than silently dropped. PVE cloud-init volumes on non-IDE slots, by contrast, are PVE-owned and are explicitly reported. See Compatibility / Gaps for the gap backlog (the source of new entries is exactly this adopt report).