NetBox → Proxmox Intent Layer¶
Status: design (Sub-PR A of v0.0.15 / proxbox-api v0.0.11). Tracking issue: #377. Roadmap deviation: parent issue nominated
v0.1.0; this work is consolidated into the current-version slot (v0.0.15/proxbox-api v0.0.11) and lands as a sequence of 12 sub-PRs (A-L, #378-#389).
1. Motivation¶
Historically netbox-proxbox runs a one-way reflection sync:
Proxmox is the source of truth, NetBox mirrors it. Operators who want
to change infrastructure must do it in Proxmox (UI or API), and the
plugin reflects the change shortly after.
The intent layer inverts that flow for opt-in branches. Operators
edit VirtualMachine records and their plugin-owned ProxmoxVMIntent rows
inside a NetBox branch, review the diff, and on
merge the plugin dispatches Proxmox writes (CREATE / UPDATE / DELETE)
through proxbox-api. The result is a GitOps-style declarative
workflow on top of NetBox.
The reflection sync still runs unchanged: operators who do not enable the master flag observe no behavior change.
2. Safety Model¶
Four invariants that hold at every stage of every sub-PR:
- Master flag + typed phrase. Apply only runs when
ProxboxPluginSettings.netbox_to_proxmox_enabledisTrueandnetbox_to_proxmox_typed_confirmationequalsallow-edit-and-add-actions. The form enforces this at save time (netbox_proxbox/forms/settings.py:536); the apply job re-checks at runtime. - Destroy modules allowlist. After Sub-PR I lands, only two
modules in the entire workspace may call Proxmox destroy:
proxbox_api/routes/intent/dispatchers/qemu_destroy.pyand…/lxc_destroy.py. A static walker (tests/test_static_destroy_gate.py) asserts this by walking both repos. Sub-PRs F, G, H carry interim versions of the walker so the invariant holds throughout the chain. - Four-eyes deletion.
DeletionRequest.requested_bymust differ fromDeletionRequest.authorizerunless the operator explicitly opts in viaintent_apply_authorization_self_approve_allowed=True. Enforced at three layers: modelclean(), view, API client. - Receiver isolation.
handle_branch_merged(thepost_mergesignal receiver) runs after the merge transaction has already committed. Its body is wrapped intry/except; it never re-raises. The apply job is enqueued on RQ and runs out-of-band.
3. Trigger mechanism (Option B: post_merge)¶
The intent layer hooks netbox-branching's post_merge signal.
When an operator merges a branch whose ProxboxBranchIntent row has
apply_to_proxmox=True,
handle_branch_merged fires, validates pre-conditions (master flag,
phrase, perms), and enqueues a ProxmoxApplyJob keyed by the merged
branch.
The signal handler does not itself call Proxmox. It does the minimum work to enqueue, then returns. The RQ worker picks up the job and runs the dispatch.
This decouples apply latency from the operator's merge action and isolates dispatch failures from the merge transaction.
4. Plan vs. apply¶
| Phase | Trigger | Side effects | Returns |
|---|---|---|---|
| Plan | merge_validator invocation (pre-merge) |
None | BranchActionIndicator(permitted, message, plan_summary) |
| Apply | post_merge signal → RQ job |
Proxmox writes | ProxmoxApplyJob.per_vm_results JSONField |
Plan runs in NetBox's request thread (the user is waiting for the
merge button). It is read-only: it validates that target nodes
are online, VMIDs are free, template VMIDs exist, cloud-init YAML
parses, the user has the right intent_* permissions, and (for
DELETE diffs) apply_destroy_confirmed=True.
Apply runs in the worker. It mutates Proxmox state via
proxbox-api's /intent/apply route and the deletion-request
executor.
5. Diff classification¶
The validator unions virtualmachine and proxmoxvmintent ChangeDiff streams,
keyed by virtual-machine ID, and classifies each resulting VM once:
| diff kind | NetBox semantics | Plan check | Apply dispatcher |
|---|---|---|---|
| CREATE | new VM record | VMID free, node online, template VMID exists | qemu_create.py / lxc_create.py |
| UPDATE | existing VM, fields changed | TOCTOU-safe re-read | qemu_update.py / lxc_update.py |
| DELETE | record deleted in branch | apply_destroy_confirmed=True + perm |
NO destroy. Creates DeletionRequest + tags Proxmox VM |
DELETE never destroys directly. It triggers the four-eyes deletion flow (see §11).
A VM diff keeps its own create, update, or delete operation. If the same VM also
has an intent diff, the VM operation wins and is dispatched once. An intent-only
create, update, or delete becomes a VM update; deleting an intent means applying
the same all-null desired values as clearing every former intent custom field.
Deleted intent rows resolve their parent from ChangeDiff.original. A deleted
core VM remains in the union after its generic relation disappears. That entry
retains the originating ChangeDiff, and the apply job builds the pending
deletion snapshot from its original/current, object_repr, and object_id
values. It never treats the NetBox object ID as a Proxmox VMID; missing VMID,
node, or name identity fails that diff instead of silently discarding it.
6. VM intent and branch intent models¶
ProxmoxVMIntent is a plugin-owned NetBoxModel with one row per
virtualization.VirtualMachine. Operators edit it from the VM detail page. An
existing intent cannot be reassigned to another VM through either the form or
REST serializer; delete it and create a new row instead. This lifetime ownership
prevents the old guest from retaining an undispatched desired-state change.
target_node text
target_storage text
iso text
template_vmid integer
swap integer
rootfs text
ostemplate text
cloud_init_user text
cloud_init_ssh_keys text (long)
cloud_init_user_data text (long)
cloud_init_network text (long)
intent_state text # apply-managed
last_apply_run_id text # apply-managed UUID
target_node is desired placement. Delete authorization snapshots use
vm_identity.resolve_vm_node() for the reflected current location and record
the intent separately as audit metadata.
Successful applies update stamp fields only when the intent row still exists. An intent-only deletion deliberately becomes a VM update, and its successful stamp must not recreate the row the operator removed.
ProxboxBranchIntent stores the two branch-scoped safety gates:
apply_to_proxmox boolean
apply_destroy_confirmed boolean
The model uses the branch integer primary key and schema_id as a unique soft
reference. It has no database relation or migration dependency on the optional
branching plugin. The shared resolver verifies that the referenced Branch still
exists before returning either gate. A missing intent row, disabled branching
plugin, deleted branch, missing branch, or lookup exception returns False for
both gates. The branch detail extension is registered only when branching is enabled and
links to the plugin-owned create/edit form.
7. RBAC permissions (Sub-PR B)¶
Seven new Django permissions, registered via RunPython migration
following the #376 core.run_proxmox_action precedent:
netbox_proxbox.intent_create_vm
netbox_proxbox.intent_update_vm
netbox_proxbox.intent_delete_vm
netbox_proxbox.intent_create_lxc
netbox_proxbox.intent_update_lxc
netbox_proxbox.intent_delete_lxc
netbox_proxbox.authorize_deletion_request
Each has a matching helper in netbox_proxbox/views/proxbox_access.py
that returns the dotted name; views consume these via
ContentTypePermissionRequiredMixin.
8. /intent/* route surface on proxbox-api¶
| Route | Method | Sub-PR | Notes |
|---|---|---|---|
/intent/plan |
POST | D | Read-only; validates a list of diffs |
/intent/apply |
POST | F → G | CREATE in F; UPDATE in G; DELETE always 501 |
/intent/deletion-requests/{id}/approve |
POST | I | 4-eyes guard |
/intent/deletion-requests/{id}/reject |
POST | I | Returns reason |
/intent/deletion-requests/{id}/execute |
POST | I | Triggers qemu/lxc_destroy dispatchers |
Every write route calls _gate(endpoint) from
routes/proxmox_actions.py to honor ProxmoxEndpoint.allow_writes,
and writes a journal entry via
services/verb_dispatch.write_verb_journal_entry().
Auth is global via APIKeyAuthMiddleware; no AUTH_EXEMPT_PATHS
change is required.
9. ProxmoxApplyJob model¶
Promoted to full schema in Sub-PR E (migration 0040_apply_job_full).
| Field | Type | Notes |
|---|---|---|
branch |
FK netbox_branching.branch |
on_delete=SET_NULL (branch may be deleted; job history survives) |
user |
FK | the user who merged the branch |
run_uuid |
UUIDField | stamps ProxmoxVMIntent.last_apply_run_id |
state |
CharField | queued|running|succeeded|failed|partial |
per_vm_results |
JSONField | per-VMID outcome + reason |
started_at |
DateTime | nullable until pickup |
finished_at |
DateTime | nullable until completion |
A partial state means at least one VM succeeded and at least one
failed; the operator inspects per_vm_results to decide next steps.
10. DeletionRequest state machine¶
Promoted to full schema in Sub-PR H (migration 0041_deletion_request_full).
+-------------+
| pending | ← created by apply job on DELETE diff
+-------------+
| | |
approve reject TTL expires
| | |
v v v
+---------+---+-----------+--------------+
| approved | rejected | auto_rejected |
+----+-----+----------+--------------+
|
execute
|
v
+-------------+
| executed | OR +---------+
+-------------+ | failed |
+---------+
Self-approval is blocked at three layers (see §2 invariant 3).
11. Cloud-Init (Sub-PR K)¶
The four ProxmoxVMIntent cloud-init fields feed into a CloudInitPayload
Pydantic v2 model
on the backend, which the dispatchers map to Proxmox API arguments:
| Intent field | Proxmox arg |
|---|---|
cloud_init_user |
ciuser |
cloud_init_ssh_keys |
sshkeys (URL-encoded) |
cloud_init_user_data |
cicustom (YAML) |
cloud_init_network |
ipconfig0 |
cipassword is never stored in NetBox. If cloud_init_user_data
contains a top-level password: key, the merge validator emits a
plan-time warning (toggle: intent_warn_plaintext_password,
default True).
The backend log path runs every dispatched dict through
proxbox_api/utils/log_scrubbing.py so plaintext passwords never
land in audit logs.
12. merge_validator registration¶
netbox-branching exposes the merge_validators PLUGINS_CONFIG hook.
Plugins cannot directly mutate PLUGINS_CONFIG; the operator must
register the validator in configuration.py.
Sub-PR D ships a management command
proxbox install-merge-validator that prints the required snippet:
PLUGINS_CONFIG['netbox_branching'] = {
'merge_validators': [
'netbox_proxbox.intent.merge_validator.validate_proxmox_intent',
],
}
Sub-PR L's operator guide repeats this in plain prose.
13. SSE plan / apply progress¶
The existing SSE infrastructure in views/job_stream.py is extended
to forward two new frame kinds:
plan_summary— emitted once before apply starts; lists per-VM classifications and verdicts.apply_progress— emitted per VM during dispatch; carries state transitions.
The schema lives at contracts/proxbox_api_sse_schema.json; Sub-PR D
adds the two new kinds.
14. Test scaffolding¶
| Test | First lands in | Final form in |
|---|---|---|
test_settings_form_gate.py |
B | B |
test_settings_warning_callout.py |
B | J |
test_intent_permissions.py |
B | B |
test_intent_shell_models.py |
B | B |
test_bootstrap_intent_cfs.py |
C | C |
test_merge_validator.py |
D | D |
test_plan_endpoint.py (backend) |
D | D |
test_signal_receiver.py |
E | E |
test_apply_job_dryrun.py |
E | F |
test_apply_endpoint_create.py (backend) |
F | F |
test_apply_create_vm.py |
F | F |
test_apply_create_lxc.py |
F | F |
test_static_no_destroy.py |
F | H |
test_apply_endpoint_update.py (backend) |
G | G |
test_apply_update_vm.py |
G | G |
test_apply_toctou.py |
G | G |
test_apply_delete_safe.py |
H | H |
test_deletion_request_state_machine.py |
H | I |
test_deletion_endpoint.py (backend) |
I | I |
test_deletion_request_views.py |
I | I |
test_deletion_request_approve.py |
I | I |
test_deletion_request_executor.py |
I | I |
test_deletion_request_ttl.py |
I | I |
test_intent_safety.py |
J | J |
test_four_eyes.py |
J | J |
test_safe_delete_invariants.py |
J | J |
test_orphan_tag_invariants.py |
J | J |
test_state_machine_full.py |
J | J |
test_static_destroy_gate.py |
J | J |
test_audit_log_emits.py |
J | J |
test_cloud_init_builder.py (backend) |
K | K |
test_cloud_init_zero_diff.py (backend) |
K | K |
test_log_scrubbing.py (backend) |
K | K |
test_apply_cloud_init.py |
K | K |
tests/playwright/intent_full_flow.spec.ts |
L | L |
15. Migration order on netbox-proxbox¶
| Number | Purpose | Sub-PR |
|---|---|---|
0037_v0_0_15_release |
(already on origin/develop) |
n/a |
0038_intent_permissions |
7 RBAC perms | B |
0039_intent_custom_fields |
Original 12 intent custom fields | C |
0040_apply_job_full |
ProxmoxApplyJob schema |
E |
0041_deletion_request_full |
DeletionRequest schema |
H |
0042_intent_warn_plaintext_password |
settings toggle | K |
0088_proxmox_vm_intent |
Plugin-owned VM intent schema | model cutover |
0089_remove_vm_intent_custom_fields |
Retire the ten empty VM definitions | model cutover |
0090_proxbox_branch_intent |
Plugin-owned soft-referenced branch gates | branch intent cutover |
0091_remove_branch_intent_custom_fields |
Retire the two empty Branch definitions | branch intent cutover |
The historical rollout used one migration per sub-PR. The later intent-model cutover deliberately separates additive schema from conservative custom-field removal while keeping a single migration leaf.
16. proxbox-api inline SQLite migrations¶
proxbox-api uses inline column migrations in
proxbox_api/database.py::_migrate_*_columns(). Sub-PR F adds a new
section there only if intent state needs to be persisted on the
backend side. The current design holds intent state in
ProxmoxVMIntent; the backend remains stateless for /intent/*.
17. Rollout phases¶
| Phase | Default state | Operator action to opt in |
|---|---|---|
| Phase 0 (v0.0.15 ships) | netbox_to_proxmox_enabled=False |
none — reflection sync continues |
| Phase 1 (operator opts in) | typed phrase entered | branches with apply_to_proxmox=True dispatch on merge |
| Phase 2 (delete enabled) | apply_destroy_confirmed per branch + 4-eyes config |
DELETE diffs reach the deletion-request queue |
| Phase 3 (full automation) | TTL cron enabled | stale requests auto-reject and untag |
Each phase is reversible: toggle off the master flag and the plugin reverts to reflection-only behavior on the next merge.
See also¶
- Operator guide:
docs/operations/netbox-to-proxmox.md(Sub-PR L) - Deletion flow:
docs/operations/deletion-requests.md(Sub-PR L) - Troubleshooting:
docs/operations/troubleshooting.md(Sub-PR L) - Tracking issue: #377