Files
micronomicon/docs/multi-node-plan.md
2026-04-03 14:46:27 +02:00

7.2 KiB

Multi-Node Support for Micronomicon

Context

Currently Micronomicon runs a single NomadNet node — one identity, one set of pages, one index.mu. The user wants to host multiple independent "sites" on the Reticulum network, each with its own node identity, name, and pages. This requires changes across Docker infrastructure, backend API, and frontend UI.

Approach: Dynamic Node Containers via Docker API

The FastAPI backend already has Docker socket access. Instead of statically defining NomadNet services in compose.yml, the backend will create/manage NomadNet containers dynamically — one per node. Each node gets its own directory with pages, sources, config, and identity.

Storage Layout

/data/nodes/
  default/
    node.json              # {"id", "display_name", "port", "created_at"}
    nomadnet.conf          # generated — unique node_name
    pages/                 # compiled .mu files (mounted into container)
    sources/               # .uf source files
  my-relay/
    node.json
    nomadnet.conf
    pages/
    sources/

Files to Create

backend/nodes.py — Node lifecycle management

  • NodeConfig pydantic model: id, display_name, port, created_at
  • NODES_DIR = Path(os.environ.get("NODES_DIR", "/data/nodes"))
  • CRUD: list_nodes(), get_node(id), create_node(id, display_name), delete_node(id)
  • _generate_nomadnet_conf(display_name) — template from existing nomadnet.conf with node-specific node_name
  • _next_port() — allocate TCP ports starting from 4242
  • Docker container management via Docker SDK (already a dependency):
    • start_node_container(node) — create container from ghcr.io/markqvist/nomadnet:latest, mount node's pages/ dir, per-node identity volume, shared reticulum.conf, generated nomadnet.conf
    • stop_node_container(id), restart_node_container(id), get_node_status(id)
  • ensure_default_node() — migration: if NODES_DIR/default doesn't exist, create it and move contents from old PAGES_DIR/SOURCES_DIR
  • Startup reconciliation: check existing containers, start any that should be running
  • API router:
    • GET /api/nodes — list all nodes with status
    • POST /api/nodes — create {id, display_name}
    • GET /api/nodes/{node_id} — node details
    • DELETE /api/nodes/{node_id} — stop container, remove dir
    • POST /api/nodes/{node_id}/restart

frontend/src/stores/nodesStore.ts — Node state

  • nodes: NodeMeta[], activeNodeId: string (persisted to localStorage, defaults to "default")
  • fetchNodes(), setActiveNode(id), createNode(id, displayName), deleteNode(id), restartNode(id)

frontend/src/components/shared/NodeSelector.tsx — Header dropdown

  • Dropdown listing nodes with status indicator (green/red dot)
  • Switching active node refetches pages
  • "Manage Nodes" link to /nodes

frontend/src/routes/NodesView.tsx — Node management page

  • Table: Name, Status, Port, Created, Actions (restart/delete)
  • "Create Node" button with dialog (ID + display name)

Files to Modify

backend/pages.py

  • Replace global PAGES_DIR/SOURCES_DIR with helpers: _node_pages_dir(node_id) -> NODES_DIR/node_id/pages, _node_sources_dir(node_id) -> NODES_DIR/node_id/sources
  • Refactor all functions to accept node_id parameter
  • Add node-scoped endpoints: GET/POST/DELETE /api/nodes/{node_id}/pages/{name}
  • Keep existing /api/pages/{name} as aliases -> node_id="default"
  • ensure_default_pages(node_id) — create index.mu per node

backend/graph.py

  • Same refactor: accept node_id, add /api/nodes/{node_id}/graph
  • Keep /api/graph as alias for default

backend/docker_utils.py

  • Deprecate in favor of nodes.py restart endpoint
  • Keep /api/restart as alias -> restart default node

backend/main.py

  • Import and include nodes router
  • Startup: call ensure_default_node(), then start all node containers
  • Remove direct ensure_default_pages() call (handled by node creation)

compose.yml

  • Remove static nomadnet service (backend manages containers dynamically)
  • Replace pages/sources/nomadnet-config volumes with single nodes volume
  • Add NODES_DIR=/data/nodes, NOMADNET_IMAGE, RETICULUM_CONF env vars
  • Add reticulum bridge network for container communication
  • Keep reticulum.conf bind-mount for backend to pass to spawned containers

frontend/src/stores/pagesStore.ts

  • Read activeNodeId from nodesStore
  • All fetch calls go to /api/nodes/{activeNodeId}/pages/...

frontend/src/routes/DashboardView.tsx

  • Show active node name in header
  • Restart button restarts active node
  • Page list scoped to active node via store

frontend/src/routes/EditorView.tsx

  • Save/publish calls use /api/nodes/{activeNodeId}/pages/{slug}
  • Load page from /api/nodes/{activeNodeId}/pages/{name}
  • Show active node name in toolbar

frontend/src/routes/GraphView.tsx

  • Fetch from /api/nodes/{activeNodeId}/graph

frontend/src/App.tsx

  • Add /nodes route -> NodesView

frontend/src/components/shared/AppShell.tsx (or equivalent layout)

  • Add NodeSelector to header/nav area

Backward Compatibility

  • Existing /api/pages/..., /api/restart, /api/graph remain as aliases for node_id="default"
  • ensure_default_node() migrates old flat PAGES_DIR/SOURCES_DIR into NODES_DIR/default/
  • Frontend defaults activeNodeId to "default" — single-node users see no change
  • NodeSelector only shows when >1 node exists (or shows as subtle indicator for single node)

Implementation Order

  1. backend/nodes.py — core model, storage, Docker container management, API
  2. backend/pages.py — refactor to accept node_id, add node-scoped endpoints
  3. backend/graph.py — refactor to accept node_id
  4. backend/main.py — wire in nodes router, startup logic
  5. compose.yml — restructure (remove static nomadnet, add nodes volume + network)
  6. frontend/src/stores/nodesStore.ts — new store
  7. frontend/src/stores/pagesStore.ts — parameterize by active node
  8. frontend/src/components/shared/NodeSelector.tsx — node switcher
  9. frontend/src/routes/NodesView.tsx — management UI
  10. frontend/src/App.tsx + layout — add routes, add selector to shell
  11. frontend/src/routes/DashboardView.tsx — node-aware
  12. frontend/src/routes/EditorView.tsx — node-aware save/publish/load
  13. frontend/src/routes/GraphView.tsx — node-aware

Verification

  1. docker compose up --build -d — starts backend only (no static nomadnet)
  2. Hit GET /api/nodes — should return [{id: "default", display_name: "Micronomicon", status: "running", ...}]
  3. Open web IDE — pages dashboard shows default node's pages, NodeSelector shows "Micronomicon"
  4. Create a page, publish — appears in default node's container at /root/.nomadnetwork/storage/pages/
  5. POST /api/nodes with {id: "relay", display_name: "Relay Alpha"} — new container starts, GET /api/nodes shows 2 nodes
  6. Switch to "Relay Alpha" in NodeSelector — empty page list, create and publish index.mu
  7. From remote NomadNet client, both nodes visible on network with separate identities and pages
  8. Delete "Relay Alpha" — container stops, files removed, back to single node
  9. Run backend tests: python -m pytest uframe/tests/ -v — all 91 pass (DSL unchanged)