pixie-lab 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. pixie/__init__.py +20 -0
  2. pixie/_util.py +38 -0
  3. pixie/catalog/__init__.py +25 -0
  4. pixie/catalog/_fetcher.py +513 -0
  5. pixie/catalog/_routes.py +392 -0
  6. pixie/catalog/_schema.py +263 -0
  7. pixie/catalog/_store.py +234 -0
  8. pixie/deploy/__init__.py +26 -0
  9. pixie/deploy/_main.py +321 -0
  10. pixie/deploy/_templates.py +153 -0
  11. pixie/disks.py +85 -0
  12. pixie/events/__init__.py +28 -0
  13. pixie/events/_kinds.py +203 -0
  14. pixie/events/_log.py +210 -0
  15. pixie/events/_routes.py +42 -0
  16. pixie/exports/__init__.py +17 -0
  17. pixie/exports/_routes.py +211 -0
  18. pixie/exports/_store.py +156 -0
  19. pixie/exports/_supervisor.py +240 -0
  20. pixie/flash.py +1971 -0
  21. pixie/images.py +473 -0
  22. pixie/machines/__init__.py +16 -0
  23. pixie/machines/_routes.py +190 -0
  24. pixie/machines/_store.py +623 -0
  25. pixie/oras.py +547 -0
  26. pixie/pivot/__init__.py +180 -0
  27. pixie/pivot/nbdboot +348 -0
  28. pixie/pxe/__init__.py +19 -0
  29. pixie/pxe/_renderer.py +244 -0
  30. pixie/pxe/_routes.py +386 -0
  31. pixie/tftp/__init__.py +21 -0
  32. pixie/tftp/_supervisor.py +129 -0
  33. pixie/tui/__init__.py +185 -0
  34. pixie/tui/_app.py +2219 -0
  35. pixie/tui_catalog.py +657 -0
  36. pixie/web/__init__.py +6 -0
  37. pixie/web/_auth.py +70 -0
  38. pixie/web/_settings_store.py +211 -0
  39. pixie/web/_static/.gitkeep +0 -0
  40. pixie/web/_static/bootstrap-icons.min.css +5 -0
  41. pixie/web/_static/bootstrap.min.css +12 -0
  42. pixie/web/_static/fonts/bootstrap-icons.woff +0 -0
  43. pixie/web/_static/fonts/bootstrap-icons.woff2 +0 -0
  44. pixie/web/_static/htmx.min.js +1 -0
  45. pixie/web/_static/pixie-favicon.png +0 -0
  46. pixie/web/_static/sse.js +290 -0
  47. pixie/web/_table_state.py +261 -0
  48. pixie/web/_templates/_partials/page_description.html +22 -0
  49. pixie/web/_templates/_partials/recent_events.html +47 -0
  50. pixie/web/_templates/_partials/table_helpers.html +189 -0
  51. pixie/web/_templates/catalog.html +364 -0
  52. pixie/web/_templates/catalog_detail.html +386 -0
  53. pixie/web/_templates/dashboard.html +256 -0
  54. pixie/web/_templates/events.html +125 -0
  55. pixie/web/_templates/ipxe/bootstrap.j2 +12 -0
  56. pixie/web/_templates/ipxe/exit.j2 +10 -0
  57. pixie/web/_templates/ipxe/nbdboot.j2 +57 -0
  58. pixie/web/_templates/ipxe/pixie-live-env.j2 +55 -0
  59. pixie/web/_templates/ipxe/unavailable.j2 +14 -0
  60. pixie/web/_templates/layout.html +345 -0
  61. pixie/web/_templates/login.html +40 -0
  62. pixie/web/_templates/machine_detail.html +786 -0
  63. pixie/web/_templates/machines.html +186 -0
  64. pixie/web/_templates/settings.html +265 -0
  65. pixie/web/main.py +1910 -0
  66. pixie_lab-0.1.0.dist-info/METADATA +107 -0
  67. pixie_lab-0.1.0.dist-info/RECORD +70 -0
  68. pixie_lab-0.1.0.dist-info/WHEEL +4 -0
  69. pixie_lab-0.1.0.dist-info/entry_points.txt +3 -0
  70. pixie_lab-0.1.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,42 @@
