pixie-lab 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. pixie/__init__.py +20 -0
  2. pixie/_util.py +38 -0
  3. pixie/catalog/__init__.py +25 -0
  4. pixie/catalog/_fetcher.py +513 -0
  5. pixie/catalog/_routes.py +392 -0
  6. pixie/catalog/_schema.py +263 -0
  7. pixie/catalog/_store.py +234 -0
  8. pixie/deploy/__init__.py +26 -0
  9. pixie/deploy/_main.py +321 -0
  10. pixie/deploy/_templates.py +153 -0
  11. pixie/disks.py +85 -0
  12. pixie/events/__init__.py +28 -0
  13. pixie/events/_kinds.py +203 -0
  14. pixie/events/_log.py +210 -0
  15. pixie/events/_routes.py +42 -0
  16. pixie/exports/__init__.py +17 -0
  17. pixie/exports/_routes.py +211 -0
  18. pixie/exports/_store.py +156 -0
  19. pixie/exports/_supervisor.py +240 -0
  20. pixie/flash.py +1971 -0
  21. pixie/images.py +473 -0
  22. pixie/machines/__init__.py +16 -0
  23. pixie/machines/_routes.py +190 -0
  24. pixie/machines/_store.py +623 -0
  25. pixie/oras.py +547 -0
  26. pixie/pivot/__init__.py +180 -0
  27. pixie/pivot/nbdboot +348 -0
  28. pixie/pxe/__init__.py +19 -0
  29. pixie/pxe/_renderer.py +244 -0
  30. pixie/pxe/_routes.py +386 -0
  31. pixie/tftp/__init__.py +21 -0
  32. pixie/tftp/_supervisor.py +129 -0
  33. pixie/tui/__init__.py +185 -0
  34. pixie/tui/_app.py +2219 -0
  35. pixie/tui_catalog.py +657 -0
  36. pixie/web/__init__.py +6 -0
  37. pixie/web/_auth.py +70 -0
  38. pixie/web/_settings_store.py +211 -0
  39. pixie/web/_static/.gitkeep +0 -0
  40. pixie/web/_static/bootstrap-icons.min.css +5 -0
  41. pixie/web/_static/bootstrap.min.css +12 -0
  42. pixie/web/_static/fonts/bootstrap-icons.woff +0 -0
  43. pixie/web/_static/fonts/bootstrap-icons.woff2 +0 -0
  44. pixie/web/_static/htmx.min.js +1 -0
  45. pixie/web/_static/pixie-favicon.png +0 -0
  46. pixie/web/_static/sse.js +290 -0
  47. pixie/web/_table_state.py +261 -0
  48. pixie/web/_templates/_partials/page_description.html +22 -0
  49. pixie/web/_templates/_partials/recent_events.html +47 -0
  50. pixie/web/_templates/_partials/table_helpers.html +189 -0
  51. pixie/web/_templates/catalog.html +364 -0
  52. pixie/web/_templates/catalog_detail.html +386 -0
  53. pixie/web/_templates/dashboard.html +256 -0
  54. pixie/web/_templates/events.html +125 -0
  55. pixie/web/_templates/ipxe/bootstrap.j2 +12 -0
  56. pixie/web/_templates/ipxe/exit.j2 +10 -0
  57. pixie/web/_templates/ipxe/nbdboot.j2 +57 -0
  58. pixie/web/_templates/ipxe/pixie-live-env.j2 +55 -0
  59. pixie/web/_templates/ipxe/unavailable.j2 +14 -0
  60. pixie/web/_templates/layout.html +345 -0
  61. pixie/web/_templates/login.html +40 -0
  62. pixie/web/_templates/machine_detail.html +786 -0
  63. pixie/web/_templates/machines.html +186 -0
  64. pixie/web/_templates/settings.html +265 -0
  65. pixie/web/main.py +1910 -0
  66. pixie_lab-0.1.0.dist-info/METADATA +107 -0
  67. pixie_lab-0.1.0.dist-info/RECORD +70 -0
  68. pixie_lab-0.1.0.dist-info/WHEEL +4 -0
  69. pixie_lab-0.1.0.dist-info/entry_points.txt +3 -0
  70. pixie_lab-0.1.0.dist-info/licenses/LICENSE +674 -0
