Skip to content

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:

  1. Master flag + typed phrase. Apply only runs when ProxboxPluginSettings.netbox_to_proxmox_enabled is True and netbox_to_proxmox_typed_confirmation equals allow-edit-and-add-actions. The form enforces this at save time (netbox_proxbox/forms/settings.py:536); the apply job re-checks at runtime.
  2. 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.py and …/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.
  3. Four-eyes deletion. DeletionRequest.requested_by must differ from DeletionRequest.authorizer unless the operator explicitly opts in via intent_apply_authorization_self_approve_allowed=True. Enforced at three layers: model clean(), view, API client.
  4. Receiver isolation. handle_branch_merged (the post_merge signal receiver) runs after the merge transaction has already committed. Its body is wrapped in try/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