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 cloud-init / placement
custom fields) 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 CF 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 reads branch.changediff_set.filter(object_type__model='virtualmachine') and classifies each diff:
| 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).
6. Custom fields landed by Sub-PR C¶
10 VM CFs (attach to virtualization.virtualmachine):
proxmox_node text
proxmox_storage text
proxmox_iso text
proxmox_template_vmid integer
cloud_init_user text
cloud_init_ssh_keys text (long)
cloud_init_user_data text (long)
cloud_init_network text
proxbox_intent_state text # pending|applied|failed|deleted
proxbox_last_apply_run_id text # UUID of last ProxmoxApplyJob
2 Branch CFs (attach to netbox_branching.branch):
apply_to_proxmox boolean
apply_destroy_confirmed boolean
The Branch CF registration is guarded by a ContentType lookup so
plugin install without netbox_branching skips silently (matches the
is_branching_available() runtime guard pattern).
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 proxbox_last_apply_run_id on each VM |
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 4 cloud-init CFs feed into a CloudInitPayload Pydantic v2 model
on the backend, which the dispatchers map to Proxmox API arguments:
| NetBox CF | 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 |
12 intent CFs | C |
0040_apply_job_full |
ProxmoxApplyJob schema |
E |
0041_deletion_request_full |
DeletionRequest schema |
H |
0042_intent_warn_plaintext_password |
settings toggle | K |
One migration per sub-PR keeps the chain auditable.
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 (current design holds intent state in NetBox CFs; 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