Phase 2: BridgedVXLanNetwork scaffold, ErrorReport, ensure_mesh migration¶
Context¶
Phase 1 shipped the per-node queue family, exponential back-off, and cancellation check. Phase 2 is the first per-method migration. It establishes the pattern that every later migration (floating-IP, dnsmasq, lifecycle) follows, so the choices made here matter beyond the one method being migrated.
Phase 2 ships, in order:
ErrorReportatshakenfist/operations/error_report.py. The structured failure record carried across the queue boundary. Includesfrom_exception()for worker-side construction (with the four initial network-exception registry entries) andto_http()for REST rendering. Persistence is a new append-onlycluster_operation_errorstable — see step 2a for the rationale.BridgedVXLanNetworkatshakenfist/network/bridged_vxlan_network.py. The worker-only sibling ofNetwork. Holds the_apply_*methods that actually mutate host network state. Phase 2 implements_apply_ensure_meshonly; subsequent phases add the other_apply_*methods.poll_until_terminal/op.error_report/op.raise_for_error. The small generic helper that callers needing synchronous semantics use to block until an op reaches a terminal state, plus the convenience methods onBaseClusterOperationfor accessing the error report and raising on failure.- A new
network_ensure_meshtask onNetOpsoNetwork.ensure_mesh()has something to enqueue. The task handler instantiatesBridgedVXLanNetwork(n)._apply_ensure_mesh(). - Migrate net_op.py in-worker callers. Replace
the two existing
n.ensure_mesh()calls inside_network_deployand_network_update_dnsmasqwithBridgedVXLanNetwork(n)._apply_ensure_mesh(). This is the re-entrancy safety from the master plan — in-worker code uses the worker class, not the public class. - Flip
Network.ensure_mesh(). Its body changes from "do the work inline" to "build a NetOp task fornetwork_ensure_mesh, enqueue on this node's per-nodenetworkqueue, return the op handle". The existingwith self.get_lock(...)wrapper stays in_apply_ensure_meshfor now — Phase 8 removes it once the queue is the sole serialisation point. - Update external callers. The five non-worker
call sites of
n.ensure_mesh()change fromn.ensure_mesh()toop = n.ensure_mesh(); op.raise_for_error()— one extra line each. This preserves today's synchronous-with-exception semantics through the per-method migration. Later phases can introduce chains viadepends_ononce enough methods are migrated. - Documentation updates.
What Phase 2 does not do:
- No migration of
add_floating_ip,route_address,unroute_address,remove_nat— Phase 3. - No migration of
update_dnsmasq,remove_dnsmasq,update_dns_entry,remove_dns_entry,remove_dhcp_lease— Phase 4. - No migration of
create_on_*,delete_on_*— Phase 5. - No rewrite of
maintain.py— Phase 6. - No removal of the
NodeLockwrappers in_apply_ensure_mesh— Phase 8. - No
depends_onchain semantics on external callers. They use theop.raise_for_error()shim to preserve sync behaviour during the per-method migration.
Key references in the existing code¶
-
shakenfist/network/network.py:885-929— currentNetwork.ensure_mesh()body. The actual work (theutil_concurrency.ensure_vxlan_meshcall plus the event emission and FDB diff logic) moves toBridgedVXLanNetwork._apply_ensure_mesh(). Thewith self.get_lock(...)block stays inside_apply_ensure_meshuntil Phase 8. -
shakenfist/operations/net_op.py:100-122— current task handlers (_network_deploy,_network_update_dnsmasq) that calln.ensure_mesh()directly. These are in-worker callers and switch toBridgedVXLanNetwork(n)._apply_ensure_mesh(). -
shakenfist/operations/net_op.py:69-98— the dispatcher'sdispatch_taskmethod. Its outerexceptis where exceptions escaping_apply_*methods need to be converted toErrorReportviaErrorReport.from_exception()and persisted on the op record. Today it just callsutil_exceptions.ignore_exceptionand setsSTATE_ERROR. -
shakenfist/schema/operations/net_op.py:27-32— themodel_tasksenum that needs a newnetwork_ensure_meshentry. -
shakenfist/operations/baseoperation.py:97-249—BaseClusterOperationis the right home for the newerror_reportproperty andraise_for_error(timeout=None)method. Thepoll_until_terminalhelper can be a free function alongsideis_outstanding(line 242) or a method on the class. Either works; choose by readability. -
shakenfist/mariadb.py:801-857—cluster_operationstable is documented as insert-only ("rows are not mutated after creation"). ThereforeErrorReportcannot live as a column on this table. State transitions go to the separateobject_statestable. We add a newcluster_operation_errorstable for ErrorReport persistence — see step 2a. -
shakenfist/schema/object_state.py:27-72— theStatePydantic model has an optionalmessagefield. We deliberately do not overload this field with JSON; structured error data goes to the new table. -
shakenfist/config.py:108-114—API_ASYNC_WAIT(default 15 seconds) is the existing async-op timeout knob. Thepoll_until_terminalhelper defaults itstimeoutparameter to this value. -
shakenfist/deploy/shakenfist_ci/base.py:395—_await_instance_operations_completeis the existing poll-based wait pattern. The newpoll_until_terminalhelper generalises it; the CI helper can later move to use the new helper as cleanup. -
The five external
n.ensure_mesh()call sites: shakenfist/instance.py:2022shakenfist/operations/node_inst_netdesc_op.py:243shakenfist/daemons/queues/startup_tasks.py:135shakenfist/daemons/network/maintain.py:153Each becomesop = n.ensure_mesh(); op.raise_for_error().
Success criteria¶
Phase 2 is complete when:
ErrorReportexists atshakenfist/operations/error_report.pywithcode,message,details,origin_class, andtracebackfields.ErrorReport.from_exception()maps the four registered network exceptions (EnsureMeshFailed,DeadNetwork,CreateVXLANInterfaceFailed,CannotAssignFloatingGateway) to stable codes; unknown exceptions becomeinternal.unknownwith the original class preserved.to_http()maps each registered code to a sensible HTTP status- body dict.
- A
cluster_operation_errorstable exists in MariaDB withop_uuid(primary key, FK tocluster_operations.uuid),error_report(JSON), andcreated_at. The schema is registered via the same pattern as the other cluster-operation tables (_ensure_<x>_schemainmariadb.py). A migration test confirms the table is created on a clean install. BridgedVXLanNetworkexists atshakenfist/network/bridged_vxlan_network.py. Its constructor takes aNetworkinstance._apply_ensure_mesh()implements the host-mutating body that was previously insideNetwork.ensure_mesh(), including the existingget_lockwrapper, the FDB diff computation, theutil_concurrency.ensure_vxlan_meshcall, and the event emission. Raises the same typed exceptions (EnsureMeshFailed,DeadNetwork) it does today.BaseClusterOperationexposesop.error_report(returns theErrorReportloaded from the new table, orNone) andop.raise_for_error(timeout=None)(polls until terminal, then if state isERRORraisesNetworkOperationFailed(error_report=...), otherwise returns silently). Apoll_until_terminal(op, timeout=None)helper exists (as a standalone function or method) and is used byraise_for_error. Default timeout comes fromconfig.API_ASYNC_WAIT.NetworkOperationFailedexception exists (likely inshakenfist/exceptions.py), carries theErrorReportas an attribute, and renders the report's message in__str__.NetOpschema has a new tasknetwork_ensure_mesh.NetOp.dispatch_taskinvokes the handler_network_ensure_mesh(self, n)which instantiatesBridgedVXLanNetwork(n)and calls_apply_ensure_mesh(). On exception, the dispatcher's outer except clause writes anErrorReportrow via the new table helper before settingSTATE_ERROR.net_op.pyin-worker callers (_network_deploy,_network_update_dnsmasq) callBridgedVXLanNetwork(n)._apply_ensure_mesh()directly, notn.ensure_mesh(). Re-entrancy through the queue is structurally impossible.Network.ensure_mesh()body changes fromutil_concurrency.ensure_vxlan_meshto building aNetOptask withtask=network_ensure_mesh, enqueueing attarget=config.NODE_UUID, family='network', priorityuser_facing, and returning the enqueued op handle. No host mutation happens insideNetwork.ensure_mesh()anymore.- The five external
n.ensure_mesh()call sites are updated toop = n.ensure_mesh(); op.raise_for_error(), preserving today's synchronous-with-exception semantics. pre-commit run --all-filespasses.tox -e py3shows no regressions.- The cluster_ci functional suite passes on the phase 2 PR.
grep -n "util_concurrency.ensure_vxlan_mesh" shakenfist/network/network.pyreturns nothing — the call site has moved entirely toBridgedVXLanNetwork.ARCHITECTURE.md,AGENTS.md, and the developer guide describeErrorReport,BridgedVXLanNetwork, theop.error_report/op.raise_for_errorAPI, and the per-method migration pattern. The dev guide pagenetwork_dispatcher.md(created in phase 1) gains a section on how the dispatcher translates in-worker exceptions toErrorReport.
Step-level guidance¶
Each step is its own commit on the phase branch. Steps 2a, 2b, 2c are independent prerequisites for the later steps; 2d depends on 2b and 2c; 2e depends on 2b; 2f depends on 2d; 2g depends on 2f; 2h after all.
| Step | Effort | Model | Isolation | Brief for sub-agent |
|---|---|---|---|---|
| 2a. ErrorReport + persistence | high | opus | none | Create shakenfist/operations/error_report.py with the ErrorReport dataclass (or Pydantic model, matching existing conventions in shakenfist/schema/): fields code: str, message: str, details: dict[str, Any], origin_class: str, traceback: str. Add ErrorReport.from_exception(exc) -> ErrorReport with a module-level registry _EXCEPTION_CODE_REGISTRY: dict[type[Exception], str] mapping the four network exceptions to codes (network.ensure_mesh.failed, network.dead, network.create_vxlan.failed, network.floating.assign_failed). Unknown exceptions become code='internal.unknown' with origin_class set to the fully qualified class path. Add ErrorReport.to_http() -> tuple[int, dict[str, Any]] returning (http_status, body_dict); the mapping table inside the method maps known codes to status codes (e.g. network.dead → 410 Gone; network.ensure_mesh.failed → 500; internal.unknown → 500). Add a new MariaDB table cluster_operation_errors via _get_cluster_operation_errors_table() in shakenfist/mariadb.py, with columns: op_uuid (sa.Uuid primary key, FK to cluster_operations.uuid), error_report (sa.JSON, not null), created_at (sa.Double, not null). Register the table in the existing schema-ensure pattern (look at _ensure_cluster_operations_schema and add an analogous _ensure_cluster_operation_errors_schema; wire it into the schema-ensure entry point alongside the other tables). Add public functions set_cluster_operation_error(op_uuid, error_report) (inserts a row, JSON-serialising the report) and get_cluster_operation_error(op_uuid) -> Optional[ErrorReport] (selects + deserialises). Both go through the three-layer pattern (_direct_, _grpc_, public) per shakenfist/mariadb.py conventions; the gRPC piece needs corresponding protos and a database-daemon handler — see daemons/database/main.py for the pattern. Note on proto regeneration: if proto files change, run tox -e genprotos and commit the regenerated stubs. Add unit tests for from_exception (each registered exception, plus a generic Exception), to_http (each registered code), table creation, and round-trip persistence. Commit message subject: operations: introduce ErrorReport for queue-boundary failures. |
| 2b. BridgedVXLanNetwork scaffold | high | opus | none | Create shakenfist/network/bridged_vxlan_network.py. The class wraps a Network instance: __init__(self, network: Network) stores the network. Move the body of Network.ensure_mesh() (network.py:885-929) into a new method _apply_ensure_mesh(self) -> None on BridgedVXLanNetwork. Inside _apply_ensure_mesh: keep the existing with self.network.get_lock(op='Network ensure mesh', global_scope=False): wrapper (Phase 8 removes it once the queue is the sole serialisation point); the FDB diff computation; the util_concurrency.ensure_vxlan_mesh call; the add_event calls; the typed exceptions raised on failure (EnsureMeshFailed, DeadNetwork). All references to self.uuid, self.vxid, etc. become self.network.uuid, self.network.vxid. Do not modify Network.ensure_mesh() in this step — that's step 2f. The intent of this step is to create the new class alongside the old code path. Add a module-level docstring explaining the class's role: instantiated only inside the workitem dispatcher, holds _apply_* host-mutating methods, paired with Network which is the public enqueueing facade. Add unit tests that mock the privexec layer and confirm _apply_ensure_mesh calls util_concurrency.ensure_vxlan_mesh with the expected arguments computed from the wrapped Network. Commit message subject: network: add BridgedVXLanNetwork with _apply_ensure_mesh. |
| 2c. poll helper + op API | high | opus | none | Add poll_until_terminal(op, timeout=None) either as a standalone function in shakenfist/operations/baseoperation.py or as a method on BaseClusterOperation. Default timeout=config.API_ASYNC_WAIT. The helper polls mariadb.get_cluster_operation(op_uuid) at a short interval (e.g. 0.1 s) until the op's state is in {STATE_COMPLETE, STATE_ABORT, STATE_DELETED, STATE_ERROR} or timeout elapses. If timeout elapses, raise OperationTimeout (add to shakenfist/exceptions.py if absent). On terminal state, return the refreshed op. Add op.error_report as a property on BaseClusterOperation that calls mariadb.get_cluster_operation_error(self.uuid) and returns the ErrorReport (or None). Add op.raise_for_error(timeout=None): calls poll_until_terminal(self, timeout), then if the resulting state value is dbo.STATE_ERROR, raises NetworkOperationFailed(error_report=self.error_report). Add NetworkOperationFailed(Exception) to shakenfist/exceptions.py with __init__(self, error_report), self.error_report = error_report, and __str__ rendering the report's code + message. Existing test pattern at shakenfist/deploy/shakenfist_ci/base.py:395 is similar to what the helper provides; do not modify that file in this step (its eventual cleanup to use the new helper is out of scope here). Add unit tests covering: terminal-state transition is observed; timeout raises OperationTimeout; raise_for_error raises NetworkOperationFailed on ERROR state with the right report attached; raise_for_error returns silently on COMPLETE state. Commit message subject: operations: add poll_until_terminal and op error API. |
| 2d. network_ensure_mesh task | medium | sonnet | none | Add a new task network_ensure_mesh to the model_tasks enum in shakenfist/schema/operations/net_op.py:27. Position it at the end (after network_remove_nat=5) with value 6 to preserve existing enum values. The schema regeneration via tox -e genprotos may be needed if model_tasks affects protobuf — check whether model_tasks is serialised as part of the protobuf surface (read tox.ini and tools/ for the gen-protos invocation). Add the dispatcher handler _network_ensure_mesh(self, n) in shakenfist/operations/net_op.py alongside the other task handlers. The handler imports BridgedVXLanNetwork from shakenfist.network.bridged_vxlan_network and runs BridgedVXLanNetwork(n)._apply_ensure_mesh(). The dispatcher's outer try/except in dispatch_task (lines 81-98) needs adjustment: on exception, before setting STATE_ERROR, call mariadb.set_cluster_operation_error(self.uuid, ErrorReport.from_exception(exc)) so the report is persisted. Existing typed-exception handling (EnsureMeshFailed, CreateVXLANInterfaceFailed) remains unchanged behaviourally — those branches still set STATE_ERROR, just now they also persist a report first. Add unit tests: a fake NetOp with task network_ensure_mesh dispatches into the handler; mocked _apply_ensure_mesh is called; when it raises, the op transitions to ERROR and a report is persisted. Commit message subject: net_op: add network_ensure_mesh task and ErrorReport wiring. |
| 2e. Migrate net_op in-worker callers | medium | sonnet | none | In shakenfist/operations/net_op.py, update _network_deploy (currently line 100) and _network_update_dnsmasq (currently line 120) to use BridgedVXLanNetwork. Each method instantiates a single BridgedVXLanNetwork(n) at the top and calls bvn._apply_ensure_mesh() instead of n.ensure_mesh(). Other calls in those handlers (n.create_on_network_node(), n.remove_dnsmasq(), etc.) are not yet migrated — they stay as-is on Network because those methods aren't migrated until later phases. The intent here is purely to ensure that the in-worker callers of ensure_mesh use the worker class so that step 2f (flipping Network.ensure_mesh() to enqueue) does not cause re-entrancy. Add a unit test confirming that _network_deploy calls BridgedVXLanNetwork._apply_ensure_mesh (not Network.ensure_mesh) by patching both and asserting which one is called. Commit message subject: net_op: route in-worker ensure_mesh through BridgedVXLanNetwork. |
| 2f. Flip Network.ensure_mesh() | high | opus | none | Replace the body of Network.ensure_mesh() (network.py:885-929) with code that builds a NetOp task and enqueues it. The new body looks roughly like: from shakenfist.schema.operations import net_op as net_op_schema; from shakenfist.schema.operations.baseclusteroperation import PRIORITY; ... op_type, op_uuid = net_op_schema.create_and_enqueue(network_uuid=str(self.uuid), tasks=[net_op_schema.model_tasks.network_ensure_mesh], priority=PRIORITY.user_facing); from shakenfist.constants import get_object_class; return get_object_class(op_type).from_db(op_uuid). The function now returns the NetOp instance (loaded from DB so callers can raise_for_error() on it). Critical: the enqueue must use the calling node's per-node queue. Look at shakenfist/schema/operations/util.py:enqueue_cluster_operation to confirm how target and family propagate; for ensure_mesh we need target=config.NODE_UUID, family='network'. The create_and_enqueue wrapper in net_op.py does not currently expose family — extend it with a family='clusteroperation' parameter (matching the underlying enqueue_cluster_operation shape) and pass family='network' from the new Network.ensure_mesh body. Remove the now-dead _not_on_floating_network decorator usage if it doesn't make sense for the enqueueing version (read the decorator and decide). Keep the public method name and signature; the only externally observable change is the return value. Update the docstring to describe the new contract: "enqueues an ensure_mesh task and returns the operation handle; callers that need to block on completion call op.raise_for_error()." Add unit tests: calling n.ensure_mesh() enqueues exactly one NetOp with task network_ensure_mesh and priority user_facing on this node's per-node network queue; no util_concurrency.ensure_vxlan_mesh call is made directly. Commit message subject: network: Network.ensure_mesh now enqueues a NetOp. |
| 2g. Update external callers | medium | sonnet | none | Update the five external call sites listed in the Key references section so each n.ensure_mesh() becomes op = n.ensure_mesh(); op.raise_for_error(). The five sites are: shakenfist/instance.py:2022, shakenfist/operations/node_inst_netdesc_op.py:243, shakenfist/daemons/queues/startup_tasks.py:135, shakenfist/daemons/network/maintain.py:153. (The two net_op.py sites are in-worker and already migrated in step 2e.) Each update is a one-line addition; preserve surrounding logic. For maintain.py:153, note that Phase 6 will rewrite this function entirely; the goal here is just to keep it working through the per-method migration, so a minimal mechanical change is appropriate. Add a focused regression test for node_inst_netdesc_op.py (or whichever site has the cleanest seam) that confirms a raise_for_error failure surfaces as NetworkOperationFailed rather than the original EnsureMeshFailed. Commit message subject: network: callers of ensure_mesh use op.raise_for_error. |
| 2h. Documentation | medium | sonnet | none | Update ARCHITECTURE.md to describe ErrorReport (the structured failure record at the queue boundary, the registry pattern, the to_http mapping), BridgedVXLanNetwork (the worker-only sibling of Network, instantiated only inside the dispatcher), and the per-method migration pattern (callers see synchronous semantics via op.raise_for_error() during migration; chains via depends_on come later). Update AGENTS.md to point at the new files. Extend docs/developer_guide/network_dispatcher.md (created in phase 1) with a section on how the dispatcher translates exceptions to ErrorReport at the queue boundary, including the principle "errors are data, never rehydrated exceptions" and the convergence with gRPC's status-code model. Update mkdocs.yml.tmpl if any new doc file is added (per phase 1's convention, never edit mkdocs.yml directly). Commit message subject: docs: describe ErrorReport and BridgedVXLanNetwork. |
Step ordering and dependencies¶
- 2a, 2b, 2c are independent of each other and can land in any order (or in parallel sub-agents if isolation allows — but the implementations touch different files, so direct-tree parallel is safe).
- 2d depends on 2a (uses
ErrorReport) and 2b (callsBridgedVXLanNetwork). - 2e depends on 2b (uses
BridgedVXLanNetwork). - 2f depends on 2d (uses the new task) and on 2e being
in place (otherwise the net_op in-worker handlers
would now enqueue when
Network.ensure_mesh()flips, causing re-entrancy through the queue). - 2g depends on 2c (
op.raise_for_error()) and 2f (the new return value). - 2h after all.
Recommended landing order on the phase branch: 2a → 2b → 2c → 2d → 2e → 2f → 2g → 2h.
Back brief¶
Before executing any step, the implementing sub-agent must back brief the management session as to its understanding of the step and how its planned changes align with this phase plan and the master plan. The back brief should explicitly confirm:
- The single safety property step 2f relies on: at the
moment
Network.ensure_mesh()flips to enqueue, every in-worker caller must already be usingBridgedVXLanNetwork(step 2e). Otherwise the net-worker enqueues to itself and deadlocks. ErrorReport.from_exceptioncovers all four registered exceptions and falls back cleanly for the unknown case.op.raise_for_error()is bounded byconfig.API_ASYNC_WAIT— it does not block forever.- The
_apply_ensure_meshbody keeps the existingget_lockwrapper; Phase 8 removes it.
Review checklist for the management session¶
After each step's sub-agent reports completion:
- Named files were modified; no unrelated files changed.
-
pre-commit run --files <changed files>passes. - New unit tests pass via
tox -e py3 -- <module>. - The commit message subject ends in a period, is
no longer than 50 characters, and the body wraps
at 75 characters per
CLAUDE.mdconventions. - The commit body includes the
Prompt:paragraph and theCo-Authored-ByandSigned-off-bylines. - For step 2a: the protos for the new
cluster-operation-errors gRPC endpoints were
regenerated via
tox -e genprotosand committed. - For step 2f: a follow-up grep
(
grep -rn "util_concurrency.ensure_vxlan_mesh" shakenfist/) shows the call site has moved entirely toBridgedVXLanNetwork; the old call site inNetworkis gone.
After all steps complete:
- cluster_ci functional smoke suite passes on the phase 2 PR.
- No new
ERROR/Tracebacklines in the cluster_ci stable-log gate. - Master plan execution table for Phase 2 is
updated from
PlanningtoComplete.