pixie-lab 0.3.2__py3-none-any.whl → 0.4.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/deploy/_main.py +25 -3
- pixie/events/_kinds.py +21 -19
- pixie/exports/_store.py +118 -80
- pixie/machines/_routes.py +23 -7
- pixie/machines/_store.py +56 -30
- pixie/pxe/_renderer.py +62 -82
- pixie/pxe/_routes.py +13 -0
- pixie/web/_bind_preview.py +13 -9
- pixie/web/_fsutil.py +36 -0
- pixie/web/_images.py +194 -0
- pixie/web/_overlay_bind.py +101 -0
- pixie/web/_overlays.py +68 -50
- pixie/web/_settings_store.py +7 -5
- pixie/web/_templates/catalog.html +21 -57
- pixie/web/_templates/catalog_detail.html +1 -1
- pixie/web/_templates/dashboard.html +66 -76
- pixie/web/_templates/image_detail.html +108 -0
- pixie/web/_templates/images.html +139 -0
- pixie/web/_templates/layout.html +3 -0
- pixie/web/_templates/machine_detail.html +88 -69
- pixie/web/_templates/machines.html +16 -5
- pixie/web/_templates/overlays.html +36 -29
- pixie/web/main.py +303 -114
- {pixie_lab-0.3.2.dist-info → pixie_lab-0.4.0.dist-info}/METADATA +1 -1
- {pixie_lab-0.3.2.dist-info → pixie_lab-0.4.0.dist-info}/RECORD +28 -23
- {pixie_lab-0.3.2.dist-info → pixie_lab-0.4.0.dist-info}/WHEEL +0 -0
- {pixie_lab-0.3.2.dist-info → pixie_lab-0.4.0.dist-info}/entry_points.txt +0 -0
- {pixie_lab-0.3.2.dist-info → pixie_lab-0.4.0.dist-info}/licenses/LICENSE +0 -0
pixie/deploy/_main.py
CHANGED
|
@@ -311,10 +311,32 @@ def _cmd_purge(args: argparse.Namespace) -> int:
|
|
|
311
311
|
shutil.rmtree(data_dir)
|
|
312
312
|
print(f"pixie-lab purge: removed {data_dir}", file=sys.stderr)
|
|
313
313
|
|
|
314
|
-
# 4. Delete the deploy directory itself.
|
|
314
|
+
# 4. Delete the deploy directory itself. ``dest`` often sits under a
|
|
315
|
+
# root-owned parent (e.g. /opt), where an unprivileged operator can
|
|
316
|
+
# empty the dir but can't rmdir it -- rmtree clears the contents,
|
|
317
|
+
# then raises on the final rmdir. Report that plainly instead of
|
|
318
|
+
# dumping a traceback; the state is gone either way, so it's a
|
|
319
|
+
# caveat, not a failure.
|
|
315
320
|
if remove_dir and dest.exists():
|
|
316
|
-
|
|
317
|
-
|
|
321
|
+
try:
|
|
322
|
+
shutil.rmtree(dest)
|
|
323
|
+
print(f"pixie-lab purge: removed {dest}", file=sys.stderr)
|
|
324
|
+
except OSError as exc:
|
|
325
|
+
leftover = sorted(p.name for p in dest.iterdir()) if dest.is_dir() else []
|
|
326
|
+
if not leftover:
|
|
327
|
+
print(
|
|
328
|
+
f"pixie-lab purge: emptied {dest}, but could not remove the "
|
|
329
|
+
f"directory itself ({exc.strerror or exc}) -- its parent is not "
|
|
330
|
+
f"writable (a root-owned /opt, say). The empty dir is harmless; "
|
|
331
|
+
f"you can redeploy into it or 'sudo rmdir' it.",
|
|
332
|
+
file=sys.stderr,
|
|
333
|
+
)
|
|
334
|
+
else:
|
|
335
|
+
print(
|
|
336
|
+
f"pixie-lab purge: could not fully remove {dest} "
|
|
337
|
+
f"({exc.strerror or exc}); leftover: {', '.join(leftover)}",
|
|
338
|
+
file=sys.stderr,
|
|
339
|
+
)
|
|
318
340
|
|
|
319
341
|
return 0
|
|
320
342
|
|
pixie/events/_kinds.py
CHANGED
|
@@ -21,7 +21,9 @@ from __future__ import annotations
|
|
|
21
21
|
# ---------- catalog + fetch pipeline ---------------------------------
|
|
22
22
|
#
|
|
23
23
|
# Every catalog-store mutation emits one of these. ``subject_kind`` is
|
|
24
|
-
#
|
|
24
|
+
# ``"entry"`` (``subject_id`` = the catalog entry name) for the per-row
|
|
25
|
+
# kinds; the ``catalog.import.*`` pair is catalog-scoped instead
|
|
26
|
+
# (``subject_kind = "catalog"``) since one import touches many entries.
|
|
25
27
|
|
|
26
28
|
CATALOG_ENTRY_ADDED = "catalog.entry.added"
|
|
27
29
|
"""A new row landed via POST /catalog/entries, POST /ui/catalog/add,
|
|
@@ -95,36 +97,37 @@ EXPORT_NBDKIT_EXITED = "export.nbdkit.exited"
|
|
|
95
97
|
(``_refresh_row`` noticed no live proc for a ``running`` row).
|
|
96
98
|
``details.previous_port`` + ``details.error`` explain."""
|
|
97
99
|
|
|
98
|
-
# ---------- persistent overlays (
|
|
100
|
+
# ---------- persistent overlays (alias-keyed volumes) ---------------
|
|
99
101
|
#
|
|
100
|
-
# ``subject_kind`` is always ``"
|
|
101
|
-
#
|
|
102
|
+
# ``subject_kind`` is always ``"overlay"``; ``subject_id`` is the
|
|
103
|
+
# globally-unique ``alias`` (``""`` for a fleet-wide bulk action). An
|
|
104
|
+
# overlay is a single-writer qcow2 volume over one base image, not a
|
|
105
|
+
# per-machine file; ``details`` carries ``alias`` / ``image_sha`` /
|
|
106
|
+
# ``qcow2_path`` as relevant.
|
|
102
107
|
|
|
103
108
|
OVERLAY_CREATED = "overlay.created"
|
|
104
|
-
"""
|
|
105
|
-
|
|
106
|
-
``
|
|
109
|
+
"""An alias-keyed qcow2 overlay was materialised for the first time
|
|
110
|
+
(lazy-created on the first nbdboot render, or after a Reset).
|
|
111
|
+
``details`` carries ``alias``, ``image_sha``, and ``qcow2_path``."""
|
|
107
112
|
|
|
108
113
|
OVERLAY_RESET = "overlay.reset"
|
|
109
|
-
"""Operator hit Reset
|
|
110
|
-
terminated, the qcow2
|
|
114
|
+
"""Operator hit Reset (one alias) or Prune (bulk) on the Overlays
|
|
115
|
+
page: the qemu-nbd was terminated, the qcow2 unlinked, the overlays
|
|
116
|
+
row deleted. ``subject_id`` is the alias for a single Reset, ``""``
|
|
117
|
+
for a bulk Prune (``details.pruned`` counts the reclaimed volumes).
|
|
111
118
|
Next plan render lazily creates a fresh overlay from the base."""
|
|
112
119
|
|
|
113
|
-
OVERLAY_BOOTED = "overlay.booted"
|
|
114
|
-
"""Plan render for an already-existing overlay row (heartbeat).
|
|
115
|
-
Emitted on every persistent nbdboot render so an operator can grep
|
|
116
|
-
the events log for "which overlays am I actually using?"."""
|
|
117
|
-
|
|
118
120
|
# ---------- machines + PXE ------------------------------------------
|
|
119
121
|
#
|
|
120
122
|
# ``subject_kind`` is always ``"machine"``; ``subject_id`` is the
|
|
121
123
|
# canonical MAC address.
|
|
122
124
|
|
|
123
125
|
MACHINE_DISCOVERED = "machine.discovered"
|
|
124
|
-
"""First-ever GET /pxe/<mac> for a MAC pixie has not seen before.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
via the ``pxe.plan.rendered`` event
|
|
126
|
+
"""First-ever GET /pxe/<mac> for a MAC pixie has not seen before. The
|
|
127
|
+
``/pxe/<mac>`` route detects the absent row before ``touch_seen``
|
|
128
|
+
auto-registers it and emits this once; subsequent hits do NOT emit
|
|
129
|
+
(they update ``last_seen_at`` via the ``pxe.plan.rendered`` event
|
|
130
|
+
instead). ``details.boot_mode`` is the auto-registered default."""
|
|
128
131
|
|
|
129
132
|
MACHINE_BOUND = "machine.bound"
|
|
130
133
|
"""First bind for a MAC: PUT /machines/<mac> or POST
|
|
@@ -232,7 +235,6 @@ KNOWN_EVENT_KINDS: frozenset[str] = frozenset(
|
|
|
232
235
|
EXPORT_NBDKIT_EXITED,
|
|
233
236
|
OVERLAY_CREATED,
|
|
234
237
|
OVERLAY_RESET,
|
|
235
|
-
OVERLAY_BOOTED,
|
|
236
238
|
MACHINE_DISCOVERED,
|
|
237
239
|
MACHINE_BOUND,
|
|
238
240
|
MACHINE_BINDING_CHANGED,
|
pixie/exports/_store.py
CHANGED
|
@@ -33,33 +33,80 @@ CREATE TABLE IF NOT EXISTS exports (
|
|
|
33
33
|
|
|
34
34
|
CREATE INDEX IF NOT EXISTS idx_exports_content_sha
|
|
35
35
|
ON exports(content_sha256);
|
|
36
|
+
"""
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
38
|
+
# An overlay is a globally-named writable volume over ONE base image.
|
|
39
|
+
# Identity is its ``alias`` (globally unique), NOT the machine: the
|
|
40
|
+
# qcow2 at ``qcow2_path`` has ``backing_file`` pointing at the catalog
|
|
41
|
+
# blob for ``image_sha`` and holds every write made through it. Exactly
|
|
42
|
+
# one machine may hold exclusive write at a time -- ``attached_mac``
|
|
43
|
+
# (empty = free) records which; qemu-nbd's qcow2 image-lock is the
|
|
44
|
+
# backstop. Separate table from ``exports`` because the ephemeral
|
|
45
|
+
# (nbdkit, per-content) and persistent (qemu-nbd, per-alias) paths have
|
|
46
|
+
# different identity + lifecycle.
|
|
47
|
+
_OVERLAYS_SCHEMA = """
|
|
44
48
|
CREATE TABLE IF NOT EXISTS overlays (
|
|
45
|
-
|
|
49
|
+
alias TEXT PRIMARY KEY,
|
|
46
50
|
image_sha TEXT NOT NULL,
|
|
47
|
-
profile TEXT NOT NULL,
|
|
48
51
|
qcow2_path TEXT NOT NULL,
|
|
52
|
+
attached_mac TEXT NOT NULL DEFAULT '',
|
|
49
53
|
nbd_port INTEGER NOT NULL DEFAULT 0,
|
|
50
54
|
status TEXT NOT NULL DEFAULT 'idle',
|
|
51
55
|
error TEXT NOT NULL DEFAULT '',
|
|
52
56
|
created_at TEXT NOT NULL,
|
|
53
|
-
last_boot_at TEXT NOT NULL DEFAULT ''
|
|
54
|
-
PRIMARY KEY (mac, image_sha, profile)
|
|
57
|
+
last_boot_at TEXT NOT NULL DEFAULT ''
|
|
55
58
|
);
|
|
56
|
-
CREATE INDEX IF NOT EXISTS idx_overlays_mac
|
|
57
|
-
ON overlays(mac);
|
|
58
59
|
CREATE INDEX IF NOT EXISTS idx_overlays_image_sha
|
|
59
60
|
ON overlays(image_sha);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_overlays_attached_mac
|
|
62
|
+
ON overlays(attached_mac);
|
|
60
63
|
"""
|
|
61
64
|
|
|
62
65
|
|
|
66
|
+
def _migrate_overlays_schema(conn: sqlite3.Connection) -> None:
|
|
67
|
+
"""Re-key the pre-alias ``overlays`` table (PK
|
|
68
|
+
``(mac, image_sha, profile)``) to the alias-keyed shape. For each
|
|
69
|
+
old row a globally-unique ``alias = <profile>-<mac_slug>`` is minted
|
|
70
|
+
(dedup on the rare collision) and ``attached_mac`` is seeded with the
|
|
71
|
+
row's old ``mac`` so the machine that owned it keeps its exclusive
|
|
72
|
+
hold. The qcow2 ``qcow2_path`` is left untouched -- no large files
|
|
73
|
+
move during migration. Idempotent: a table that already has an
|
|
74
|
+
``alias`` column, or no overlays table at all, is left alone."""
|
|
75
|
+
cols = {row[1] for row in conn.execute("PRAGMA table_info(overlays)").fetchall()}
|
|
76
|
+
if not cols or "alias" in cols:
|
|
77
|
+
return
|
|
78
|
+
old_rows = conn.execute("SELECT * FROM overlays").fetchall()
|
|
79
|
+
conn.execute("ALTER TABLE overlays RENAME TO overlays_pre_alias")
|
|
80
|
+
conn.executescript(_OVERLAYS_SCHEMA)
|
|
81
|
+
seen: set[str] = set()
|
|
82
|
+
for r in old_rows:
|
|
83
|
+
mac_slug = str(r["mac"]).replace(":", "-")
|
|
84
|
+
base = f"{r['profile']}-{mac_slug}"
|
|
85
|
+
alias = base
|
|
86
|
+
n = 1
|
|
87
|
+
while alias in seen:
|
|
88
|
+
n += 1
|
|
89
|
+
alias = f"{base}-{n}"
|
|
90
|
+
seen.add(alias)
|
|
91
|
+
conn.execute(
|
|
92
|
+
"""
|
|
93
|
+
INSERT INTO overlays (
|
|
94
|
+
alias, image_sha, qcow2_path, attached_mac,
|
|
95
|
+
nbd_port, status, error, created_at, last_boot_at
|
|
96
|
+
) VALUES (?, ?, ?, ?, 0, 'idle', '', ?, ?)
|
|
97
|
+
""",
|
|
98
|
+
(
|
|
99
|
+
alias,
|
|
100
|
+
r["image_sha"],
|
|
101
|
+
r["qcow2_path"],
|
|
102
|
+
r["mac"],
|
|
103
|
+
r["created_at"],
|
|
104
|
+
r["last_boot_at"],
|
|
105
|
+
),
|
|
106
|
+
)
|
|
107
|
+
conn.execute("DROP TABLE overlays_pre_alias")
|
|
108
|
+
|
|
109
|
+
|
|
63
110
|
@dataclass
|
|
64
111
|
class Export:
|
|
65
112
|
"""One registered NBD export."""
|
|
@@ -99,6 +146,8 @@ class ExportsStore:
|
|
|
99
146
|
def _ensure_schema(self) -> None:
|
|
100
147
|
with self._conn() as conn:
|
|
101
148
|
conn.executescript(_SCHEMA)
|
|
149
|
+
_migrate_overlays_schema(conn)
|
|
150
|
+
conn.executescript(_OVERLAYS_SCHEMA)
|
|
102
151
|
|
|
103
152
|
@contextlib.contextmanager
|
|
104
153
|
def _conn(self) -> Generator[sqlite3.Connection]:
|
|
@@ -182,18 +231,20 @@ def _row_to_export(row: sqlite3.Row) -> Export:
|
|
|
182
231
|
|
|
183
232
|
@dataclass
|
|
184
233
|
class Overlay:
|
|
185
|
-
"""
|
|
234
|
+
"""A globally-named writable volume over one base image.
|
|
186
235
|
|
|
187
|
-
Identity is the ``(
|
|
188
|
-
``qcow2_path`` has ``backing_file`` pointing at the catalog
|
|
189
|
-
for ``image_sha
|
|
190
|
-
``
|
|
191
|
-
|
|
236
|
+
Identity is the ``alias`` (globally unique), not a machine: the
|
|
237
|
+
qcow2 at ``qcow2_path`` has ``backing_file`` pointing at the catalog
|
|
238
|
+
blob for ``image_sha`` and holds every write made through it.
|
|
239
|
+
``attached_mac`` records the single machine currently holding
|
|
240
|
+
exclusive write ("" = free); a qemu-nbd subprocess serves it while
|
|
241
|
+
``status == 'running'``. Deleting the row is the operator's "Reset"
|
|
242
|
+
action -- the caller unlinks the qcow2 too."""
|
|
192
243
|
|
|
193
|
-
|
|
244
|
+
alias: str
|
|
194
245
|
image_sha: str
|
|
195
|
-
profile: str
|
|
196
246
|
qcow2_path: str
|
|
247
|
+
attached_mac: str = ""
|
|
197
248
|
nbd_port: int = 0
|
|
198
249
|
status: str = "idle" # "idle" | "running" | "error"
|
|
199
250
|
error: str = ""
|
|
@@ -202,10 +253,10 @@ class Overlay:
|
|
|
202
253
|
|
|
203
254
|
def to_dict(self) -> dict[str, Any]:
|
|
204
255
|
out: dict[str, Any] = {
|
|
205
|
-
"
|
|
256
|
+
"alias": self.alias,
|
|
206
257
|
"image_sha": self.image_sha,
|
|
207
|
-
"profile": self.profile,
|
|
208
258
|
"qcow2_path": self.qcow2_path,
|
|
259
|
+
"attached_mac": self.attached_mac,
|
|
209
260
|
"status": self.status,
|
|
210
261
|
"created_at": self.created_at,
|
|
211
262
|
}
|
|
@@ -219,12 +270,12 @@ class Overlay:
|
|
|
219
270
|
|
|
220
271
|
|
|
221
272
|
class OverlaysStore:
|
|
222
|
-
"""Repository over the ``overlays`` table
|
|
273
|
+
"""Repository over the alias-keyed ``overlays`` table.
|
|
223
274
|
|
|
224
|
-
Same DB path as :class:`ExportsStore` + :class:`CatalogStore`.
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
275
|
+
Same DB path as :class:`ExportsStore` + :class:`CatalogStore`. The
|
|
276
|
+
table is created + migrated by :meth:`ExportsStore._ensure_schema`
|
|
277
|
+
(constructed first at startup), so this is a thin data-access
|
|
278
|
+
sibling."""
|
|
228
279
|
|
|
229
280
|
def __init__(self, db_path: Path) -> None:
|
|
230
281
|
self.db_path = Path(db_path)
|
|
@@ -240,31 +291,21 @@ class OverlaysStore:
|
|
|
240
291
|
conn.close()
|
|
241
292
|
|
|
242
293
|
def list_all(self) -> list[Overlay]:
|
|
243
|
-
"""Every overlay row across every (mac, image, profile) triple.
|
|
244
|
-
Named ``list_all`` (not ``list``) so ``list[Overlay]`` in
|
|
245
|
-
sibling method signatures below resolves to the built-in
|
|
246
|
-
rather than the shadowed method."""
|
|
247
294
|
with self._conn() as conn:
|
|
248
|
-
rows = conn.execute(
|
|
249
|
-
"SELECT * FROM overlays ORDER BY mac, image_sha, profile"
|
|
250
|
-
).fetchall()
|
|
295
|
+
rows = conn.execute("SELECT * FROM overlays ORDER BY alias").fetchall()
|
|
251
296
|
return [_row_to_overlay(r) for r in rows]
|
|
252
297
|
|
|
253
|
-
def get(self,
|
|
298
|
+
def get(self, alias: str) -> Overlay | None:
|
|
254
299
|
with self._conn() as conn:
|
|
255
|
-
row = conn.execute(
|
|
256
|
-
"SELECT * FROM overlays WHERE mac = ? AND image_sha = ? AND profile = ?",
|
|
257
|
-
(mac, image_sha, profile),
|
|
258
|
-
).fetchone()
|
|
300
|
+
row = conn.execute("SELECT * FROM overlays WHERE alias = ?", (alias,)).fetchone()
|
|
259
301
|
return _row_to_overlay(row) if row else None
|
|
260
302
|
|
|
261
|
-
def
|
|
262
|
-
"""Every
|
|
263
|
-
|
|
303
|
+
def list_for_image(self, image_sha: str) -> list[Overlay]:
|
|
304
|
+
"""Every overlay over this base image. Drives the bind-form
|
|
305
|
+
picker (which aliases a machine could attach for this image)."""
|
|
264
306
|
with self._conn() as conn:
|
|
265
307
|
rows = conn.execute(
|
|
266
|
-
"SELECT * FROM overlays WHERE
|
|
267
|
-
(mac, image_sha),
|
|
308
|
+
"SELECT * FROM overlays WHERE image_sha = ? ORDER BY alias", (image_sha,)
|
|
268
309
|
).fetchall()
|
|
269
310
|
return [_row_to_overlay(r) for r in rows]
|
|
270
311
|
|
|
@@ -273,15 +314,15 @@ class OverlaysStore:
|
|
|
273
314
|
conn.execute(
|
|
274
315
|
"""
|
|
275
316
|
INSERT OR REPLACE INTO overlays (
|
|
276
|
-
|
|
317
|
+
alias, image_sha, qcow2_path, attached_mac,
|
|
277
318
|
nbd_port, status, error, created_at, last_boot_at
|
|
278
319
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
279
320
|
""",
|
|
280
321
|
(
|
|
281
|
-
ov.
|
|
322
|
+
ov.alias,
|
|
282
323
|
ov.image_sha,
|
|
283
|
-
ov.profile,
|
|
284
324
|
ov.qcow2_path,
|
|
325
|
+
ov.attached_mac,
|
|
285
326
|
ov.nbd_port,
|
|
286
327
|
ov.status,
|
|
287
328
|
ov.error,
|
|
@@ -290,55 +331,52 @@ class OverlaysStore:
|
|
|
290
331
|
),
|
|
291
332
|
)
|
|
292
333
|
|
|
293
|
-
def delete(self,
|
|
334
|
+
def delete(self, alias: str) -> bool:
|
|
294
335
|
with _DB_WRITE_LOCK, self._conn() as conn:
|
|
295
|
-
cur = conn.execute(
|
|
296
|
-
"DELETE FROM overlays WHERE mac = ? AND image_sha = ? AND profile = ?",
|
|
297
|
-
(mac, image_sha, profile),
|
|
298
|
-
)
|
|
336
|
+
cur = conn.execute("DELETE FROM overlays WHERE alias = ?", (alias,))
|
|
299
337
|
return cur.rowcount > 0
|
|
300
338
|
|
|
301
|
-
def update_runtime(
|
|
302
|
-
self,
|
|
303
|
-
mac: str,
|
|
304
|
-
image_sha: str,
|
|
305
|
-
profile: str,
|
|
306
|
-
*,
|
|
307
|
-
nbd_port: int,
|
|
308
|
-
status: str,
|
|
309
|
-
error: str = "",
|
|
310
|
-
) -> None:
|
|
339
|
+
def update_runtime(self, alias: str, *, nbd_port: int, status: str, error: str = "") -> None:
|
|
311
340
|
"""Refresh transient port + status after a qemu-nbd spawn."""
|
|
312
341
|
with _DB_WRITE_LOCK, self._conn() as conn:
|
|
313
342
|
conn.execute(
|
|
314
|
-
""
|
|
315
|
-
|
|
316
|
-
SET nbd_port = ?, status = ?, error = ?
|
|
317
|
-
WHERE mac = ? AND image_sha = ? AND profile = ?
|
|
318
|
-
""",
|
|
319
|
-
(nbd_port, status, error, mac, image_sha, profile),
|
|
343
|
+
"UPDATE overlays SET nbd_port = ?, status = ?, error = ? WHERE alias = ?",
|
|
344
|
+
(nbd_port, status, error, alias),
|
|
320
345
|
)
|
|
321
346
|
|
|
322
|
-
def touch_last_boot(self,
|
|
323
|
-
|
|
324
|
-
|
|
347
|
+
def touch_last_boot(self, alias: str) -> None:
|
|
348
|
+
with _DB_WRITE_LOCK, self._conn() as conn:
|
|
349
|
+
conn.execute("UPDATE overlays SET last_boot_at = ? WHERE alias = ?", (now_iso(), alias))
|
|
350
|
+
|
|
351
|
+
def attach(self, alias: str, mac: str) -> None:
|
|
352
|
+
"""Record ``mac`` as the exclusive writer of ``alias``.
|
|
353
|
+
Single-writer is enforced at the call site (bind route + renderer
|
|
354
|
+
refuse a hand-off to a different mac); this just records it."""
|
|
355
|
+
with _DB_WRITE_LOCK, self._conn() as conn:
|
|
356
|
+
conn.execute("UPDATE overlays SET attached_mac = ? WHERE alias = ?", (mac, alias))
|
|
357
|
+
|
|
358
|
+
def detach(self, alias: str) -> None:
|
|
359
|
+
"""Release the writer hold on ``alias`` (attached_mac -> '')."""
|
|
360
|
+
with _DB_WRITE_LOCK, self._conn() as conn:
|
|
361
|
+
conn.execute("UPDATE overlays SET attached_mac = '' WHERE alias = ?", (alias,))
|
|
362
|
+
|
|
363
|
+
def detach_mac(self, mac: str, *, keep: str = "") -> None:
|
|
364
|
+
"""Release every alias ``mac`` holds except ``keep`` -- called
|
|
365
|
+
when a machine rebinds away so a stale hold can't block another
|
|
366
|
+
machine from attaching."""
|
|
325
367
|
with _DB_WRITE_LOCK, self._conn() as conn:
|
|
326
368
|
conn.execute(
|
|
327
|
-
""
|
|
328
|
-
|
|
329
|
-
SET last_boot_at = ?
|
|
330
|
-
WHERE mac = ? AND image_sha = ? AND profile = ?
|
|
331
|
-
""",
|
|
332
|
-
(now_iso(), mac, image_sha, profile),
|
|
369
|
+
"UPDATE overlays SET attached_mac = '' WHERE attached_mac = ? AND alias != ?",
|
|
370
|
+
(mac, keep),
|
|
333
371
|
)
|
|
334
372
|
|
|
335
373
|
|
|
336
374
|
def _row_to_overlay(row: sqlite3.Row) -> Overlay:
|
|
337
375
|
return Overlay(
|
|
338
|
-
|
|
376
|
+
alias=row["alias"],
|
|
339
377
|
image_sha=row["image_sha"],
|
|
340
|
-
profile=row["profile"],
|
|
341
378
|
qcow2_path=row["qcow2_path"],
|
|
379
|
+
attached_mac=row["attached_mac"],
|
|
342
380
|
nbd_port=row["nbd_port"] or 0,
|
|
343
381
|
status=row["status"],
|
|
344
382
|
error=row["error"],
|
pixie/machines/_routes.py
CHANGED
|
@@ -66,13 +66,15 @@ class BindBody(BaseModel):
|
|
|
66
66
|
"the global PIXIE_LIVE_ENV_EXTRA_CMDLINE. Single line."
|
|
67
67
|
),
|
|
68
68
|
)
|
|
69
|
-
|
|
69
|
+
overlay_alias: str = Field(
|
|
70
70
|
default="",
|
|
71
71
|
description=(
|
|
72
|
-
"
|
|
73
|
-
"ephemeral tmpfs (writes vanish on reboot); non-blank
|
|
74
|
-
"
|
|
75
|
-
"
|
|
72
|
+
"Globally-unique persistent-overlay alias for nbdboot. Blank "
|
|
73
|
+
"means ephemeral tmpfs (writes vanish on reboot); non-blank "
|
|
74
|
+
"attaches a named writable volume (qcow2 over a base image) "
|
|
75
|
+
"that persists on pixie's data volume. Single-writer: one "
|
|
76
|
+
"machine per alias. Alphanumeric-leading; a-z / A-Z / 0-9 / "
|
|
77
|
+
". _ - (max 64 chars)."
|
|
76
78
|
),
|
|
77
79
|
)
|
|
78
80
|
|
|
@@ -133,14 +135,28 @@ def upsert_machine(
|
|
|
133
135
|
# not supply any new ones -- honours a partial-update PUT
|
|
134
136
|
# without needing PATCH.
|
|
135
137
|
labels_arg = labels if labels else (list(previous.labels) if previous else [])
|
|
138
|
+
# Single-writer + alias-implies-image resolution is shared with
|
|
139
|
+
# the UI bind route (see pixie.web._overlay_bind): attaching an
|
|
140
|
+
# alias held by another machine raises ValueError -> 422 here.
|
|
141
|
+
from pixie.web._overlay_bind import overlay_state, resolve_overlay_bind
|
|
142
|
+
|
|
143
|
+
overlays, overlays_dir = overlay_state(request.app.state)
|
|
144
|
+
image_sha, resolved_alias = resolve_overlay_bind(
|
|
145
|
+
overlays=overlays,
|
|
146
|
+
overlays_dir=overlays_dir,
|
|
147
|
+
mac=canon,
|
|
148
|
+
boot_mode=body.boot_mode,
|
|
149
|
+
image_sha=body.image_content_sha256.strip().lower(),
|
|
150
|
+
alias=body.overlay_alias,
|
|
151
|
+
)
|
|
136
152
|
row = _get_machines(request).upsert_binding(
|
|
137
153
|
canon,
|
|
138
154
|
boot_mode=body.boot_mode,
|
|
139
|
-
image_content_sha256=
|
|
155
|
+
image_content_sha256=image_sha,
|
|
140
156
|
labels=labels_arg,
|
|
141
157
|
target_disk_serial=body.target_disk_serial,
|
|
142
158
|
extra_cmdline=body.extra_cmdline,
|
|
143
|
-
|
|
159
|
+
overlay_alias=resolved_alias,
|
|
144
160
|
)
|
|
145
161
|
except ValueError as exc:
|
|
146
162
|
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
pixie/machines/_store.py
CHANGED
|
@@ -146,6 +146,7 @@ CREATE TABLE IF NOT EXISTS machines (
|
|
|
146
146
|
target_disk_serial TEXT NOT NULL DEFAULT '',
|
|
147
147
|
extra_cmdline TEXT NOT NULL DEFAULT '',
|
|
148
148
|
overlay_profile TEXT NOT NULL DEFAULT '',
|
|
149
|
+
overlay_alias TEXT NOT NULL DEFAULT '',
|
|
149
150
|
inventory_json TEXT NOT NULL DEFAULT '',
|
|
150
151
|
inventory_at TEXT NOT NULL DEFAULT '',
|
|
151
152
|
discovered_at TEXT NOT NULL,
|
|
@@ -163,19 +164,27 @@ CREATE INDEX IF NOT EXISTS idx_machines_image_content_sha
|
|
|
163
164
|
# live env's flash pipeline has a concrete destination.
|
|
164
165
|
_FLASH_MODES: frozenset[str] = frozenset({"pixie-flash-once", "pixie-flash-always"})
|
|
165
166
|
|
|
167
|
+
# Boot modes that actually consume the machine's bound disk image on the
|
|
168
|
+
# next PXE: the flash pipeline writes it, nbdboot serves it over NBD. The
|
|
169
|
+
# other modes (ipxe-exit / pixie-inventory / pixie-tui) ignore
|
|
170
|
+
# ``image_content_sha256`` entirely, so surfacing a bound image for them
|
|
171
|
+
# is noise. Used by the machines-list Image column.
|
|
172
|
+
IMAGE_BOOT_MODES: frozenset[str] = _FLASH_MODES | frozenset({"nbdboot"})
|
|
173
|
+
|
|
166
174
|
# Bty's shape: alphanumeric-leading, alphanumeric + space + . _ - inside,
|
|
167
175
|
# 64 chars max per label, 16 labels max per machine. Matches the CSS-safe
|
|
168
176
|
# subset so a label can render as a ``.badge`` without escaping surprises.
|
|
169
177
|
_LABEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._\-]{0,63}$")
|
|
170
178
|
_LABEL_LIMIT = 16
|
|
171
179
|
|
|
172
|
-
# Overlay
|
|
173
|
-
#
|
|
174
|
-
#
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
|
|
180
|
+
# Overlay aliases land on disk as ``data/overlays/<alias>.qcow2``.
|
|
181
|
+
# Restrict to a filesystem-safe subset so a hand-typed name can't escape
|
|
182
|
+
# the storage tree via ``..`` or a directory separator. Same alnum-
|
|
183
|
+
# leading shape as labels for consistency; no space to keep the on-disk
|
|
184
|
+
# filename shell-friendly. Public so the bind-route overlay resolver
|
|
185
|
+
# (pixie.web._overlay_bind) validates a new alias with the SAME rule
|
|
186
|
+
# before it ever writes a row or a path.
|
|
187
|
+
OVERLAY_ALIAS_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,63}$")
|
|
179
188
|
|
|
180
189
|
|
|
181
190
|
def _migrate_schema(conn: sqlite3.Connection) -> None:
|
|
@@ -193,6 +202,17 @@ def _migrate_schema(conn: sqlite3.Connection) -> None:
|
|
|
193
202
|
conn.execute("ALTER TABLE machines ADD COLUMN extra_cmdline TEXT NOT NULL DEFAULT ''")
|
|
194
203
|
if "overlay_profile" not in cols:
|
|
195
204
|
conn.execute("ALTER TABLE machines ADD COLUMN overlay_profile TEXT NOT NULL DEFAULT ''")
|
|
205
|
+
# Overlay re-model: a machine now binds a globally-unique overlay
|
|
206
|
+
# ``alias`` instead of a per-(mac,image) ``profile``. Seed the new
|
|
207
|
+
# column from the old one with the SAME deterministic mapping the
|
|
208
|
+
# overlays-table migration uses (``<profile>-<mac_slug>``) so a
|
|
209
|
+
# machine keeps pointing at the same qcow2.
|
|
210
|
+
if "overlay_alias" not in cols:
|
|
211
|
+
conn.execute("ALTER TABLE machines ADD COLUMN overlay_alias TEXT NOT NULL DEFAULT ''")
|
|
212
|
+
conn.execute(
|
|
213
|
+
"UPDATE machines SET overlay_alias = overlay_profile || '-' || "
|
|
214
|
+
"REPLACE(mac, ':', '-') WHERE overlay_profile != '' AND overlay_alias = ''"
|
|
215
|
+
)
|
|
196
216
|
# Retired 2026-07 pre-1.0: sanboot_drive was carried on the bind
|
|
197
217
|
# form as an iPXE ``sanboot`` BIOS drive slug, but pixie never
|
|
198
218
|
# actually rendered it into any iPXE template -- the ipxe-exit
|
|
@@ -255,16 +275,17 @@ class Machine:
|
|
|
255
275
|
workaround on ONE target without dragging the deploy-wide default
|
|
256
276
|
with it."""
|
|
257
277
|
overlay_profile: str = ""
|
|
258
|
-
"""
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
278
|
+
"""Legacy per-(mac,image) overlay profile name. Superseded by
|
|
279
|
+
``overlay_alias``; kept only so a pre-re-model state.db still reads.
|
|
280
|
+
Not consulted by the renderer or UI anymore."""
|
|
281
|
+
overlay_alias: str = ""
|
|
282
|
+
"""The globally-unique overlay ``alias`` this machine attaches for
|
|
283
|
+
``boot_mode == 'nbdboot'``. Blank means "ephemeral tmpfs overlay"
|
|
284
|
+
(writes vanish on reboot). Non-blank names a persistent writable
|
|
285
|
+
volume (a qcow2 over a base image; see :mod:`pixie.exports._store`);
|
|
286
|
+
the overlay carries its own ``image_sha``, and the bind sets this
|
|
287
|
+
machine's ``image_content_sha256`` to match. Single-writer: at most
|
|
288
|
+
one machine holds an alias at a time (``Overlay.attached_mac``)."""
|
|
268
289
|
inventory: dict[str, Any] = field(default_factory=dict)
|
|
269
290
|
inventory_at: str = ""
|
|
270
291
|
discovered_at: str = field(default_factory=now_iso)
|
|
@@ -288,8 +309,8 @@ class Machine:
|
|
|
288
309
|
out["target_disk_serial"] = self.target_disk_serial
|
|
289
310
|
if self.extra_cmdline:
|
|
290
311
|
out["extra_cmdline"] = self.extra_cmdline
|
|
291
|
-
if self.
|
|
292
|
-
out["
|
|
312
|
+
if self.overlay_alias:
|
|
313
|
+
out["overlay_alias"] = self.overlay_alias
|
|
293
314
|
if self.last_seen_ip:
|
|
294
315
|
out["last_seen_ip"] = self.last_seen_ip
|
|
295
316
|
if self.inventory:
|
|
@@ -338,7 +359,7 @@ class MachinesStore:
|
|
|
338
359
|
labels: Sequence[str] | None = None,
|
|
339
360
|
target_disk_serial: str = "",
|
|
340
361
|
extra_cmdline: str = "",
|
|
341
|
-
|
|
362
|
+
overlay_alias: str = "",
|
|
342
363
|
) -> Machine:
|
|
343
364
|
"""Operator-driven write: set boot mode + optional image ref.
|
|
344
365
|
Creates the row if it doesn't exist; preserves discovery
|
|
@@ -355,11 +376,12 @@ class MachinesStore:
|
|
|
355
376
|
``PIXIE_LIVE_ENV_EXTRA_CMDLINE`` fallback. Newlines are
|
|
356
377
|
rejected so a stray paste can't smuggle a second cmdline into
|
|
357
378
|
the iPXE render.
|
|
358
|
-
``
|
|
359
|
-
``nbdboot`` (blank = ephemeral tmpfs). The
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
379
|
+
``overlay_alias`` names a globally-unique persistent overlay for
|
|
380
|
+
``nbdboot`` (blank = ephemeral tmpfs). The alias carries its own
|
|
381
|
+
base image; single-writer exclusivity + the alias<->image link
|
|
382
|
+
are enforced by the bind route (which also sets
|
|
383
|
+
``image_content_sha256`` to the overlay's image). Must match a
|
|
384
|
+
filesystem-safe subset so the on-disk qcow2 name stays legible.
|
|
363
385
|
"""
|
|
364
386
|
canon = normalise_mac(mac)
|
|
365
387
|
if boot_mode not in BOOT_MODES:
|
|
@@ -373,10 +395,10 @@ class MachinesStore:
|
|
|
373
395
|
raise ValueError(
|
|
374
396
|
"extra_cmdline must be a single line (newlines truncate the iPXE render)"
|
|
375
397
|
)
|
|
376
|
-
overlay = (
|
|
377
|
-
if overlay and not
|
|
398
|
+
overlay = (overlay_alias or "").strip()
|
|
399
|
+
if overlay and not OVERLAY_ALIAS_RE.match(overlay):
|
|
378
400
|
raise ValueError(
|
|
379
|
-
"
|
|
401
|
+
"overlay_alias must be alphanumeric-leading; a-z / A-Z / 0-9 / . _ - "
|
|
380
402
|
"(max 64 chars); avoids filesystem escapes into the qcow2 storage path"
|
|
381
403
|
)
|
|
382
404
|
|
|
@@ -417,7 +439,7 @@ class MachinesStore:
|
|
|
417
439
|
INSERT INTO machines (
|
|
418
440
|
mac, boot_mode, image_content_sha256,
|
|
419
441
|
labels, target_disk_serial, extra_cmdline,
|
|
420
|
-
|
|
442
|
+
overlay_alias,
|
|
421
443
|
discovered_at, last_seen_at, last_seen_ip, updated_at
|
|
422
444
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?)
|
|
423
445
|
""",
|
|
@@ -440,7 +462,7 @@ class MachinesStore:
|
|
|
440
462
|
UPDATE machines
|
|
441
463
|
SET boot_mode = ?, image_content_sha256 = ?,
|
|
442
464
|
labels = ?, target_disk_serial = ?,
|
|
443
|
-
extra_cmdline = ?,
|
|
465
|
+
extra_cmdline = ?, overlay_alias = ?,
|
|
444
466
|
updated_at = ?
|
|
445
467
|
WHERE mac = ?
|
|
446
468
|
""",
|
|
@@ -649,6 +671,9 @@ def _row(r: sqlite3.Row) -> Machine:
|
|
|
649
671
|
overlay_profile = ""
|
|
650
672
|
with contextlib.suppress(IndexError, KeyError):
|
|
651
673
|
overlay_profile = r["overlay_profile"] or ""
|
|
674
|
+
overlay_alias = ""
|
|
675
|
+
with contextlib.suppress(IndexError, KeyError):
|
|
676
|
+
overlay_alias = r["overlay_alias"] or ""
|
|
652
677
|
return Machine(
|
|
653
678
|
mac=r["mac"],
|
|
654
679
|
boot_mode=r["boot_mode"],
|
|
@@ -657,6 +682,7 @@ def _row(r: sqlite3.Row) -> Machine:
|
|
|
657
682
|
target_disk_serial=target_serial,
|
|
658
683
|
extra_cmdline=extra_cmdline,
|
|
659
684
|
overlay_profile=overlay_profile,
|
|
685
|
+
overlay_alias=overlay_alias,
|
|
660
686
|
inventory=inv,
|
|
661
687
|
inventory_at=inv_at,
|
|
662
688
|
discovered_at=r["discovered_at"],
|