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.
- pixie/__init__.py +20 -0
- pixie/_util.py +38 -0
- pixie/catalog/__init__.py +25 -0
- pixie/catalog/_fetcher.py +513 -0
- pixie/catalog/_routes.py +392 -0
- pixie/catalog/_schema.py +263 -0
- pixie/catalog/_store.py +234 -0
- pixie/deploy/__init__.py +26 -0
- pixie/deploy/_main.py +321 -0
- pixie/deploy/_templates.py +153 -0
- pixie/disks.py +85 -0
- pixie/events/__init__.py +28 -0
- pixie/events/_kinds.py +203 -0
- pixie/events/_log.py +210 -0
- pixie/events/_routes.py +42 -0
- pixie/exports/__init__.py +17 -0
- pixie/exports/_routes.py +211 -0
- pixie/exports/_store.py +156 -0
- pixie/exports/_supervisor.py +240 -0
- pixie/flash.py +1971 -0
- pixie/images.py +473 -0
- pixie/machines/__init__.py +16 -0
- pixie/machines/_routes.py +190 -0
- pixie/machines/_store.py +623 -0
- pixie/oras.py +547 -0
- pixie/pivot/__init__.py +180 -0
- pixie/pivot/nbdboot +348 -0
- pixie/pxe/__init__.py +19 -0
- pixie/pxe/_renderer.py +244 -0
- pixie/pxe/_routes.py +386 -0
- pixie/tftp/__init__.py +21 -0
- pixie/tftp/_supervisor.py +129 -0
- pixie/tui/__init__.py +185 -0
- pixie/tui/_app.py +2219 -0
- pixie/tui_catalog.py +657 -0
- pixie/web/__init__.py +6 -0
- pixie/web/_auth.py +70 -0
- pixie/web/_settings_store.py +211 -0
- pixie/web/_static/.gitkeep +0 -0
- pixie/web/_static/bootstrap-icons.min.css +5 -0
- pixie/web/_static/bootstrap.min.css +12 -0
- pixie/web/_static/fonts/bootstrap-icons.woff +0 -0
- pixie/web/_static/fonts/bootstrap-icons.woff2 +0 -0
- pixie/web/_static/htmx.min.js +1 -0
- pixie/web/_static/pixie-favicon.png +0 -0
- pixie/web/_static/sse.js +290 -0
- pixie/web/_table_state.py +261 -0
- pixie/web/_templates/_partials/page_description.html +22 -0
- pixie/web/_templates/_partials/recent_events.html +47 -0
- pixie/web/_templates/_partials/table_helpers.html +189 -0
- pixie/web/_templates/catalog.html +364 -0
- pixie/web/_templates/catalog_detail.html +386 -0
- pixie/web/_templates/dashboard.html +256 -0
- pixie/web/_templates/events.html +125 -0
- pixie/web/_templates/ipxe/bootstrap.j2 +12 -0
- pixie/web/_templates/ipxe/exit.j2 +10 -0
- pixie/web/_templates/ipxe/nbdboot.j2 +57 -0
- pixie/web/_templates/ipxe/pixie-live-env.j2 +55 -0
- pixie/web/_templates/ipxe/unavailable.j2 +14 -0
- pixie/web/_templates/layout.html +345 -0
- pixie/web/_templates/login.html +40 -0
- pixie/web/_templates/machine_detail.html +786 -0
- pixie/web/_templates/machines.html +186 -0
- pixie/web/_templates/settings.html +265 -0
- pixie/web/main.py +1910 -0
- pixie_lab-0.1.0.dist-info/METADATA +107 -0
- pixie_lab-0.1.0.dist-info/RECORD +70 -0
- pixie_lab-0.1.0.dist-info/WHEEL +4 -0
- pixie_lab-0.1.0.dist-info/entry_points.txt +3 -0
- pixie_lab-0.1.0.dist-info/licenses/LICENSE +674 -0
pixie/events/_routes.py
ADDED
|
@@ -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
|
+
"""
|
pixie/exports/_routes.py
ADDED
|
@@ -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)
|
pixie/exports/_store.py
ADDED
|
@@ -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
|
+
)
|