From f082a30b7f85c460c9d5d3caeb9dbada3a78a97e Mon Sep 17 00:00:00 2001 From: dtoro Date: Fri, 3 Apr 2026 14:46:27 +0200 Subject: [PATCH] feat: docker and nomad config --- backend/main.py | 7 +- backend/pages.py | 42 ++++++++++++ compose.yml | 34 ++++++++-- docker-compose.yml | 30 --------- docs/multi-node-plan.md | 145 ++++++++++++++++++++++++++++++++++++++++ nomadnet.conf | 30 +++++++++ reticulum.conf | 21 ++++++ 7 files changed, 271 insertions(+), 38 deletions(-) delete mode 100644 docker-compose.yml create mode 100644 docs/multi-node-plan.md create mode 100644 nomadnet.conf create mode 100644 reticulum.conf diff --git a/backend/main.py b/backend/main.py index 21c2deb..67a15d5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,7 +4,7 @@ from pathlib import Path from fastapi import FastAPI from fastapi.staticfiles import StaticFiles -from pages import router as pages_router +from pages import router as pages_router, ensure_default_pages from graph import router as graph_router from docker_utils import router as docker_router from converter import router as converter_router @@ -17,6 +17,11 @@ app.include_router(graph_router, prefix="/api") app.include_router(docker_router, prefix="/api") +@app.on_event("startup") +async def startup(): + ensure_default_pages() + + @app.get("/api/health") async def health(): return {"status": "ok"} diff --git a/backend/pages.py b/backend/pages.py index a73f8f3..9cf0bc1 100644 --- a/backend/pages.py +++ b/backend/pages.py @@ -12,6 +12,48 @@ router = APIRouter() PAGES_DIR = Path(os.environ.get("PAGES_DIR", "/data/pages")) SOURCES_DIR = Path(os.environ.get("SOURCES_DIR", "/data/sources")) +DEFAULT_INDEX_SOURCE = '''\ +page "Welcome" 60 + bigtitle "uFrame" thin + + box rounded "Micronomicon" + align center + text "Decentralized Page Server" + text "Powered by @bold{Reticulum} and @bold{NomadNet}" + + spacer + + heading 2 "Pages" + text "This node is serving pages built with the uFrame DSL." + text "Use the web IDE to create and publish new pages." + + spacer + + divider light + + label "Node" "Micronomicon" + label "Engine" "uFrame v1" + status "Node" online +''' + + +def ensure_default_pages(): + """Create a default index page if none exists.""" + PAGES_DIR.mkdir(parents=True, exist_ok=True) + SOURCES_DIR.mkdir(parents=True, exist_ok=True) + + index_mu = PAGES_DIR / "index.mu" + index_src = SOURCES_DIR / "index.uf" + + if not index_mu.is_file(): + # Compile and publish the default index + result = uframe.compile(DEFAULT_INDEX_SOURCE) + index_mu.write_text(result.micron, encoding="utf-8") + index_mu.chmod(0o644) + + if not index_src.is_file(): + index_src.write_text(DEFAULT_INDEX_SOURCE, encoding="utf-8") + class PageMeta(BaseModel): name: str diff --git a/compose.yml b/compose.yml index f7425f6..2961395 100644 --- a/compose.yml +++ b/compose.yml @@ -1,14 +1,34 @@ services: - micron-editor: - build: . + micronomicon: + build: + context: . + dockerfile: Dockerfile ports: - "127.0.0.1:8080:8080" - volumes: - - ~/.nomadnetwork/storage/pages:/data/pages - - ~/.micron-editor/sources:/data/sources - - /var/run/docker.sock:/var/run/docker.sock environment: - PAGES_DIR=/data/pages - SOURCES_DIR=/data/sources - NOMADNET_CONTAINER=nomadnet - restart: always + volumes: + - pages:/data/pages + - sources:/data/sources + - /var/run/docker.sock:/var/run/docker.sock:ro + restart: unless-stopped + depends_on: + - nomadnet + + nomadnet: + image: ghcr.io/markqvist/nomadnet:latest + ports: + - "0.0.0.0:4242:4242" + volumes: + - pages:/root/.nomadnetwork/storage/pages + - nomadnet-config:/root/.nomadnetwork + - ./nomadnet.conf:/root/.nomadnetwork/config:ro + - ./reticulum.conf:/root/.reticulum/config:ro + restart: unless-stopped + +volumes: + pages: + sources: + nomadnet-config: diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 872cead..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,30 +0,0 @@ -services: - micronomicon: - build: - context: . - dockerfile: Dockerfile - ports: - - "8080:8080" - environment: - - PAGES_DIR=/data/pages - - SOURCES_DIR=/data/sources - - NOMADNET_CONTAINER=nomadnet - volumes: - - pages:/data/pages - - sources:/data/sources - - /var/run/docker.sock:/var/run/docker.sock:ro - restart: unless-stopped - depends_on: - - nomadnet - - nomadnet: - image: ghcr.io/markqvist/nomadnet:latest - volumes: - - pages:/root/.nomadnetwork/storage/pages - - nomadnet-config:/root/.nomadnetwork - restart: unless-stopped - -volumes: - pages: - sources: - nomadnet-config: diff --git a/docs/multi-node-plan.md b/docs/multi-node-plan.md new file mode 100644 index 0000000..4d3c95d --- /dev/null +++ b/docs/multi-node-plan.md @@ -0,0 +1,145 @@ +# 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) diff --git a/nomadnet.conf b/nomadnet.conf new file mode 100644 index 0000000..b79cd2b --- /dev/null +++ b/nomadnet.conf @@ -0,0 +1,30 @@ +[logging] +loglevel = 4 +destination = file + +[client] +enable_client = yes +user_interface = text +announce_at_start = yes + +[textui] +intro_time = 1 +theme = dark +colormode = 256 +glyphs = unicode + +[node] +# Enable page-serving node +enable_node = yes +node_name = Micronomicon + +# Announce on the network +announce_interval = 360 +announce_at_start = yes + +# Rescan pages directory every minute so newly +# published pages are picked up automatically +page_refresh_interval = 1 + +# Not running as a propagation node +disable_propagation = yes diff --git a/reticulum.conf b/reticulum.conf new file mode 100644 index 0000000..6ab421b --- /dev/null +++ b/reticulum.conf @@ -0,0 +1,21 @@ +[reticulum] + enable_transport = True + share_instance = Yes + +[logging] + loglevel = 4 + +[interfaces] + + # Local AutoInterface for LAN discovery + [[Default Interface]] + type = AutoInterface + enabled = Yes + + # TCP Server — allows other Reticulum peers to connect + # Expose this port to reach the node from outside Docker + [[TCP Server Interface]] + type = TCPServerInterface + enabled = Yes + listen_ip = 0.0.0.0 + listen_port = 4242