Labtris docs

Phase 1 spec

What the first shipping release is — and is not.

This document is the prompt. It is self-contained: an implementer needs only this file plus a Postgres instance and a Docker daemon. docs/0104 are background for why; this file is what.

Scope

Build the foundation: schema, the privileged network daemon, the runtime abstraction, and one working runtime backend (Docker).

In scope: repo skeleton · Postgres schema + migrations · netd privileged daemon · NodeRuntime protocol · DockerRuntime · REST API for labs/nodes/networks/links · deterministic interface naming · acceptance tests.

Out of scope (do not build): QEMU backend (Phase 1b) · canvas/frontend (Phase 2) · tc impairment and capture (Phase 3 — but netd reserves the verbs) · AI tab (Phase 5) · auth beyond a dev stub · multi-host.

Constraint: clean-room. Do not read or copy code from any incumbent network-emulation product. Mechanism facts in /design/findings (observed behaviour of a running system) are fair game; source code is not.

Definition of done

pytest tests/acceptance/test_phase1.py passes green against a real Postgres and a real Docker daemon. That test is specified verbatim in §9.


1. Architecture constraint: the privilege split

Two processes. This is non-negotiable and shapes everything.

┌────────────────────────────────────────┐
│ labtris-api        user: pnl  (unprivileged)│
│ FastAPI · SQLAlchemy · aiodocker        │
│ NEVER calls ip/tc/tcpdump/systemctl     │
└───────────────┬────────────────────────┘
                │ AF_UNIX SOCK_STREAM, NDJSON
                │ /run/labtris/netd.sock  (0660, group pnl)
┌───────────────▼────────────────────────┐
│ labtris-netd       user: root               │
│ pyroute2 netlink · tc · (later) capture │
│ strict verb allowlist · no shell, ever  │
└─────────────────────────────────────────┘

Rules:

netd wire protocol

Newline-delimited JSON. One request per line, one response per line, same connection.

// request
{"id": "01J...", "verb": "bridge.create", "params": {"name": "b3f9k2m1"}}
// success
{"id": "01J...", "ok": true, "result": {"index": 42}}
// failure
{"id": "01J...", "ok": false, "error": {"code": "EEXIST", "message": "bridge exists"}}