1
+ """HTTP routes for the events log.
2
+
3
+ Read routes are OPEN because the on-call operator glances at
4
+ ``GET /events`` from a workstation curl; the events themselves are
5
+ just names + timestamps + already-visible fields, no secrets.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any
11
+
12
+ from fastapi import APIRouter, HTTPException, Query, Request
13
+
14
+ from pixie.events._log import EventsLog
15
+
16
+ router = APIRouter()
17
+
18
+
19
+ def _get_log(request: Request) -> EventsLog:
20
+ log: EventsLog | None = getattr(request.app.state, "events_log", None)
21
+ if log is None:
22
+ raise HTTPException(status_code=503, detail="events log not initialised")
23
+ return log
24
+
25
+
26
+ @router.get("/events")
27
+ def list_events(
28
+ request: Request,
29
+ kind: str = Query("", description="filter by exact kind (empty = all kinds)"),
30
+ subject_kind: str = Query("", description="filter by subject_kind"),
31
+ subject_id: str = Query("", description="filter by subject_id"),
32
+ since_id: int = Query(0, ge=0, description="return only events with id > since_id"),
33
+ limit: int = Query(100, ge=1, le=1000, description="max rows"),
34
+ ) -> dict[str, list[dict[str, Any]]]:
35
+ rows = _get_log(request).list(
36
+ kind=kind,
37
+ subject_kind=subject_kind,
38
+ subject_id=subject_id,
39
+ since_id=since_id,
40
+ limit=limit,
41
+ )
42
+ return {"events": [r.to_dict() for r in rows]}
@@ -0,0 +1,17 @@
1
+ """NBD-export supervisor + persistence.
2
+
3
+ An **export** is a name-plus-content_sha256 pair that pixie serves over
4
+ NBD. The bytes come from the catalog blob at
5
+ ``<state_dir>/blobs/<content_sha256>/blob``; the name is a short
6
+ identifier the operator can type + iPXE can reference.
7
+
8
+ One nbdkit subprocess per export, one TCP port each, one filter chain
9
+ (``--filter=cow``, plus ``--filter=partition`` when the blob has an
10
+ MBR/GPT). Ports are allocated from a base + scan; the assigned port
11
+ lands on the export row so ``GET /exports`` surfaces it.
12
+
13
+ Ported from nbdmux 0.9.2's ``server.py`` NbdServer + friends on
14
+ 2026-07-13. Simplified for pixie's content-addressed model: no more
15
+ ``images_dir`` step (the catalog store owns the blob path); exports
16
+ reference catalog entries by content_sha256 instead of by src_url.
17
+ """
@@ -0,0 +1,211 @@
1
+ """HTTP routes for the exports surface.
2
+
3
+ Read routes (``GET /exports``, ``GET /export/{name}``) are OPEN so
4
+ in-network monitoring can poll without a session. Write routes
5
+ (``POST /exports``, ``DELETE /exports/{name}``) require the pixie
6
+ session cookie.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import re
13
+ from typing import Any
14
+
15
+ from fastapi import APIRouter, Depends, HTTPException, Request
16
+ from fastapi.responses import Response
17
+ from pydantic import BaseModel, Field
18
+
19
+ from pixie.catalog._store import CatalogStore
20
+ from pixie.events._kinds import (
21
+ EXPORT_DELETED,
22
+ EXPORT_NBDKIT_EXITED,
23
+ EXPORT_NBDKIT_SPAWNED,
24
+ EXPORT_REGISTERED,
25
+ )
26
+ from pixie.exports._store import Export, ExportsStore
27
+ from pixie.exports._supervisor import NbdServer
28
+ from pixie.web._auth import require_auth
29
+
30
+ _log = logging.getLogger(__name__)
31
+
32
+ # Same allowlist as nbdmux 0.9.2; the export name lands in nbdkit's
33
+ # ``-e <name>`` argv and (indirectly) in operator-facing URLs.
34
+ _EXPORT_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
35
+
36
+
37
+ class RegisterBody(BaseModel):
38
+ """Register an export against an already-fetched catalog entry.
39
+
40
+ The ``content_sha256`` refers to the catalog entry's fetched
41
+ content sha (which the catalog list surfaces once the entry has
42
+ been Fetched). The name is a short operator-typable identifier
43
+ used both as the nbdkit ``-e`` param and as the URL segment.
44
+ """
45
+
46
+ name: str = Field(..., min_length=1, max_length=64)
47
+ content_sha256: str = Field(..., min_length=64, max_length=64, pattern="^[0-9a-f]{64}$")
48
+
49
+
50
+ router = APIRouter()
51
+
52
+
53
+ def _get_exports(request: Request) -> ExportsStore:
54
+ store: ExportsStore | None = getattr(request.app.state, "exports_store", None)
55
+ if store is None:
56
+ raise HTTPException(status_code=503, detail="exports store not initialised")
57
+ return store
58
+
59
+
60
+ def _get_catalog(request: Request) -> CatalogStore:
61
+ store: CatalogStore | None = getattr(request.app.state, "catalog_store", None)
62
+ if store is None:
63
+ raise HTTPException(status_code=503, detail="catalog store not initialised")
64
+ return store
65
+
66
+
67
+ def _get_nbd(request: Request) -> NbdServer:
68
+ nbd: NbdServer | None = getattr(request.app.state, "nbd_server", None)
69
+ if nbd is None:
70
+ raise HTTPException(status_code=503, detail="nbd supervisor not initialised")
71
+ return nbd
72
+
73
+
74
+ def _refresh_row(
75
+ export: Export,
76
+ nbd: NbdServer,
77
+ exports: ExportsStore,
78
+ events_log: Any = None,
79
+ ) -> Export:
80
+ """Merge live supervisor state into a stored row before returning
81
+ it to the operator. Doesn't persist the port -- we do that from
82
+ the spawn path -- but keeps ``GET /exports`` honest when nbdkit
83
+ has died out of band."""
84
+ port = nbd.port_for(export.name)
85
+ if port is None and export.status == "running":
86
+ # Supervisor lost track: row still says running but no proc.
87
+ # Log the transition as a distinct event so an operator can
88
+ # grep for spontaneous nbdkit deaths without having to diff
89
+ # /exports over time. The row's own status still moves to
90
+ # ``error`` so the /ui/catalog table renders it.
91
+ previous_port = export.nbd_port
92
+ exports.update_runtime(export.name, nbd_port=0, status="error", error="nbdkit exited")
93
+ export.nbd_port = 0
94
+ export.status = "error"
95
+ export.error = "nbdkit exited"
96
+ if events_log is not None:
97
+ events_log.emit(
98
+ EXPORT_NBDKIT_EXITED,
99
+ subject_kind="export",
100
+ subject_id=export.name,
101
+ summary=f"nbdkit for {export.name} exited",
102
+ details={"previous_port": previous_port},
103
+ )
104
+ elif port is not None and export.nbd_port != port:
105
+ exports.update_runtime(export.name, nbd_port=port, status="running", error="")
106
+ export.nbd_port = port
107
+ export.status = "running"
108
+ export.error = ""
109
+ return export
110
+
111
+
112
+ @router.get("/exports")
113
+ def list_exports(request: Request) -> dict[str, list[dict[str, Any]]]:
114
+ exports = _get_exports(request)
115
+ nbd = _get_nbd(request)
116
+ events_log = getattr(request.app.state, "events_log", None)
117
+ rows = [_refresh_row(e, nbd, exports, events_log).to_dict() for e in exports.list()]
118
+ return {"exports": rows}
119
+
120
+
121
+ @router.get("/exports/{name}")
122
+ def get_export(request: Request, name: str) -> dict[str, Any]:
123
+ exports = _get_exports(request)
124
+ row = exports.get(name)
125
+ if row is None:
126
+ raise HTTPException(status_code=404, detail=f"no export named {name!r}")
127
+ nbd = _get_nbd(request)
128
+ return _refresh_row(row, nbd, exports, getattr(request.app.state, "events_log", None)).to_dict()
129
+
130
+
131
+ @router.post("/exports", status_code=201)
132
+ def register_export(
133
+ request: Request,
134
+ body: RegisterBody,
135
+ _auth: None = Depends(require_auth),
136
+ ) -> dict[str, Any]:
137
+ """Register + spawn nbdkit for the given ``content_sha256``.
138
+
139
+ Fails with 400 if the catalog blob is missing (operator hit Register
140
+ before Fetch), 409 if the name is already taken, 500 with
141
+ ``last_error`` set on the row if nbdkit refused to start.
142
+ """
143
+ if not _EXPORT_NAME_RE.match(body.name):
144
+ raise HTTPException(
145
+ status_code=400,
146
+ detail="name must match [A-Za-z0-9][A-Za-z0-9._-]{0,63}",
147
+ )
148
+ exports = _get_exports(request)
149
+ catalog = _get_catalog(request)
150
+ nbd = _get_nbd(request)
151
+
152
+ if exports.get(body.name) is not None:
153
+ raise HTTPException(status_code=409, detail=f"export {body.name!r} already exists")
154
+
155
+ blob_path = catalog.blob_path(body.content_sha256)
156
+ if not blob_path.is_file():
157
+ raise HTTPException(
158
+ status_code=400,
159
+ detail=(
160
+ f"no blob on disk for content_sha256={body.content_sha256[:12]!r}; "
161
+ "Fetch the catalog entry first"
162
+ ),
163
+ )
164
+
165
+ export = Export(name=body.name, content_sha256=body.content_sha256)
166
+ exports.upsert(export)
167
+
168
+ try:
169
+ port = nbd.spawn(body.name, blob_path)
170
+ except RuntimeError as exc:
171
+ exports.update_runtime(body.name, nbd_port=0, status="error", error=str(exc))
172
+ raise HTTPException(status_code=500, detail=str(exc)) from exc
173
+
174
+ exports.update_runtime(body.name, nbd_port=port, status="running", error="")
175
+ log = getattr(request.app.state, "events_log", None)
176
+ if log is not None:
177
+ log.emit(
178
+ EXPORT_REGISTERED,
179
+ subject_kind="export",
180
+ subject_id=body.name,
181
+ summary=f"{body.name} on port {port}",
182
+ details={"content_sha256": body.content_sha256, "nbd_port": port},
183
+ )
184
+ log.emit(
185
+ EXPORT_NBDKIT_SPAWNED,
186
+ subject_kind="export",
187
+ subject_id=body.name,
188
+ summary=f"nbdkit spawned on port {port}",
189
+ details={"nbd_port": port},
190
+ )
191
+ row = exports.get(body.name)
192
+ assert row is not None
193
+ return row.to_dict()
194
+
195
+
196
+ @router.delete("/exports/{name}", status_code=204)
197
+ def delete_export(
198
+ request: Request,
199
+ name: str,
200
+ _auth: None = Depends(require_auth),
201
+ ) -> Response:
202
+ exports = _get_exports(request)
203
+ nbd = _get_nbd(request)
204
+ if exports.get(name) is None:
205
+ raise HTTPException(status_code=404, detail=f"no export named {name!r}")
206
+ nbd.terminate(name)
207
+ exports.delete(name)
208
+ log = getattr(request.app.state, "events_log", None)
209
+ if log is not None:
210
+ log.emit(EXPORT_DELETED, subject_kind="export", subject_id=name, summary=name)
211
+ return Response(status_code=204)
@@ -0,0 +1,156 @@
1
+ """Exports table + typed row.
2
+
3
+ An export row is name + content_sha256 (points at the catalog blob) +
4
+ transient runtime fields (nbd_port, status). The row is persistent so
5
+ a restart can respawn the NBD supervisor without operator help; the
6
+ port + status are refreshed at spawn time.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import sqlite3
13
+ import threading
14
+ from collections.abc import Generator
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from pixie._util import now_iso
20
+
21
+ _DB_WRITE_LOCK = threading.Lock()
22
+
23
+ _SCHEMA = """
24
+ CREATE TABLE IF NOT EXISTS exports (
25
+ name TEXT PRIMARY KEY,
26
+ content_sha256 TEXT NOT NULL,
27
+ nbd_port INTEGER NOT NULL DEFAULT 0,
28
+ status TEXT NOT NULL DEFAULT 'idle',
29
+ error TEXT NOT NULL DEFAULT '',
30
+ created_at TEXT NOT NULL,
31
+ updated_at TEXT NOT NULL
32
+ );
33
+
34
+ CREATE INDEX IF NOT EXISTS idx_exports_content_sha
35
+ ON exports(content_sha256);
36
+ """
37
+
38
+
39
+ @dataclass
40
+ class Export:
41
+ """One registered NBD export."""
42
+
43
+ name: str
44
+ content_sha256: str
45
+ nbd_port: int = 0
46
+ status: str = "idle" # "idle" | "running" | "error"
47
+ error: str = ""
48
+ created_at: str = field(default_factory=now_iso)
49
+ updated_at: str = field(default_factory=now_iso)
50
+
51
+ def to_dict(self) -> dict[str, Any]:
52
+ return {
53
+ "name": self.name,
54
+ "content_sha256": self.content_sha256,
55
+ "nbd_port": self.nbd_port or None,
56
+ "status": self.status,
57
+ "error": self.error or None,
58
+ "created_at": self.created_at,
59
+ "updated_at": self.updated_at,
60
+ }
61
+
62
+
63
+ class ExportsStore:
64
+ """Repository over the ``exports`` table on the shared state.db.
65
+
66
+ Shares the state.db with :class:`pixie.catalog.CatalogStore`;
67
+ pixie's design is one state.db per deploy. Callers pass the DB
68
+ path in explicitly so tests can point at a fresh tmpdir.
69
+ """
70
+
71
+ def __init__(self, db_path: Path) -> None:
72
+ self.db_path = Path(db_path)
73
+ self._ensure_schema()
74
+
75
+ def _ensure_schema(self) -> None:
76
+ with self._conn() as conn:
77
+ conn.executescript(_SCHEMA)
78
+
79
+ @contextlib.contextmanager
80
+ def _conn(self) -> Generator[sqlite3.Connection]:
81
+ conn = sqlite3.connect(str(self.db_path))
82
+ conn.row_factory = sqlite3.Row
83
+ try:
84
+ yield conn
85
+ conn.commit()
86
+ finally:
87
+ conn.close()
88
+
89
+ # ---------- CRUD ------------------------------------------------
90
+
91
+ def list(self) -> list[Export]:
92
+ with self._conn() as conn:
93
+ rows = conn.execute("SELECT * FROM exports ORDER BY name").fetchall()
94
+ return [_row_to_export(r) for r in rows]
95
+
96
+ def get(self, name: str) -> Export | None:
97
+ with self._conn() as conn:
98
+ row = conn.execute("SELECT * FROM exports WHERE name = ?", (name,)).fetchone()
99
+ return _row_to_export(row) if row else None
100
+
101
+ def upsert(self, export: Export) -> None:
102
+ with _DB_WRITE_LOCK, self._conn() as conn:
103
+ conn.execute(
104
+ """
105
+ INSERT OR REPLACE INTO exports (
106
+ name, content_sha256, nbd_port, status, error,
107
+ created_at, updated_at
108
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
109
+ """,
110
+ (
111
+ export.name,
112
+ export.content_sha256,
113
+ export.nbd_port,
114
+ export.status,
115
+ export.error,
116
+ export.created_at,
117
+ now_iso(),
118
+ ),
119
+ )
120
+
121
+ def delete(self, name: str) -> bool:
122
+ with _DB_WRITE_LOCK, self._conn() as conn:
123
+ cur = conn.execute("DELETE FROM exports WHERE name = ?", (name,))
124
+ return cur.rowcount > 0
125
+
126
+ def update_runtime(
127
+ self,
128
+ name: str,
129
+ *,
130
+ nbd_port: int,
131
+ status: str,
132
+ error: str = "",
133
+ ) -> None:
134
+ """Refresh the transient fields (port + status + error) after
135
+ a spawn attempt. Silent on missing row (raced with a delete)."""
136
+ with _DB_WRITE_LOCK, self._conn() as conn:
137
+ conn.execute(
138
+ """
139
+ UPDATE exports
140
+ SET nbd_port = ?, status = ?, error = ?, updated_at = ?
141
+ WHERE name = ?
142
+ """,
143
+ (nbd_port, status, error, now_iso(), name),
144
+ )
145
+
146
+
147
+ def _row_to_export(row: sqlite3.Row) -> Export:
148
+ return Export(
149
+ name=row["name"],
150
+ content_sha256=row["content_sha256"],
151
+ nbd_port=row["nbd_port"] or 0,
152
+ status=row["status"],
153
+ error=row["error"],
154
+ created_at=row["created_at"],
155
+ updated_at=row["updated_at"],
156
+ )
@@ -0,0 +1,240 @@
1
+ """NbdServer: one nbdkit subprocess per registered export.
2
+
3
+ Port allocation: :attr:`port_base` is scanned upward; a running nbdkit
4
+ holds its port for the export's lifetime. :meth:`reload` diff-syncs the
5
+ running processes against a desired export list (spawn missing, kill
6
+ dropped). :meth:`stop` kills everything.
7
+
8
+ Idempotent + thread-safe: :meth:`spawn` on an already-alive export is
9
+ a no-op; :meth:`terminate` on an unknown export is a no-op.
10
+
11
+ Filter chain:
12
+ * ``--filter=cow`` always. Under nbdkit >= 1.44 this is safe with
13
+ per-connection named exports; earlier nbdkit silently corrupts
14
+ under this combination (the base container image pins ubuntu:26.04
15
+ which ships 1.46 for that reason).
16
+ * ``--filter=partition`` when the blob's boot sector has an MBR/GPT
17
+ magic. Full-disk images (nosi's ``img.gz`` shape) need this so
18
+ ``nbd0p1`` shows up; raw filesystem images do not.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import logging
25
+ import socket
26
+ import subprocess
27
+ import sys
28
+ import threading
29
+ import time
30
+ from pathlib import Path
31
+
32
+ _log = logging.getLogger(__name__)
33
+
34
+ DEFAULT_NBDKIT_BIN = "nbdkit"
35
+ DEFAULT_PORT_BASE = 10809
36
+ _PORT_SCAN_WIDTH = 256
37
+ _SPAWN_STARTUP_GRACE = 0.2 # seconds -- give nbdkit a moment to bind + fail
38
+
39
+
40
+ def file_looks_partitioned(path: Path) -> bool:
41
+ """True iff the file at ``path`` has an MBR/GPT partition table.
42
+
43
+ Checks the classic boot-sector magic ``0x55 0xAA`` at bytes
44
+ 510-511. Covers MBR + protective-MBR-for-GPT + hybrid disks.
45
+ Files smaller than 512 bytes, unreadable, or read-erroring return
46
+ False (safer default: skip the partition filter -- if we're
47
+ wrong, the boot fails loudly on mount instead of silently
48
+ stripping to partition 1 of a non-partitioned image).
49
+ """
50
+ try:
51
+ with open(path, "rb") as f:
52
+ head = f.read(512)
53
+ except OSError:
54
+ return False
55
+ return len(head) == 512 and head[510:512] == b"\x55\xaa"
56
+
57
+
58
+ def _port_available(bind: str, port: int) -> bool:
59
+ """True iff ``bind:port`` accepts a fresh bind. The ~1ms race
60
+ between this check and nbdkit's own bind is fine: nbdkit exits
61
+ loudly on bind failure and the caller reports the error."""
62
+ s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
63
+ try:
64
+ s.bind((bind, port))
65
+ except OSError:
66
+ return False
67
+ else:
68
+ return True
69
+ finally:
70
+ with contextlib.suppress(OSError):
71
+ s.close()
72
+
73
+
74
+ class NbdServer:
75
+ """Manages nbdkit subprocesses for a collection of exports.
76
+
77
+ ``port_base`` is the first port scanned; each spawn takes the
78
+ next free port in a window of :data:`_PORT_SCAN_WIDTH`. The
79
+ server does NOT persist ports; callers who need to survive
80
+ process restart write ``NbdServer.port_for(name)`` to their
81
+ own store post-spawn.
82
+ """
83
+
84
+ def __init__(
85
+ self,
86
+ *,
87
+ port_base: int = DEFAULT_PORT_BASE,
88
+ bind: str = "0.0.0.0",
89
+ nbdkit_bin: str = DEFAULT_NBDKIT_BIN,
90
+ ) -> None:
91
+ self.port_base = port_base
92
+ self.bind = bind
93
+ self.bin = nbdkit_bin
94
+ self._procs: dict[str, subprocess.Popen[bytes]] = {}
95
+ self._ports: dict[str, int] = {}
96
+ self._paths: dict[str, Path] = {}
97
+ self._lock = threading.Lock()
98
+
99
+ # ---------- public API ------------------------------------------
100
+
101
+ def spawn(self, name: str, blob_path: Path) -> int:
102
+ """Spawn nbdkit for ``name`` serving ``blob_path``. Idempotent.
103
+
104
+ Returns the port the export listens on (allocated fresh or
105
+ reused if already running). Raises :class:`RuntimeError` if
106
+ nbdkit refuses to start (bad binary, port collision, missing
107
+ file).
108
+ """
109
+ with self._lock:
110
+ return self._spawn_locked(name, blob_path)
111
+
112
+ def terminate(self, name: str) -> bool:
113
+ """Kill the nbdkit for ``name``. Returns True iff a process
114
+ was actually killed. No-op if the export isn't running."""
115
+ with self._lock:
116
+ return self._terminate_locked(name)
117
+
118
+ def reload(self, desired: dict[str, Path]) -> None:
119
+ """Diff-sync against ``desired``: spawn every name we're not
120
+ already serving, kill every running export that dropped out of
121
+ the map. Order-independent."""
122
+ with self._lock:
123
+ # Kill dropped
124
+ for name in list(self._procs):
125
+ if name not in desired:
126
+ self._terminate_locked(name)
127
+ # Spawn desired
128
+ for name, path in desired.items():
129
+ self._spawn_locked(name, path)
130
+
131
+ def stop(self) -> None:
132
+ """Kill every running nbdkit. Called on app shutdown."""
133
+ with self._lock:
134
+ for name in list(self._procs):
135
+ self._terminate_locked(name)
136
+
137
+ def is_running(self, name: str) -> bool:
138
+ with self._lock:
139
+ proc = self._procs.get(name)
140
+ return proc is not None and proc.poll() is None
141
+
142
+ def port_for(self, name: str) -> int | None:
143
+ with self._lock:
144
+ return self._ports.get(name)
145
+
146
+ def running_exports(self) -> dict[str, int]:
147
+ """Snapshot of currently-running exports and their ports."""
148
+ with self._lock:
149
+ return {n: p for n, p in self._ports.items() if self._procs[n].poll() is None}
150
+
151
+ # ---------- internals -------------------------------------------
152
+
153
+ def _spawn_locked(self, name: str, blob_path: Path) -> int:
154
+ """Requires ``self._lock``. Idempotent per name."""
155
+ existing = self._procs.get(name)
156
+ if existing is not None and existing.poll() is None:
157
+ return self._ports[name]
158
+
159
+ # Reap dead entry so its port slot is available for reuse.
160
+ if existing is not None:
161
+ self._procs.pop(name, None)
162
+ self._ports.pop(name, None)
163
+ self._paths.pop(name, None)
164
+
165
+ if not blob_path.is_file():
166
+ raise RuntimeError(f"blob {blob_path!s} does not exist")
167
+
168
+ port = self._allocate_port_locked()
169
+
170
+ argv: list[str] = [
171
+ self.bin,
172
+ "-p",
173
+ str(port),
174
+ "--ipaddr",
175
+ self.bind,
176
+ "-f",
177
+ "--newstyle",
178
+ "-e",
179
+ name,
180
+ "--filter=cow",
181
+ ]
182
+ # --filter=partition must sit BELOW --filter=cow (nearer the
183
+ # plugin) so client writes land in the cow overlay, not in a
184
+ # partition-filter-managed slice of the backing.
185
+ partitioned = file_looks_partitioned(blob_path)
186
+ if partitioned:
187
+ argv.append("--filter=partition")
188
+ # nbdkit's arg parser treats the first non-flag as the plugin
189
+ # name and everything after as KEY=VALUE plugin params.
190
+ # Filter params come after the plugin.
191
+ argv.extend(["file", f"file={blob_path!s}"])
192
+ if partitioned:
193
+ argv.append("partition=1")
194
+
195
+ _log.info("nbdkit spawn %r on port %d: %s", name, port, blob_path)
196
+ try:
197
+ proc = subprocess.Popen(argv, stdout=sys.stderr, stderr=sys.stderr)
198
+ except FileNotFoundError as exc:
199
+ raise RuntimeError(f"nbdkit binary not found: {self.bin!r}") from exc
200
+
201
+ # Give the child a moment to bind or fail loudly.
202
+ time.sleep(_SPAWN_STARTUP_GRACE)
203
+ if proc.poll() is not None:
204
+ rc = proc.returncode
205
+ raise RuntimeError(
206
+ f"nbdkit for export {name!r} exited immediately (rc={rc}, port={port}, "
207
+ f"file={blob_path!s}); check the binary + file exist and the port is free"
208
+ )
209
+
210
+ self._procs[name] = proc
211
+ self._ports[name] = port
212
+ self._paths[name] = blob_path
213
+ return port
214
+
215
+ def _terminate_locked(self, name: str) -> bool:
216
+ """Requires ``self._lock``. Returns True iff we killed a proc."""
217
+ proc = self._procs.pop(name, None)
218
+ self._ports.pop(name, None)
219
+ self._paths.pop(name, None)
220
+ if proc is None:
221
+ return False
222
+ if proc.poll() is None:
223
+ proc.terminate()
224
+ with contextlib.suppress(subprocess.TimeoutExpired):
225
+ proc.wait(timeout=3)
226
+ if proc.poll() is None:
227
+ proc.kill()
228
+ return True
229
+
230
+ def _allocate_port_locked(self) -> int:
231
+ """Return the next free port at or above ``self.port_base``."""
232
+ used = set(self._ports.values())
233
+ for p in range(self.port_base, self.port_base + _PORT_SCAN_WIDTH):
234
+ if p in used:
235
+ continue
236
+ if _port_available(self.bind, p):
237
+ return p
238
+ raise RuntimeError(
239
+ f"no free TCP port in range {self.port_base}..{self.port_base + _PORT_SCAN_WIDTH}"
240
+ )