feat: docker and nomad config
This commit is contained in:
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
|
||||
34
compose.yml
34
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:
|
||||
|
||||
@@ -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:
|
||||
145
docs/multi-node-plan.md
Normal file
145
docs/multi-node-plan.md
Normal file
@@ -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)
|
||||
30
nomadnet.conf
Normal file
30
nomadnet.conf
Normal file
@@ -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
|
||||
21
reticulum.conf
Normal file
21
reticulum.conf
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user