Verbs for Phase 1 (implement all; the last three are stubs that return {"code":"ENOTIMPL"} — they exist so Phase 3 doesn't reshape the protocol):

VerbParamsResult
ping{"pong": true, "version": "1"}
bridge.createname{"index": int}
bridge.deletename{}
bridge.list{"bridges": [{"name","index"}]}
tap.createname, owner_uid{"index": int}
tap.deletename{}
iface.attachname, bridge{}
iface.detachname{}
iface.set_statename, up: bool{}
veth.createname, peer{"index","peer_index"}
netns.movename, pid: int{}
tc.setname, specENOTIMPL
tc.clearnameENOTIMPL
capture.startname, bpfENOTIMPL

Name validation, enforced in netd and in the API:

IFNAME_RE = re.compile(r"^(t|b|v)[0-9a-z]{8}$")   # tap / bridge / veth

Error codes: EEXIST, ENOENT, EINVAL, EPERM, EBUSY, ENOTIMPL, EINTERNAL.


2. Repo layout

Create exactly this. Do not add top-level directories.

labtris/
├── pyproject.toml
├── docker-compose.dev.yml          # postgres only
├── alembic.ini
├── migrations/
│   ├── env.py
│   └── versions/
├── labtris_api/
│   ├── __init__.py
│   ├── main.py                     # FastAPI app factory
│   ├── config.py                   # pydantic-settings
│   ├── db.py                       # async engine, session dep
│   ├── models.py                   # SQLAlchemy ORM
│   ├── schemas.py                  # Pydantic v2 request/response
│   ├── errors.py                   # error envelope + handlers
│   ├── naming.py                   # base36 interface naming
│   ├── netd_client.py              # async client for the unix socket
│   ├── routers/
│   │   ├── __init__.py
│   │   ├── health.py
│   │   ├── labs.py
│   │   ├── nodes.py
│   │   ├── networks.py
│   │   └── links.py
│   └── runtime/
│       ├── __init__.py
│       ├── base.py                 # NodeRuntime protocol + types
│       ├── registry.py             # kind -> runtime resolution
│       └── docker.py               # DockerRuntime
├── labtris_netd/
│   ├── __init__.py
│   ├── __main__.py                 # serve on the unix socket
│   ├── protocol.py                 # frame parse/serialize
│   ├── verbs.py                    # verb allowlist + dispatch
│   └── net.py                      # pyroute2 operations
└── tests/
    ├── conftest.py
    ├── unit/
    │   ├── test_naming.py
    │   └── test_protocol.py
    └── acceptance/
        └── test_phase1.py

Pinned dependencies

Python 3.12. Pin exactly; do not float.

[project]
requires-python = ">=3.12,<3.13"
dependencies = [
  "fastapi==0.115.6",
  "uvicorn[standard]==0.34.0",
  "pydantic==2.10.4",
  "pydantic-settings==2.7.0",
  "sqlalchemy[asyncio]==2.0.36",
  "asyncpg==0.30.0",
  "alembic==1.14.0",
  "aiodocker==0.24.0",
  "pyroute2==0.8.1",
  "structlog==24.4.0",
  "ulid-py==1.1.0",
]

[project.optional-dependencies]
dev = [
  "pytest==8.3.4",
  "pytest-asyncio==0.25.0",
  "httpx==0.28.1",
  "ruff==0.8.4",
  "mypy==1.14.0",
]

SQLAlchemy 2.0 style only (Mapped[...], mapped_column, DeclarativeBase). Pydantic v2 only (model_config, field_validator). No legacy syntax from either.


3. Database schema

Postgres 16. Primary keys are ULIDs stored as CHAR(26). Every table gets created_at/updated_at TIMESTAMPTZ NOT NULL DEFAULT now().

CREATE TYPE node_state    AS ENUM ('defined','starting','running','stopping','stopped','failed');
CREATE TYPE network_kind  AS ENUM ('bridge','cloud');
CREATE TYPE runtime_kind  AS ENUM ('docker','qemu','containerlab','iol','dynamips');

CREATE TABLE labs (
  id          CHAR(26) PRIMARY KEY,
  name        TEXT NOT NULL,
  description TEXT NOT NULL DEFAULT '',
  locked      BOOLEAN NOT NULL DEFAULT false,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (name)
);

CREATE TABLE nodes (
  id            CHAR(26) PRIMARY KEY,
  lab_id        CHAR(26) NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
  name          TEXT NOT NULL,
  runtime       runtime_kind NOT NULL,
  image         TEXT NOT NULL,
  state         node_state NOT NULL DEFAULT 'defined',
  cpu_limit     NUMERIC(4,2),
  ram_mb        INTEGER,
  env           JSONB NOT NULL DEFAULT '{}',
  cmd           JSONB,                      -- null = image default
  runtime_ref   TEXT,                       -- container id / domain name
  last_error    TEXT,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (lab_id, name)
);
CREATE INDEX ON nodes (lab_id);

CREATE TABLE networks (
  id          CHAR(26) PRIMARY KEY,
  lab_id      CHAR(26) NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
  name        TEXT NOT NULL,
  kind        network_kind NOT NULL DEFAULT 'bridge',
  host_ifname TEXT UNIQUE,                  -- b<base36:8>, null until realized
  cloud_ref   TEXT,                         -- e.g. 'pnet0' when kind='cloud'
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (lab_id, name),
  CHECK ((kind = 'cloud') = (cloud_ref IS NOT NULL))
);

CREATE TABLE interfaces (
  id          CHAR(26) PRIMARY KEY,
  node_id     CHAR(26) NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
  idx         INTEGER NOT NULL,             -- 0-based, per node
  name        TEXT NOT NULL,                -- guest-visible, e.g. 'eth0'
  mac         MACADDR NOT NULL,
  host_ifname TEXT UNIQUE,                  -- t<base36:8>, null until realized
  network_id  CHAR(26) REFERENCES networks(id) ON DELETE SET NULL,
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (node_id, idx),
  UNIQUE (node_id, name)
);
CREATE INDEX ON interfaces (network_id);

-- Links are first-class. A link is realized as a network with exactly two members,
-- but the user-facing object is the link.
CREATE TABLE links (
  id           CHAR(26) PRIMARY KEY,
  lab_id       CHAR(26) NOT NULL REFERENCES labs(id) ON DELETE CASCADE,
  a_iface_id   CHAR(26) NOT NULL REFERENCES interfaces(id) ON DELETE CASCADE,
  b_iface_id   CHAR(26) NOT NULL REFERENCES interfaces(id) ON DELETE CASCADE,
  network_id   CHAR(26) NOT NULL REFERENCES networks(id) ON DELETE CASCADE,
  -- Phase 3 fills these; columns exist now so the model doesn't churn.
  impair_ab    JSONB,
  impair_ba    JSONB,
  admin_up     BOOLEAN NOT NULL DEFAULT true,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  updated_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
  UNIQUE (a_iface_id),
  UNIQUE (b_iface_id),
  CHECK (a_iface_id <> b_iface_id)
);

-- Geometry is NOT topology. Separate table, separate endpoint, separate lifecycle.
CREATE TABLE geometry (
  lab_id     CHAR(26) PRIMARY KEY REFERENCES labs(id) ON DELETE CASCADE,
  data       JSONB NOT NULL DEFAULT '{}',   -- {nodes:{id:{x,y}}, links:{id:{waypoints}}, view:{}}
  updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Reserves host interface names so allocation is race-free across processes.
CREATE TABLE ifname_registry (
  host_ifname TEXT PRIMARY KEY,
  kind        TEXT NOT NULL CHECK (kind IN ('tap','bridge','veth')),
  owner_id    CHAR(26) NOT NULL,            -- interface.id or network.id
  created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

The UNIQUE (a_iface_id) / UNIQUE (b_iface_id) constraints enforce at most one link per interface at the database level. Do not enforce this only in Python.


4. Interface naming

docs/03-architecture.md calls for base36 with a collision check. Concretely:

def host_ifname(kind: Literal["tap","bridge","veth"], owner_id: str, salt: int = 0) -> str:
    prefix = {"tap": "t", "bridge": "b", "veth": "v"}[kind]
    digest = hashlib.blake2b(f"{owner_id}:{salt}".encode(), digest_size=8).digest()
    n = int.from_bytes(digest, "big")
    return prefix + base36(n).rjust(8, "0")[:8]

Allocation is a transaction: compute the name, INSERT into ifname_registry, and on unique-violation retry with salt+1 (cap at 16 attempts, then EINTERNAL). Never probe the kernel to test availability — that is a TOCTOU race. The registry is the source of truth; the kernel is downstream of it.

Total length is 9 chars, well inside IFNAMSIZ-1 = 15, with no entity ceiling. Identity resolves by DB lookup, never by parsing the name.


5. Runtime abstraction

labtris_api/runtime/base.py — these are real types, not sketches.

from typing import Protocol, Literal
from dataclasses import dataclass
from enum import StrEnum

class Capability(StrEnum):
    SUSPEND     = "suspend"
    SNAPSHOT    = "snapshot"
    HOTPLUG_NIC = "hotplug_nic"
    EXEC        = "exec"
    SERIAL      = "serial"

class StopMode(StrEnum):
    GRACEFUL = "graceful"
    FORCE    = "force"

@dataclass(frozen=True)
class RuntimeHandle:
    node_id: str
    ref: str                    # container id, domain name, ...
    pid: int | None = None

@dataclass(frozen=True)
class RuntimeState:
    exists: bool
    running: bool
    pid: int | None
    exit_code: int | None
    detail: str = ""

@dataclass(frozen=True)
class ConsoleEndpoint:
    kind: Literal["exec", "serial", "vnc", "telnet"]
    target: str                 # container id, or host:port
    meta: dict[str, str]

@dataclass(frozen=True)
class NodeSpec:
    """Everything a runtime needs. No ORM objects cross this boundary."""
    node_id: str
    name: str
    image: str
    env: dict[str, str]
    cmd: list[str] | None
    cpu_limit: float | None
    ram_mb: int | None
    interfaces: list["IfaceSpec"]

@dataclass(frozen=True)
class IfaceSpec:
    iface_id: str
    idx: int
    guest_name: str             # eth0
    host_ifname: str            # t3f9k2m1
    mac: str

class NodeRuntime(Protocol):
    kind: str
    capabilities: frozenset[Capability]

    async def create(self, spec: NodeSpec) -> RuntimeHandle: ...
    async def start(self, h: RuntimeHandle) -> None: ...
    async def stop(self, h: RuntimeHandle, mode: StopMode) -> None: ...
    async def destroy(self, h: RuntimeHandle) -> None: ...
    async def attach_iface(self, h: RuntimeHandle, i: IfaceSpec, bridge: str) -> None: ...
    async def detach_iface(self, h: RuntimeHandle, i: IfaceSpec) -> None: ...
    async def console(self, h: RuntimeHandle) -> ConsoleEndpoint: ...
    async def observe(self, h: RuntimeHandle) -> RuntimeState: ...

ORM objects must not cross this boundary. Routers load from the DB, build a NodeSpec, and hand that to the runtime. This keeps backends testable and stops the QEMU backend from lazy-loading a SQLAlchemy relationship on an event loop it doesn't own.

DockerRuntime

capabilities = {EXEC, HOTPLUG_NIC} — no SUSPEND, no SNAPSHOT. The API must surface capabilities so the UI can grey out unsupported actions rather than failing at call time.

Implementation notes:


6. REST API

Prefix /api/v1. All responses JSON. Errors use one envelope:

{"error": {"code": "not_found", "message": "lab 01J... not found", "detail": {}}}

Codes: bad_request 400 · not_found 404 · conflict 409 · unprocessable 422 · runtime_error 502 · internal 500.

MethodPathBody → Response
GET/health{status, netd: bool, db: bool, docker: bool}
GET/labs[Lab]
POST/labs{name, description?}Lab 201
GET/labs/{lab_id}LabDetail (nodes, networks, links)
DELETE/labs/{lab_id}→ 204 (stops+destroys all nodes first)
GET/labs/{lab_id}/geometry{data}
PUT/labs/{lab_id}/geometry{data}{data}
POST/labs/{lab_id}/nodes{name, runtime, image, env?, cmd?, cpu_limit?, ram_mb?, interfaces?}Node 201
GET/nodes/{node_id}NodeDetail (+capabilities)
PATCH/nodes/{node_id}{name?, env?, cmd?, cpu_limit?, ram_mb?}Node (409 if running)
DELETE/nodes/{node_id}→ 204
POST/nodes/{node_id}/startNode
POST/nodes/{node_id}/stop{mode?}Node
GET/nodes/{node_id}/consoleConsoleEndpoint
POST/nodes/{node_id}/interfaces{name?, network_id?}Interface 201
POST/labs/{lab_id}/networks{name, kind?, cloud_ref?}Network 201
DELETE/networks/{network_id}→ 204 (409 if in use)
POST/labs/{lab_id}/links{a_iface_id, b_iface_id}Link 201
DELETE/links/{link_id}→ 204

POST /labs/{id}/nodes accepts interfaces: [{name?}] to create N interfaces atomically with the node. Interface index is assigned server-side, lowest free.

Creating a link: allocate a network (kind bridge, auto-named lnk-<short>), point both interfaces at it, insert the link row — all in one transaction. If both nodes are already running, realize it immediately via netd; otherwise realize at start.

Auth is a dev stub in Phase 1. A get_current_user dependency that returns a fixed user. Do not build JWT, sessions, or RBAC now — but do route every handler through the dependency so adding real auth later is a one-file change.

Lifecycle: what happens on start

  1. state = starting, commit.
  2. For each interface without host_ifname, allocate via ifname_registry.
  3. For each attached network without host_ifname, allocate, then netd bridge.create.
  4. runtime.create(spec) → store runtime_ref.
  5. runtime.start(handle).
  6. For each interface: runtime.attach_iface(...).
  7. netd iface.set_state(bridge, up=True) for each bridge.
  8. state = running, commit.

Any failure: state = failed, last_error set, and unwind what was created. Idempotency matters — starting a running node returns 200 with the current state, not an error.


7. Configuration

pydantic-settings, prefix LABTRIS_:

VarDefaultMeaning
LABTRIS_DATABASE_URLpostgresql+asyncpg://pnl:pnl@localhost/pnl
LABTRIS_NETD_SOCKET/run/labtris/netd.sock
LABTRIS_DOCKER_HOSTunix:///var/run/docker.sock
LABTRIS_LOG_LEVELinfo

No secrets in code, no defaults that are secrets.


8. Development environment

docker-compose.dev.yml runs Postgres 16 only. netd runs on the host under sudo (it needs real netlink); the API runs unprivileged in your shell. Provide a Makefile with dev-db, netd, api, test, lint.

For tests, netd may listen on a socket path from LABTRIS_NETD_SOCKET so CI can point it at a tmpdir.


9. Acceptance test — this defines done

tests/acceptance/test_phase1.py. Requires real Postgres, real Docker, and a running netd. Skip with a clear message if any is absent; never silently pass.

async def test_two_alpine_containers_ping_over_a_link(client):
    lab  = await post(client, "/api/v1/labs", {"name": "p1"})

    a = await post(client, f"/api/v1/labs/{lab['id']}/nodes", {
        "name": "a", "runtime": "docker", "image": "alpine:3.20",
        "cmd": ["sleep", "3600"], "interfaces": [{"name": "eth1"}]})
    b = await post(client, f"/api/v1/labs/{lab['id']}/nodes", {
        "name": "b", "runtime": "docker", "image": "alpine:3.20",
        "cmd": ["sleep", "3600"], "interfaces": [{"name": "eth1"}]})

    link = await post(client, f"/api/v1/labs/{lab['id']}/links", {
        "a_iface_id": a["interfaces"][0]["id"],
        "b_iface_id": b["interfaces"][0]["id"]})

    await post(client, f"/api/v1/nodes/{a['id']}/start", {})
    await post(client, f"/api/v1/nodes/{b['id']}/start", {})

    # address the link from inside the guests, then prove L2 works
    await dexec(a, "ip addr add 10.99.0.1/24 dev eth1 && ip link set eth1 up")
    await dexec(b, "ip addr add 10.99.0.2/24 dev eth1 && ip link set eth1 up")
    rc, out = await dexec(a, "ping -c3 -W2 10.99.0.2")
    assert rc == 0, out

    # teardown leaves no host state behind
    await delete(client, f"/api/v1/labs/{lab['id']}")
    assert await host_ifaces_matching(r"^[tbv][0-9a-z]{8}$") == []

Additional required tests:


10. Explicit non-goals for this phase

Do not build: a frontend, WebSockets, the task queue, tc, capture, templates, import/export, QEMU, containerlab, multi-host, real auth, or metrics. Every one of these has a later phase. Resist scaffolding them "for later" — empty modules rot and mislead.

11. Review checklist