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 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
- shutil.rmtree(dest)
317
- print(f"pixie-lab purge: removed {dest}", file=sys.stderr)
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
- # always ``"entry"``; ``subject_id`` is the catalog entry name.
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 (per-machine qcow2) -----------------
100
+ # ---------- persistent overlays (alias-keyed volumes) ---------------
99
101
  #
100
- # ``subject_kind`` is always ``"machine"``; ``subject_id`` is the
101
- # canonical MAC. ``details`` names the (image_sha, profile) triple.
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
- """A per-machine qcow2 overlay was materialised for the first time
105
- for a ``(mac, image_sha, profile)`` triple. ``details`` carries
106
- ``image_sha``, ``profile``, and ``qcow2_path``."""
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 on the machine detail page: the qemu-nbd was
110
- terminated, the qcow2 was unlinked, the overlays row was deleted.
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
- Emitted from ``machines_store.touch_seen`` when a new row is
126
- created; subsequent hits do NOT emit (they update ``last_seen_at``
127
- via the ``pxe.plan.rendered`` event instead)."""
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
- -- One row per (mac, image_sha, profile) triple; the qcow2 file at
38
- -- ``qcow2_path`` has ``backing_file`` pointing at the catalog blob
39
- -- for ``image_sha`` and holds every write the machine has made under
40
- -- that profile. Separate from the ``exports`` table above because the
41
- -- ephemeral (nbdkit) and persistent (qemu-nbd) paths have different
42
- -- identity (per-content vs per-triple) and lifecycle (respawn on
43
- -- restart vs create-on-first-bind).
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
- mac TEXT NOT NULL,
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
- """One persistent per-machine qcow2 overlay for ``boot_mode=nbdboot``.
234
+ """A globally-named writable volume over one base image.
186
235
 
187
- Identity is the ``(mac, image_sha, profile)`` triple. The qcow2 at
188
- ``qcow2_path`` has ``backing_file`` pointing at the catalog blob
189
- for ``image_sha``; a qemu-nbd subprocess serves it while the row's
190
- ``status == 'running'``. Deleting the row is the operator's
191
- "Reset overlay" action -- the caller unlinks the qcow2 too."""
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
- mac: str
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
- "mac": self.mac,
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 on the shared state.db.
273
+ """Repository over the alias-keyed ``overlays`` table.
223
274
 
224
- Same DB path as :class:`ExportsStore` + :class:`CatalogStore`.
225
- Kept as a sibling class rather than a method-set on ExportsStore
226
- so the ephemeral vs persistent lifecycles read as distinct
227
- concerns at the call site."""
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, mac: str, image_sha: str, profile: str) -> Overlay | None:
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 list_for_machine_and_image(self, mac: str, image_sha: str) -> list[Overlay]:
262
- """Every profile this ``(mac, image_sha)`` combo has ever created.
263
- Drives the bind-form overlay picker."""
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 mac = ? AND image_sha = ? ORDER BY profile",
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
- mac, image_sha, profile, qcow2_path,
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.mac,
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, mac: str, image_sha: str, profile: str) -> bool:
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
- UPDATE overlays
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, mac: str, image_sha: str, profile: str) -> None:
323
- """Record the timestamp of the most recent plan render for this
324
- overlay. Used by the UI's "last used" column + future auto-GC."""
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
- UPDATE overlays
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
- mac=row["mac"],
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
- overlay_profile: str = Field(
69
+ overlay_alias: str = Field(
70
70
  default="",
71
71
  description=(
72
- "Persistent-overlay profile name for nbdboot. Blank means "
73
- "ephemeral tmpfs (writes vanish on reboot); non-blank names a "
74
- "qcow2 overlay that persists on pixie's data volume. "
75
- "Alphanumeric-leading; a-z / A-Z / 0-9 / . _ - (max 64 chars)."
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=body.image_content_sha256.strip().lower(),
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
- overlay_profile=body.overlay_profile,
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 profile names land on disk as
173
- # ``data/overlays/<mac_slug>/<image_sha>/<profile>.qcow2``. Restrict to
174
- # a filesystem-safe subset so a hand-typed name can't escape the
175
- # storage tree via ``..`` or a directory separator. Same alnum-leading
176
- # shape as labels for consistency; no space to keep the on-disk
177
- # filename shell-friendly.
178
- _OVERLAY_PROFILE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,63}$")
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
- """Per-machine persistent-overlay profile name for ``nbdboot``.
259
- Blank means "ephemeral tmpfs overlay" (the historical behaviour;
260
- writes vanish on reboot). Non-blank names a qcow2 file kept under
261
- ``data/overlays/<mac_slug>/<image_sha>/<profile>.qcow2``; writes
262
- persist across reboots because the qcow2 is stored on pixie's
263
- data volume. Multiple profiles can coexist per (machine, image)
264
- -- the operator flips between them by editing this field. Only
265
- consulted when ``boot_mode == 'nbdboot'``; other modes ignore
266
- it. See :mod:`pixie.exports._store` for the overlay row + the
267
- qcow2 lifecycle."""
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.overlay_profile:
292
- out["overlay_profile"] = self.overlay_profile
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
- overlay_profile: str = "",
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
- ``overlay_profile`` names a persistent qcow2 overlay for
359
- ``nbdboot`` (blank = ephemeral tmpfs). The renderer + exports
360
- supervisor materialise the qcow2 lazily on first plan render.
361
- Must match a filesystem-safe subset so the on-disk filename
362
- stays legible.
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 = (overlay_profile or "").strip()
377
- if overlay and not _OVERLAY_PROFILE_RE.match(overlay):
398
+ overlay = (overlay_alias or "").strip()
399
+ if overlay and not OVERLAY_ALIAS_RE.match(overlay):
378
400
  raise ValueError(
379
- "overlay_profile must be alphanumeric-leading; a-z / A-Z / 0-9 / . _ - "
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
- overlay_profile,
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 = ?, overlay_profile = ?,
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"],