@@ -0,0 +1,623 @@
1
+ """Machines table + typed row.
2
+
3
+ One row per MAC. The two operator-writable fields are ``boot_mode``
4
+ and ``image_content_sha256``; the rest are discovery + telemetry.
5
+ Discovery is a side-effect of ``GET /pxe/<mac>`` -- the routes
6
+ module upserts a row (or bumps ``last_seen_at`` on an existing one)
7
+ before rendering the plan.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import contextlib
13
+ import re
14
+ import sqlite3
15
+ import threading
16
+ from collections.abc import Generator, Sequence
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ from pixie._util import now_iso
22
+
23
+ _DB_WRITE_LOCK = threading.Lock()
24
+
25
+ # Boot modes pixie renders a plan for. The set is closed on purpose:
26
+ # an unknown mode on a row would silently fall through to the default
27
+ # and confuse an operator staring at ``GET /machines/<mac>``. Mirrors
28
+ # bty's tuple minus ``bty-tui`` (whose live-env driver has not been
29
+ # ported yet). ``pixie-flash-*`` + ``pixie-inventory`` chain into
30
+ # pixie's own live env; the renderer currently emits an
31
+ # ``unavailable`` plan for them so a bound target boots into a
32
+ # readable "live env not yet baked" screen rather than kernel-panicking
33
+ # on a bty-media initrd.
34
+ LIVE_ENV_MODES: frozenset[str] = frozenset(
35
+ {
36
+ "pixie-flash-once",
37
+ "pixie-flash-always",
38
+ "pixie-inventory",
39
+ "pixie-tui",
40
+ }
41
+ )
42
+ """Boot modes that chain the target into pixie's own netboot-pc live
43
+ env. Extracted as its own set so ``pxe/_renderer.py`` and the store
44
+ share one source of truth: if a mode is added on one side only, the
45
+ renderer silently falls through the plan-render match and the bound
46
+ target boots into ``unavailable.j2``. Kept close to :data:`BOOT_MODES`
47
+ so a reader adding a mode sees both spots at once."""
48
+
49
+ BOOT_MODES: frozenset[str] = frozenset({"ipxe-exit", "nbdboot"}) | LIVE_ENV_MODES
50
+ DEFAULT_BOOT_MODE = "ipxe-exit"
51
+
52
+ # Presentation metadata for the six boot modes. Ordered
53
+ # passthrough -> diagnostic -> interactive -> destructive so the
54
+ # radio-card picker on ``machine_detail.html`` reads roughly
55
+ # left-to-right by blast radius (ipxe-exit is a no-op; the flash
56
+ # modes rewrite the disk). Icons pulled from Bootstrap Icons.
57
+ #
58
+ # Kept next to :data:`BOOT_MODES` on purpose: adding a mode without
59
+ # a metadata row here trips the assertion below, so the picker
60
+ # cannot silently drop a mode.
61
+ BOOT_MODE_META: tuple[tuple[str, dict[str, str]], ...] = (
62
+ (
63
+ "ipxe-exit",
64
+ {
65
+ "icon": "box-arrow-right",
66
+ "short": "Exit to firmware",
67
+ "desc": "Drop through pixie's chain; the BIOS boot order picks the next device.",
68
+ },
69
+ ),
70
+ (
71
+ "pixie-inventory",
72
+ {
73
+ "icon": "hdd-stack",
74
+ "short": "Discover inventory",
75
+ "desc": "Live env posts disk + NIC info to pixie, then exits to firmware.",
76
+ },
77
+ ),
78
+ (
79
+ "pixie-tui",
80
+ {
81
+ "icon": "terminal",
82
+ "short": "Interactive TUI",
83
+ "desc": "Operator-driven wizard on the pixie live env.",
84
+ },
85
+ ),
86
+ (
87
+ "pixie-flash-once",
88
+ {
89
+ "icon": "download",
90
+ "short": "Flash once",
91
+ "desc": "Live env writes the image to the picked disk, then exits.",
92
+ },
93
+ ),
94
+ (
95
+ "pixie-flash-always",
96
+ {
97
+ "icon": "arrow-clockwise",
98
+ "short": "Flash every boot",
99
+ "desc": "Re-flash the image on every PXE. Any local changes are discarded.",
100
+ },
101
+ ),
102
+ (
103
+ "nbdboot",
104
+ {
105
+ "icon": "hdd-network",
106
+ "short": "Netboot over NBD",
107
+ "desc": "Stream the image over NBD; root is overlay-on-tmpfs. Nothing persists.",
108
+ },
109
+ ),
110
+ )
111
+
112
+ # Guard against a mode landing in BOOT_MODES without a metadata row
113
+ # above (or vice versa). Fires at module load so a mismatched pair
114
+ # never reaches a running server.
115
+ _BOOT_MODE_META_KEYS = {m for m, _ in BOOT_MODE_META}
116
+ assert _BOOT_MODE_META_KEYS == BOOT_MODES, (
117
+ f"BOOT_MODE_META keys {_BOOT_MODE_META_KEYS} != BOOT_MODES {BOOT_MODES}"
118
+ )
119
+
120
+ # Normalise MAC to lower-case colon form. Rejects anything else.
121
+ _MAC_RE = re.compile(r"^([0-9a-f]{2}:){5}[0-9a-f]{2}$")
122
+
123
+
124
+ class BadMac(ValueError):
125
+ """The provided MAC failed the canonical-form check."""
126
+
127
+
128
+ def normalise_mac(mac: str) -> str:
129
+ """Fold ``mac`` to ``aa:bb:cc:dd:ee:ff`` form + lowercase. Accepts
130
+ the same MAC in ``AA-BB-CC-DD-EE-FF`` or ``aabbccddeeff`` shapes;
131
+ raises :class:`BadMac` on anything unparseable.
132
+ """
133
+ raw = (mac or "").strip().lower().replace("-", ":").replace(".", ":")
134
+ if ":" not in raw and len(raw) == 12:
135
+ raw = ":".join(raw[i : i + 2] for i in range(0, 12, 2))
136
+ if not _MAC_RE.match(raw):
137
+ raise BadMac(f"invalid mac: {mac!r}")
138
+ return raw
139
+
140
+
141
+ _SCHEMA = """
142
+ CREATE TABLE IF NOT EXISTS machines (
143
+ mac TEXT PRIMARY KEY,
144
+ boot_mode TEXT NOT NULL DEFAULT 'ipxe-exit',
145
+ image_content_sha256 TEXT NOT NULL DEFAULT '',
146
+ labels TEXT NOT NULL DEFAULT '',
147
+ target_disk_serial TEXT NOT NULL DEFAULT '',
148
+ extra_cmdline TEXT NOT NULL DEFAULT '',
149
+ inventory_json TEXT NOT NULL DEFAULT '',
150
+ inventory_at TEXT NOT NULL DEFAULT '',
151
+ discovered_at TEXT NOT NULL,
152
+ last_seen_at TEXT NOT NULL,
153
+ last_seen_ip TEXT NOT NULL DEFAULT '',
154
+ updated_at TEXT NOT NULL
155
+ );
156
+
157
+ CREATE INDEX IF NOT EXISTS idx_machines_image_content_sha
158
+ ON machines(image_content_sha256);
159
+ """
160
+
161
+ # Boot modes that write to the target disk. Binding these requires a
162
+ # ``target_disk_serial`` chosen from the machine's inventory, so the
163
+ # live env's flash pipeline has a concrete destination.
164
+ _FLASH_MODES: frozenset[str] = frozenset({"pixie-flash-once", "pixie-flash-always"})
165
+
166
+ # Bty's shape: alphanumeric-leading, alphanumeric + space + . _ - inside,
167
+ # 64 chars max per label, 16 labels max per machine. Matches the CSS-safe
168
+ # subset so a label can render as a ``.badge`` without escaping surprises.
169
+ _LABEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._\-]{0,63}$")
170
+ _LABEL_LIMIT = 16
171
+
172
+
173
+ def _migrate_schema(conn: sqlite3.Connection) -> None:
174
+ """Additive column adds for existing state.db files. Idempotent."""
175
+ cols = {row[1] for row in conn.execute("PRAGMA table_info(machines)").fetchall()}
176
+ if "inventory_json" not in cols:
177
+ conn.execute("ALTER TABLE machines ADD COLUMN inventory_json TEXT NOT NULL DEFAULT ''")
178
+ if "inventory_at" not in cols:
179
+ conn.execute("ALTER TABLE machines ADD COLUMN inventory_at TEXT NOT NULL DEFAULT ''")
180
+ if "labels" not in cols:
181
+ conn.execute("ALTER TABLE machines ADD COLUMN labels TEXT NOT NULL DEFAULT ''")
182
+ if "target_disk_serial" not in cols:
183
+ conn.execute("ALTER TABLE machines ADD COLUMN target_disk_serial TEXT NOT NULL DEFAULT ''")
184
+ if "extra_cmdline" not in cols:
185
+ conn.execute("ALTER TABLE machines ADD COLUMN extra_cmdline TEXT NOT NULL DEFAULT ''")
186
+ # Retired 2026-07 pre-1.0: sanboot_drive was carried on the bind
187
+ # form as an iPXE ``sanboot`` BIOS drive slug, but pixie never
188
+ # actually rendered it into any iPXE template -- the ipxe-exit
189
+ # plan just does ``exit`` and hands off to firmware boot order.
190
+ # Dropped from the schema (no back-compat shim); the column is
191
+ # gone from fresh state.dbs and stays as an unused column on
192
+ # existing ones. SQLite is forgiving of unread columns.
193
+ # Renamed 2026-07: ``boot_mode='ramboot'`` -> ``'nbdboot'``. The
194
+ # earlier name evoked "loads root into RAM" which is not what
195
+ # this mode does -- it's a netboot that mounts the root over
196
+ # NBD. Operator-facing rename only: kernel cmdline still says
197
+ # ``boot=ramboot`` and pixie-media's ``/scripts/ramboot`` still
198
+ # ships under that name so currently-published nosi netboot
199
+ # bundles keep working. Longer-term the mode-specific pivot
200
+ # script should not be baked into nosi's artifact at all --
201
+ # decoupling is a follow-up (see nbdboot.j2 template comment).
202
+ # Migrate silently -- an existing state.db from before the
203
+ # rename still resolves.
204
+ conn.execute("UPDATE machines SET boot_mode = 'nbdboot' WHERE boot_mode = 'ramboot'")
205
+
206
+
207
+ def parse_labels(raw: str) -> list[str]:
208
+ """Split a comma-separated label string into a normalised list.
209
+ Whitespace-only tokens are dropped; duplicates are folded to a
210
+ single occurrence in first-seen order. Raises :class:`ValueError`
211
+ on any token that fails :data:`_LABEL_RE` or when the count
212
+ exceeds :data:`_LABEL_LIMIT`."""
213
+ out: list[str] = []
214
+ seen: set[str] = set()
215
+ for tok in (raw or "").split(","):
216
+ s = tok.strip()
217
+ if not s:
218
+ continue
219
+ if not _LABEL_RE.match(s):
220
+ raise ValueError(
221
+ f"label {s!r} must be alphanumeric-leading + a-z/0-9/space/._- (max 64 chars)"
222
+ )
223
+ if s in seen:
224
+ continue
225
+ seen.add(s)
226
+ out.append(s)
227
+ if len(out) > _LABEL_LIMIT:
228
+ raise ValueError(f"at most {_LABEL_LIMIT} labels per machine (got {len(out)})")
229
+ return out
230
+
231
+
232
+ @dataclass
233
+ class Machine:
234
+ mac: str
235
+ boot_mode: str = DEFAULT_BOOT_MODE
236
+ image_content_sha256: str = ""
237
+ labels: list[str] = field(default_factory=list)
238
+ target_disk_serial: str = ""
239
+ extra_cmdline: str = ""
240
+ """Per-machine kernel-cmdline tokens appended to the pixie-live-env
241
+ + nbdboot chains AFTER the global ``PIXIE_LIVE_ENV_EXTRA_CMDLINE``
242
+ fallback. Blank means "no per-machine tokens, use the global";
243
+ non-blank fully overrides the global for THIS machine (does not
244
+ concatenate). Intended for the odd hardware quirk that needs a
245
+ workaround on ONE target without dragging the deploy-wide default
246
+ with it."""
247
+ inventory: dict[str, Any] = field(default_factory=dict)
248
+ inventory_at: str = ""
249
+ discovered_at: str = field(default_factory=now_iso)
250
+ last_seen_at: str = field(default_factory=now_iso)
251
+ last_seen_ip: str = ""
252
+ updated_at: str = field(default_factory=now_iso)
253
+
254
+ def to_dict(self) -> dict[str, Any]:
255
+ out: dict[str, Any] = {
256
+ "mac": self.mac,
257
+ "boot_mode": self.boot_mode,
258
+ "discovered_at": self.discovered_at,
259
+ "last_seen_at": self.last_seen_at,
260
+ "updated_at": self.updated_at,
261
+ }
262
+ if self.image_content_sha256:
263
+ out["image_content_sha256"] = self.image_content_sha256
264
+ if self.labels:
265
+ out["labels"] = list(self.labels)
266
+ if self.target_disk_serial:
267
+ out["target_disk_serial"] = self.target_disk_serial
268
+ if self.extra_cmdline:
269
+ out["extra_cmdline"] = self.extra_cmdline
270
+ if self.last_seen_ip:
271
+ out["last_seen_ip"] = self.last_seen_ip
272
+ if self.inventory:
273
+ out["inventory_at"] = self.inventory_at
274
+ out["inventory"] = self.inventory
275
+ return out
276
+
277
+
278
+ class MachinesStore:
279
+ def __init__(self, db_path: Path) -> None:
280
+ self.db_path = Path(db_path)
281
+ self._ensure_schema()
282
+
283
+ def _ensure_schema(self) -> None:
284
+ with self._conn() as conn:
285
+ conn.executescript(_SCHEMA)
286
+ _migrate_schema(conn)
287
+
288
+ @contextlib.contextmanager
289
+ def _conn(self) -> Generator[sqlite3.Connection]:
290
+ conn = sqlite3.connect(str(self.db_path))
291
+ conn.row_factory = sqlite3.Row
292
+ try:
293
+ yield conn
294
+ conn.commit()
295
+ finally:
296
+ conn.close()
297
+
298
+ def list(self) -> list[Machine]:
299
+ with self._conn() as conn:
300
+ rows = conn.execute("SELECT * FROM machines ORDER BY mac").fetchall()
301
+ return [_row(r) for r in rows]
302
+
303
+ def get(self, mac: str) -> Machine | None:
304
+ canon = normalise_mac(mac)
305
+ with self._conn() as conn:
306
+ row = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
307
+ return _row(row) if row else None
308
+
309
+ def upsert_binding(
310
+ self,
311
+ mac: str,
312
+ *,
313
+ boot_mode: str,
314
+ image_content_sha256: str = "",
315
+ labels: Sequence[str] | None = None,
316
+ target_disk_serial: str = "",
317
+ extra_cmdline: str = "",
318
+ ) -> Machine:
319
+ """Operator-driven write: set boot mode + optional image ref.
320
+ Creates the row if it doesn't exist; preserves discovery
321
+ telemetry (``discovered_at``, ``last_seen_*``) on update.
322
+
323
+ ``labels`` is a pre-parsed list (caller runs :func:`parse_labels`
324
+ so form + JSON paths share the validator).
325
+ ``target_disk_serial`` is the disk serial the live env's flash
326
+ pipeline matches at flash time -- an operator picks it from
327
+ the machine's reported inventory.
328
+ ``extra_cmdline`` is a space-separated string of tokens appended
329
+ to the kernel cmdline on the live-env + nbdboot chains for
330
+ this ONE machine, overriding the global
331
+ ``PIXIE_LIVE_ENV_EXTRA_CMDLINE`` fallback. Newlines are
332
+ rejected so a stray paste can't smuggle a second cmdline into
333
+ the iPXE render.
334
+ """
335
+ canon = normalise_mac(mac)
336
+ if boot_mode not in BOOT_MODES:
337
+ raise ValueError(f"unknown boot_mode {boot_mode!r}; valid: {sorted(BOOT_MODES)}")
338
+ if image_content_sha256 and not re.match(r"^[0-9a-f]{64}$", image_content_sha256):
339
+ raise ValueError("image_content_sha256 must be 64 lowercase hex chars")
340
+ labels_json = _labels_to_json(list(labels or []))
341
+ target_serial = (target_disk_serial or "").strip()
342
+ extra = (extra_cmdline or "").strip()
343
+ if "\n" in extra or "\r" in extra:
344
+ raise ValueError(
345
+ "extra_cmdline must be a single line (newlines truncate the iPXE render)"
346
+ )
347
+
348
+ now = now_iso()
349
+ with _DB_WRITE_LOCK, self._conn() as conn:
350
+ existing = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
351
+ # Flash modes need to know which disk to overwrite. The live
352
+ # env matches ``target_disk_serial`` against currently-attached
353
+ # disks at flash time; a bind that doesn't name one would
354
+ # either fall through to "flash /dev/sda blindly" (dangerous)
355
+ # or refuse (silent no-op). Reject early so the operator sees
356
+ # a 422 pointing at the missing prerequisite, and require the
357
+ # value be one of the serials on the machine's stored
358
+ # inventory so a hand-typed sha doesn't sneak past. New /
359
+ # never-inventoried MACs are told to run pixie-inventory
360
+ # first.
361
+ if boot_mode in _FLASH_MODES:
362
+ inv_disks = _inventory_disk_serials(existing)
363
+ if not inv_disks:
364
+ raise ValueError(
365
+ f"boot_mode={boot_mode!r} requires a target_disk_serial, "
366
+ "but this machine has no inventory yet. "
367
+ "Bind boot_mode=pixie-inventory + power-cycle first."
368
+ )
369
+ if not target_serial:
370
+ raise ValueError(
371
+ f"boot_mode={boot_mode!r} requires target_disk_serial "
372
+ f"(one of: {sorted(inv_disks)})"
373
+ )
374
+ if target_serial not in inv_disks:
375
+ raise ValueError(
376
+ f"target_disk_serial={target_serial!r} is not in this "
377
+ f"machine's inventory (known: {sorted(inv_disks)})"
378
+ )
379
+ if existing is None:
380
+ conn.execute(
381
+ """
382
+ INSERT INTO machines (
383
+ mac, boot_mode, image_content_sha256,
384
+ labels, target_disk_serial, extra_cmdline,
385
+ discovered_at, last_seen_at, last_seen_ip, updated_at
386
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', ?)
387
+ """,
388
+ (
389
+ canon,
390
+ boot_mode,
391
+ image_content_sha256,
392
+ labels_json,
393
+ target_serial,
394
+ extra,
395
+ now,
396
+ now,
397
+ now,
398
+ ),
399
+ )
400
+ else:
401
+ conn.execute(
402
+ """
403
+ UPDATE machines
404
+ SET boot_mode = ?, image_content_sha256 = ?,
405
+ labels = ?, target_disk_serial = ?,
406
+ extra_cmdline = ?, updated_at = ?
407
+ WHERE mac = ?
408
+ """,
409
+ (
410
+ boot_mode,
411
+ image_content_sha256,
412
+ labels_json,
413
+ target_serial,
414
+ extra,
415
+ now,
416
+ canon,
417
+ ),
418
+ )
419
+ row = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
420
+ return _row(row)
421
+
422
+ def touch_seen(self, mac: str, *, ip: str = "") -> Machine:
423
+ """Discovery-side write: create-or-update ``last_seen_at`` +
424
+ optionally ``last_seen_ip``. Does NOT touch operator fields."""
425
+ canon = normalise_mac(mac)
426
+ now = now_iso()
427
+ with _DB_WRITE_LOCK, self._conn() as conn:
428
+ existing = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
429
+ if existing is None:
430
+ conn.execute(
431
+ """
432
+ INSERT INTO machines (
433
+ mac, boot_mode, image_content_sha256,
434
+ discovered_at, last_seen_at, last_seen_ip, updated_at
435
+ ) VALUES (?, ?, '', ?, ?, ?, ?)
436
+ """,
437
+ (canon, DEFAULT_BOOT_MODE, now, now, ip, now),
438
+ )
439
+ else:
440
+ conn.execute(
441
+ """
442
+ UPDATE machines
443
+ SET last_seen_at = ?, last_seen_ip = ?, updated_at = ?
444
+ WHERE mac = ?
445
+ """,
446
+ (now, ip or existing["last_seen_ip"], now, canon),
447
+ )
448
+ row = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
449
+ return _row(row)
450
+
451
+ def delete(self, mac: str) -> bool:
452
+ canon = normalise_mac(mac)
453
+ with _DB_WRITE_LOCK, self._conn() as conn:
454
+ cur = conn.execute("DELETE FROM machines WHERE mac = ?", (canon,))
455
+ return cur.rowcount > 0
456
+
457
+ def set_labels(self, mac: str, labels: Sequence[str]) -> Machine:
458
+ """Store operator-supplied labels against the row. Creates the
459
+ row on first contact (mirrors ``touch_seen`` + ``set_inventory``)
460
+ so an operator tagging a machine before it has PXE'd works.
461
+
462
+ ``labels`` is a pre-parsed list -- callers run
463
+ :func:`parse_labels` so the form + JSON paths share the
464
+ validator. Passing an empty list CLEARS the labels."""
465
+ canon = normalise_mac(mac)
466
+ now = now_iso()
467
+ labels_json = _labels_to_json(list(labels or []))
468
+ with _DB_WRITE_LOCK, self._conn() as conn:
469
+ existing = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
470
+ if existing is None:
471
+ conn.execute(
472
+ """
473
+ INSERT INTO machines (
474
+ mac, boot_mode, image_content_sha256,
475
+ labels, discovered_at, last_seen_at, last_seen_ip, updated_at
476
+ ) VALUES (?, ?, '', ?, ?, ?, '', ?)
477
+ """,
478
+ (canon, DEFAULT_BOOT_MODE, labels_json, now, now, now),
479
+ )
480
+ else:
481
+ conn.execute(
482
+ "UPDATE machines SET labels = ?, updated_at = ? WHERE mac = ?",
483
+ (labels_json, now, canon),
484
+ )
485
+ row = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
486
+ return _row(row)
487
+
488
+ def set_inventory(self, mac: str, inventory: dict[str, Any]) -> Machine:
489
+ """Store a fresh inventory blob against the machine row. Creates
490
+ the row on first contact (mirrors touch_seen shape) so a bare
491
+ POST from a live env's first boot lands correctly."""
492
+ import json as _json
493
+
494
+ canon = normalise_mac(mac)
495
+ now = now_iso()
496
+ blob = _json.dumps(inventory)
497
+ with _DB_WRITE_LOCK, self._conn() as conn:
498
+ existing = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
499
+ if existing is None:
500
+ conn.execute(
501
+ """
502
+ INSERT INTO machines (
503
+ mac, boot_mode, image_content_sha256,
504
+ inventory_json, inventory_at,
505
+ discovered_at, last_seen_at, last_seen_ip, updated_at
506
+ ) VALUES (?, ?, '', ?, ?, ?, ?, '', ?)
507
+ """,
508
+ (canon, DEFAULT_BOOT_MODE, blob, now, now, now, now),
509
+ )
510
+ else:
511
+ conn.execute(
512
+ """
513
+ UPDATE machines
514
+ SET inventory_json = ?, inventory_at = ?, updated_at = ?
515
+ WHERE mac = ?
516
+ """,
517
+ (blob, now, now, canon),
518
+ )
519
+ row = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
520
+ return _row(row)
521
+
522
+
523
+ def _inventory_disk_serials(row: sqlite3.Row | None) -> set[str]:
524
+ """Pull the set of disk serials off a stored inventory blob. Used
525
+ to check ``target_disk_serial`` against the machine's reported
526
+ hardware at bind time. Returns an empty set when the row is
527
+ missing, the inventory blob is empty / malformed, or none of the
528
+ disks report a serial."""
529
+ import json as _json
530
+
531
+ if row is None:
532
+ return set()
533
+ with contextlib.suppress(IndexError, KeyError):
534
+ raw = row["inventory_json"] or ""
535
+ if not raw:
536
+ return set()
537
+ try:
538
+ parsed = _json.loads(raw)
539
+ except ValueError:
540
+ return set()
541
+ if not isinstance(parsed, dict):
542
+ return set()
543
+ disks = parsed.get("disks") or []
544
+ if not isinstance(disks, list):
545
+ return set()
546
+ out: set[str] = set()
547
+ for d in disks:
548
+ if not isinstance(d, dict):
549
+ continue
550
+ serial = str(d.get("serial") or "").strip()
551
+ if serial:
552
+ out.add(serial)
553
+ return out
554
+ return set()
555
+
556
+
557
+ def _labels_to_json(labels: list[str]) -> str:
558
+ """Serialise a validated label list to a JSON array string. Empty
559
+ list -> ``''`` so the DB stores a single trivially-checkable form."""
560
+ import json as _json
561
+
562
+ return _json.dumps(list(labels)) if labels else ""
563
+
564
+
565
+ def _labels_from_json(raw: str) -> list[str]:
566
+ """Parse a stored labels JSON string. Malformed values fall back to
567
+ an empty list -- the migration path may leave a legacy row with
568
+ unexpected shape and pre-1.0 pixie tolerates it rather than 500'ing
569
+ the machines page."""
570
+ import json as _json
571
+
572
+ if not raw:
573
+ return []
574
+ try:
575
+ parsed = _json.loads(raw)
576
+ except ValueError:
577
+ return []
578
+ if not isinstance(parsed, list):
579
+ return []
580
+ return [str(x) for x in parsed if isinstance(x, str)]
581
+
582
+
583
+ def _row(r: sqlite3.Row) -> Machine:
584
+ import json as _json
585
+
586
+ inv: dict[str, Any] = {}
587
+ inv_at = ""
588
+ labels: list[str] = []
589
+ target_serial = ""
590
+ # New columns post-migration; older schema pre-v0.9 may not
591
+ # have them yet. sqlite3.Row raises IndexError on missing keys.
592
+ with contextlib.suppress(IndexError, KeyError):
593
+ raw = r["inventory_json"] or ""
594
+ if raw:
595
+ try:
596
+ parsed = _json.loads(raw)
597
+ except ValueError:
598
+ parsed = None
599
+ if isinstance(parsed, dict):
600
+ inv = parsed
601
+ with contextlib.suppress(IndexError, KeyError):
602
+ inv_at = r["inventory_at"] or ""
603
+ with contextlib.suppress(IndexError, KeyError):
604
+ labels = _labels_from_json(r["labels"] or "")
605
+ with contextlib.suppress(IndexError, KeyError):
606
+ target_serial = r["target_disk_serial"] or ""
607
+ extra_cmdline = ""
608
+ with contextlib.suppress(IndexError, KeyError):
609
+ extra_cmdline = r["extra_cmdline"] or ""
610
+ return Machine(
611
+ mac=r["mac"],
612
+ boot_mode=r["boot_mode"],
613
+ image_content_sha256=r["image_content_sha256"],
614
+ labels=labels,
615
+ target_disk_serial=target_serial,
616
+ extra_cmdline=extra_cmdline,
617
+ inventory=inv,
618
+ inventory_at=inv_at,
619
+ discovered_at=r["discovered_at"],
620
+ last_seen_at=r["last_seen_at"],
621
+ last_seen_ip=r["last_seen_ip"],
622
+ updated_at=r["updated_at"],
623
+ )