pixie-lab 0.3.2__py3-none-any.whl → 0.4.1__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 +31 -13
- pixie/machines/_store.py +85 -34
- pixie/pxe/_renderer.py +62 -82
- pixie/pxe/_routes.py +44 -2
- 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 +30 -11
- 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 +333 -114
- {pixie_lab-0.3.2.dist-info → pixie_lab-0.4.1.dist-info}/METADATA +1 -1
- {pixie_lab-0.3.2.dist-info → pixie_lab-0.4.1.dist-info}/RECORD +28 -23
- {pixie_lab-0.3.2.dist-info → pixie_lab-0.4.1.dist-info}/WHEEL +0 -0
- {pixie_lab-0.3.2.dist-info → pixie_lab-0.4.1.dist-info}/entry_points.txt +0 -0
- {pixie_lab-0.3.2.dist-info → pixie_lab-0.4.1.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
|
@@ -20,7 +20,7 @@ from pydantic import BaseModel, Field
|
|
|
20
20
|
from pixie.events._kinds import MACHINE_BINDING_CHANGED, MACHINE_BOUND, MACHINE_DELETED
|
|
21
21
|
from pixie.machines._store import (
|
|
22
22
|
BOOT_MODES,
|
|
23
|
-
|
|
23
|
+
UNCOMMITTED_BOOT_MODES,
|
|
24
24
|
BadMac,
|
|
25
25
|
MachinesStore,
|
|
26
26
|
normalise_mac,
|
|
@@ -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
|
|
@@ -149,12 +165,14 @@ def upsert_machine(
|
|
|
149
165
|
details: dict[str, Any] = {"boot_mode": row.boot_mode}
|
|
150
166
|
if row.image_content_sha256:
|
|
151
167
|
details["image_content_sha256"] = row.image_content_sha256
|
|
152
|
-
# ``machine.bound`` on a fresh MAC or on a row that was
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
#
|
|
168
|
+
# ``machine.bound`` on a fresh MAC or on a row that was only
|
|
169
|
+
# auto-registered (an uncommitted mode -- ipxe-exit or
|
|
170
|
+
# pixie-inventory -- with no bound image, including a machine
|
|
171
|
+
# that auto-inventoried then flipped back to ipxe-exit);
|
|
172
|
+
# ``machine.binding.changed`` when the mode or image actually
|
|
173
|
+
# shifted from a real prior operator bind.
|
|
156
174
|
was_bound = previous is not None and (
|
|
157
|
-
bool(previous.image_content_sha256) or previous.boot_mode
|
|
175
|
+
bool(previous.image_content_sha256) or previous.boot_mode not in UNCOMMITTED_BOOT_MODES
|
|
158
176
|
)
|
|
159
177
|
changed = previous is not None and (
|
|
160
178
|
previous.boot_mode != row.boot_mode
|