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
pixie/images.py ADDED
@@ -0,0 +1,473 @@
1
+ """Image catalog discovery and inspection.
2
+
3
+ Recognises the supported on-disk image formats (``.qcow2``, ``.img``,
4
+ ``.img.zst``, ``.img.xz``, ``.img.gz``, ``.img.bz2``), lists them
5
+ under a configured image root, and extracts detail metadata for
6
+ individual images via the appropriate tool (``qemu-img info`` for
7
+ qcow2, ``zstd -l`` / ``xz -l`` / ``gzip -l`` for the corresponding
8
+ compressed raws; bzip2 has no listing tool so .img.bz2 has no
9
+ detail block).
10
+
11
+ Format-choice rationale: pixie-shipped images all use **gzip** for
12
+ universal flasher / OS / tooling support. The flash code accepts
13
+ **any** of ``.img``, ``.img.zst``, ``.img.xz``, ``.img.gz``,
14
+ ``.img.bz2`` for operator-supplied images so format choice is
15
+ not forced on operators with their own pipelines.
16
+
17
+ - The **USB stick image** ships as ``.iso.gz``. Operators write
18
+ it host-side via Etcher / Rufus / Raspberry Pi Imager, which
19
+ decompress .gz natively (xz tripped Etcher's bundled
20
+ decompressor regardless of how the file was shaped; gzip has
21
+ no equivalent quirk). Stick prep is a one-shot, host-side cost.
22
+ Universal flasher compat wins for media written once during
23
+ setup; zstd's flash-time-decompression edge is irrelevant for a
24
+ one-shot write -- the per-job reflash hot path applies to
25
+ operator-supplied target images, not to pixie-shipped artifacts.
26
+ - Operators running per-job CI reflash on a fast disk can pick
27
+ ``.img.zst`` for their own images and the flash code will
28
+ stream-decompress at zstd's ~800-1500 MB/s. zstd's only
29
+ downside is the version-cliff in some host-side flasher
30
+ ecosystems, which doesn't apply to pixie's flash code -- it
31
+ shells out to the system ``zstd`` binary, which is universal
32
+ on Linux.
33
+ - Decompression speed ranking (rough): zstd > gzip > xz > bzip2.
34
+ Pick based on workload: gzip for one-shot delivery, zstd for
35
+ hot-path reflash.
36
+ """
37
+
38
+ from __future__ import annotations
39
+
40
+ import json
41
+ import os
42
+ import subprocess
43
+ from dataclasses import dataclass
44
+ from pathlib import Path
45
+ from typing import Any
46
+
47
+ # Default image root. Operators override via the ``PIXIE_IMAGE_ROOT``
48
+ # environment variable. The USB live stick mounts the PIXIE_IMAGES
49
+ # partition here.
50
+ DEFAULT_IMAGE_ROOT = Path("/var/lib/pixie/images")
51
+
52
+ # Supported extensions, ordered most-specific first so multi-suffix
53
+ # variants (``.img.zst``, ``.img.xz``, ``.img.gz``, ``.img.bz2``)
54
+ # win over the bare ``.img``.
55
+ _EXTENSIONS: tuple[tuple[str, str], ...] = (
56
+ (".img.zst", "img.zst"),
57
+ (".img.xz", "img.xz"),
58
+ (".img.gz", "img.gz"),
59
+ (".img.bz2", "img.bz2"),
60
+ (".qcow2", "qcow2"),
61
+ (".img", "img"),
62
+ )
63
+
64
+ # Extensions explicitly NOT supported by the single-stream flash
65
+ # pipeline. Tarballs wrap the actual image inside per-file headers;
66
+ # decompressing the gzip/xz layer doesn't yield raw image bytes,
67
+ # it yields a tar stream. dd'ing that into a target disk would
68
+ # write tar headers into the MBR. Operators with these files must
69
+ # extract first (``tar -xzf foo.tar.gz`` etc.) and drop the
70
+ # resulting .img onto PIXIE_IMAGES.
71
+ _TARBALL_HINT_EXTS: tuple[str, ...] = (
72
+ ".tar.gz",
73
+ ".tar.xz",
74
+ ".tar.bz2",
75
+ ".tar.zst",
76
+ ".tgz",
77
+ ".txz",
78
+ ".tbz2",
79
+ ".tzst",
80
+ )
81
+
82
+
83
+ def is_tarball_extension(name: str) -> bool:
84
+ """Return True if ``name`` looks like a tar archive that pixie
85
+ cannot flash directly (caller should hint the operator to
86
+ extract first)."""
87
+ lower = name.lower()
88
+ return any(lower.endswith(ext) for ext in _TARBALL_HINT_EXTS)
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class Image:
93
+ """A discovered image file. Plain bytes-on-disk metadata only.
94
+
95
+ ``sha256`` is the lower-case hex SHA-256 of the image bytes when
96
+ a cached value is available (sidecar ``.sha256`` file or
97
+ in-memory). ``None`` means "no sidecar present"; callers compute
98
+ on demand if they need it.
99
+
100
+ ``arch`` is an informational architecture hint (``x86_64`` /
101
+ ``arm64`` / etc.) derived from the filename via
102
+ :func:`detect_arch_from_name`. Never restricts flash eligibility
103
+ -- pixie writes whatever bytes the operator points at; arch is a
104
+ display-only column so the operator can see at a glance what
105
+ platform an image targets. ``None`` when the filename carries
106
+ no recognised arch token.
107
+ """
108
+
109
+ name: str
110
+ path: Path
111
+ format: str
112
+ size_bytes: int
113
+ sha256: str | None = None
114
+ arch: str | None = None
115
+
116
+ def to_dict(self) -> dict[str, Any]:
117
+ return {
118
+ "name": self.name,
119
+ "path": str(self.path),
120
+ "format": self.format,
121
+ "size_bytes": self.size_bytes,
122
+ "sha256": self.sha256,
123
+ "arch": self.arch,
124
+ }
125
+
126
+
127
+ def default_image_root() -> Path:
128
+ """Resolve the configured image root.
129
+
130
+ Precedence: ``PIXIE_IMAGE_ROOT`` env var, then ``DEFAULT_IMAGE_ROOT``.
131
+ """
132
+ env = os.environ.get("PIXIE_IMAGE_ROOT")
133
+ return Path(env) if env else DEFAULT_IMAGE_ROOT
134
+
135
+
136
+ # Formats pixie can flash or serve as an NBD-backed root disk. Everything
137
+ # in ``_EXTENSIONS`` is a raw-disk container the flash pipeline knows
138
+ # how to stream through ``dd``. Not a superset: entries the catalog
139
+ # ships as sidecars for another consumer (e.g. ``tar.gz`` netboot
140
+ # bundles, which nbdmux unpacks at warm time to obtain vmlinuz +
141
+ # initrd) are deliberately absent so the operator picker + machine-
142
+ # binding gate exclude them. Kept in one place so a new bindable
143
+ # format is enabled by adding it to ``_EXTENSIONS`` alone.
144
+ BINDABLE_FORMATS: frozenset[str] = frozenset(fmt for _, fmt in _EXTENSIONS)
145
+
146
+
147
+ def detect_format(path: Path) -> str | None:
148
+ """Return the image format identifier for ``path``, or ``None``."""
149
+ name = path.name.lower()
150
+ for ext, fmt in _EXTENSIONS:
151
+ if name.endswith(ext):
152
+ return fmt
153
+ return None
154
+
155
+
156
+ # Filename arch tokens, mapped to a canonical short name. Order
157
+ # matters: scan the LONGEST tokens first so ``x86_64`` wins over a
158
+ # spurious match on ``x86`` in (say) ``x86_64-thing``. The canonical
159
+ # names match what ``uname -m`` reports on Linux for the same
160
+ # platform, so the displayed value lines up with what an operator
161
+ # sees logging into a flashed target.
162
+ _ARCH_TOKENS: tuple[tuple[str, str], ...] = (
163
+ ("x86_64", "x86_64"),
164
+ ("x86-64", "x86_64"),
165
+ ("aarch64", "arm64"),
166
+ ("amd64", "x86_64"),
167
+ ("arm64", "arm64"),
168
+ ("armhf", "arm"),
169
+ ("armv7l", "arm"),
170
+ ("armv6l", "armv6"),
171
+ ("riscv64", "riscv64"),
172
+ ("ppc64le", "ppc64le"),
173
+ ("s390x", "s390x"),
174
+ ("i686", "i386"),
175
+ ("i386", "i386"),
176
+ )
177
+
178
+
179
+ def detect_arch_from_name(name: str) -> str | None:
180
+ """Best-effort architecture hint from an image filename.
181
+
182
+ Returns a canonical short arch name (matching ``uname -m`` on
183
+ Linux: ``x86_64``, ``arm64``, ``i386``, ``arm``, ``riscv64``,
184
+ etc.) or ``None`` when nothing is recognised.
185
+
186
+ Pure substring match, case-insensitive, longest token first.
187
+ Informational only -- callers do not filter or restrict based
188
+ on it (pixie writes whatever bytes the operator points at).
189
+ Common token forms map to one canonical:
190
+
191
+ * ``amd64`` / ``x86_64`` / ``x86-64`` -> ``x86_64``
192
+ * ``arm64`` / ``aarch64`` -> ``arm64``
193
+ * ``armhf`` / ``armv7l`` -> ``arm``
194
+ """
195
+ lower = name.lower()
196
+ for token, canonical in _ARCH_TOKENS:
197
+ if token in lower:
198
+ return canonical
199
+ return None
200
+
201
+
202
+ def list_images(root: Path) -> list[Image]:
203
+ """List supported images directly under ``root`` (non-recursive).
204
+
205
+ Reads any cached SHA from the sidecar ``<file>.sha256`` if
206
+ present (cheap; an operator may have computed it via
207
+ ``sha256sum > <file>.sha256``). Does NOT compute SHA on the fly
208
+ -- multi-GiB hashing on every listing would be punishing; this
209
+ is for the ``pixie`` CLI's USB-stick scan path where O(1) listing
210
+ matters.
211
+ """
212
+ if not root.exists() or not root.is_dir():
213
+ return []
214
+
215
+ out: list[Image] = []
216
+ for p in sorted(root.iterdir()):
217
+ # Symlinks could point outside ``root``; the bytes would
218
+ # then be served via ``GET /images/<sha>`` even though
219
+ # they live outside the operator-configured image root.
220
+ # Reject symlinks defensively -- operators who really
221
+ # want to share files across roots can copy or hardlink.
222
+ if p.is_symlink():
223
+ continue
224
+ if not p.is_file():
225
+ continue
226
+ # Skip sidecar files; they're not images themselves.
227
+ if p.name.endswith(".sha256"):
228
+ continue
229
+ fmt = detect_format(p)
230
+ if fmt is None:
231
+ continue
232
+ # ``stat`` can race with concurrent unlink (operator drops
233
+ # a file out of PIXIE_IMAGES between iterdir and stat).
234
+ # Skip rather than crash the listing.
235
+ try:
236
+ size_bytes = p.stat().st_size
237
+ except FileNotFoundError:
238
+ continue
239
+ out.append(
240
+ Image(
241
+ name=p.name,
242
+ path=p,
243
+ format=fmt,
244
+ size_bytes=size_bytes,
245
+ sha256=_read_sidecar_sha(p),
246
+ arch=detect_arch_from_name(p.name),
247
+ )
248
+ )
249
+ return out
250
+
251
+
252
+ def _sidecar_path(image_path: Path) -> Path:
253
+ """Where the SHA-256 sidecar for ``image_path`` lives.
254
+
255
+ Convention: ``foo.img.zst`` -> ``foo.img.zst.sha256``. Matches
256
+ the sha256sum-style sidecar most release artifacts ship with
257
+ so an operator can verify manually:
258
+
259
+ sha256sum -c foo.img.zst.sha256
260
+ """
261
+ return image_path.with_name(image_path.name + ".sha256")
262
+
263
+
264
+ SHA256_HEX_LEN = 64
265
+ _SHA_HEX = frozenset("0123456789abcdef")
266
+
267
+
268
+ def is_sha256_hex(s: str) -> bool:
269
+ """Return ``True`` iff ``s`` is a lower-case 64-char SHA-256
270
+ hex digest. Single predicate shared by sidecar parsing,
271
+ manifest validation, and the URL-key dispatch in pixie.
272
+ """
273
+ return len(s) == SHA256_HEX_LEN and all(c in _SHA_HEX for c in s)
274
+
275
+
276
+ def _read_sidecar_sha(image_path: Path) -> str | None:
277
+ """Read a sidecar ``<file>.sha256`` if present + parseable.
278
+
279
+ Tolerates two common shapes:
280
+
281
+ * Just the hex digest on one line (``abc123...``).
282
+ * ``sha256sum`` output: ``abc123... filename`` (we take
283
+ the first whitespace-separated token).
284
+
285
+ Returns ``None`` (not an error) if the sidecar is missing,
286
+ unreadable, or the digest doesn't look like a 64-char lower-
287
+ case hex string. Callers treat None as "no sidecar" and decide
288
+ whether to compute on demand.
289
+ """
290
+ sidecar = _sidecar_path(image_path)
291
+ try:
292
+ head = sidecar.read_text(encoding="utf-8").strip().split(maxsplit=1)
293
+ except (FileNotFoundError, IsADirectoryError, PermissionError):
294
+ return None
295
+ if not head:
296
+ return None
297
+ digest = head[0].strip().lower()
298
+ if not is_sha256_hex(digest):
299
+ return None
300
+ return digest
301
+
302
+
303
+ @dataclass(frozen=True)
304
+ class ImageSource:
305
+ """One way to obtain an image's bytes.
306
+
307
+ Post-v0.66.0 sources are always catalog entries fetched via
308
+ withcache; ``kind`` is always ``"manifest"`` and ``location``
309
+ carries the upstream HTTP(S) or ``oras://`` URL. The dual-kind
310
+ "local" variant went with the retired local dir-scan.
311
+ """
312
+
313
+ kind: str # "manifest"
314
+ location: str
315
+
316
+ def to_dict(self) -> dict[str, Any]:
317
+ return {"kind": self.kind, "location": self.location}
318
+
319
+
320
+ @dataclass(frozen=True)
321
+ class UnifiedImage:
322
+ """Image record on the merged listing.
323
+
324
+ Two identity fields, distinct on purpose:
325
+
326
+ ``ref`` is the **provenance ID** -- ``sha256(canonicalise_src(src))``,
327
+ a deterministic 64-hex digest of the canonical form of the source URL.
328
+ Populated for every catalog entry. This is THE value machine
329
+ bindings target -- a rolling oras tag's ref stays stable across
330
+ re-pushes, so binding to a tag survives the next rebuild upstream.
331
+ Always non-empty.
332
+
333
+ ``sha256`` is the **observed content hash**. May be None for a
334
+ rolling manifest entry that has never been pinned. Distinct from
335
+ ``ref`` -- the same content can land under multiple refs (e.g.
336
+ operator catalogs the same image under ``oras://a`` and
337
+ ``http://b``), and the same ref can map to different content
338
+ over time (rolling tag re-push).
339
+
340
+ ``names`` collects every label the image goes by; ``sources``
341
+ every fetch path. pixie's ``_list_unified_images`` builds
342
+ these one-per-``WithcacheCatalog`` entry; the merge that
343
+ produced multi-name entries went with ``merge_with_catalog``.
344
+ """
345
+
346
+ ref: str
347
+ sha256: str | None
348
+ names: tuple[str, ...]
349
+ format: str | None
350
+ size_bytes: int | None
351
+ sources: tuple[ImageSource, ...]
352
+ arch: str | None = None
353
+
354
+ def to_dict(self) -> dict[str, Any]:
355
+ return {
356
+ "ref": self.ref,
357
+ "sha256": self.sha256,
358
+ "names": list(self.names),
359
+ "format": self.format,
360
+ "size_bytes": self.size_bytes,
361
+ "sources": [s.to_dict() for s in self.sources],
362
+ "arch": self.arch,
363
+ }
364
+
365
+
366
+ def _run_detail_tool(cmd: list[str], *, timeout: float = 30.0) -> tuple[str | None, str | None]:
367
+ """Run a metadata-listing tool, returning ``(detail, error)``.
368
+
369
+ Exactly one element is non-None: ``detail`` is the tool's
370
+ stripped stdout on success, otherwise ``error`` carries the
371
+ stderr (or a timeout note). Bounds the call with ``timeout`` so
372
+ a hung tool (corrupt file, slow network mount) can't wedge an
373
+ inspect request -- the same defensive shell-out pattern
374
+ :mod:`pixie.disks` and :mod:`pixie.web._sysconfig` use.
375
+ """
376
+ try:
377
+ proc = subprocess.run(cmd, capture_output=True, text=True, check=False, timeout=timeout)
378
+ except subprocess.TimeoutExpired:
379
+ return None, f"{cmd[0]} timed out after {timeout:g}s"
380
+ if proc.returncode == 0:
381
+ return proc.stdout.strip(), None
382
+ return None, proc.stderr.strip()
383
+
384
+
385
+ def _set_detail(info: dict[str, Any], detail: str | None, error: str | None) -> None:
386
+ """Stash a text ``detail`` block or a ``detail_error`` onto the
387
+ inspect result, depending on which :func:`_run_detail_tool`
388
+ returned."""
389
+ if detail is not None:
390
+ info["detail"] = detail
391
+ else:
392
+ info["detail_error"] = error
393
+
394
+
395
+ def inspect_image(path: Path) -> dict[str, Any]:
396
+ """Return detailed metadata for a single image file.
397
+
398
+ Always includes ``path``, ``format``, and ``size_bytes``. Adds a
399
+ format-specific ``detail`` block when the relevant tool succeeds:
400
+
401
+ - ``qcow2`` -> the JSON output of ``qemu-img info --output=json``
402
+ - ``img.zst`` -> the textual output of ``zstd -l``
403
+ - ``img.xz`` -> the textual output of ``xz -l``
404
+ - ``img.gz`` -> the textual output of ``gzip -l``
405
+ - ``img.bz2`` -> nothing (bzip2 has no listing tool)
406
+
407
+ Raises :class:`FileNotFoundError` if the path does not exist, or
408
+ :class:`IsADirectoryError` if the path is a directory (operator
409
+ almost certainly meant a file inside; surfacing a "format='',
410
+ size_bytes=40" record for a directory was misleading).
411
+ """
412
+ if not path.exists():
413
+ raise FileNotFoundError(path)
414
+ if path.is_dir():
415
+ raise IsADirectoryError(path)
416
+
417
+ fmt = detect_format(path)
418
+ info: dict[str, Any] = {
419
+ "path": str(path),
420
+ "format": fmt,
421
+ "size_bytes": path.stat().st_size,
422
+ }
423
+
424
+ # Tarballs aren't flashable -- the inspection helper points
425
+ # the operator at the right next step instead of returning a
426
+ # blank ``format: ''`` record that looks like a pixie bug.
427
+ if fmt is None and is_tarball_extension(path.name):
428
+ info["detail_error"] = (
429
+ "tarball; not directly flashable -- extract first "
430
+ f"(e.g. ``tar -xf {path.name}``) and drop the resulting "
431
+ "``.img`` / ``.qcow2`` onto PIXIE_IMAGES"
432
+ )
433
+ return info
434
+
435
+ # Any other unrecognised extension: same shape as the tarball
436
+ # branch, just a generic "this isn't a format pixie knows about"
437
+ # message listing what IS supported. Without this, an inspect
438
+ # against e.g. README.md returned a confusing blank record
439
+ # with format=''.
440
+ if fmt is None:
441
+ supported = ", ".join(ext for ext, _ in _EXTENSIONS)
442
+ info["detail_error"] = (
443
+ f"unrecognised format for {path.name!r}; supported extensions: {supported}"
444
+ )
445
+ return info
446
+
447
+ if fmt == "qcow2":
448
+ detail, error = _run_detail_tool(["qemu-img", "info", "--output=json", str(path)])
449
+ if detail is not None:
450
+ # ``qemu-img info`` can exit 0 yet emit non-JSON (truncated
451
+ # output, an image it half-understood). Treat a decode
452
+ # failure as a detail error rather than crashing the
453
+ # inspect request -- mirrors the guarded parse in
454
+ # ``flash._image_virtual_size``.
455
+ try:
456
+ info["detail"] = json.loads(detail)
457
+ except json.JSONDecodeError as exc:
458
+ info["detail_error"] = f"qemu-img info returned unparseable JSON: {exc}"
459
+ else:
460
+ info["detail_error"] = error
461
+ elif fmt == "img.zst":
462
+ detail, error = _run_detail_tool(["zstd", "-l", str(path)])
463
+ _set_detail(info, detail, error)
464
+ elif fmt == "img.xz":
465
+ detail, error = _run_detail_tool(["xz", "-l", str(path)])
466
+ _set_detail(info, detail, error)
467
+ elif fmt == "img.gz":
468
+ detail, error = _run_detail_tool(["gzip", "-l", str(path)])
469
+ _set_detail(info, detail, error)
470
+ # img.bz2: no listing tool ships with bzip2; ``detail`` block
471
+ # is intentionally omitted.
472
+
473
+ return info
@@ -0,0 +1,16 @@
1
+ """Machine registry.
2
+
3
+ A **machine** in pixie is a MAC-keyed record of what pixie should
4
+ answer when a target PXEs. Discovery is implicit: the first time a
5
+ MAC hits ``GET /pxe/<mac>`` the row is created with the default boot
6
+ mode (``ipxe-exit`` -- chain out of iPXE, boot the local disk). An
7
+ operator later binds a machine to a catalog entry via the JSON API or
8
+ the operator UI, flipping ``boot_mode`` to ``nbdboot``.
9
+
10
+ The bound image is referenced by content sha256 (not URL sha; not
11
+ name-string). Content addressing means a machine binding survives a
12
+ catalog rename or re-add as long as the content is the same, and the
13
+ same content shared across multiple entries lives on disk once.
14
+ """
15
+
16
+ from __future__ import annotations
@@ -0,0 +1,190 @@
1
+ """HTTP routes for machine CRUD.
2
+
3
+ Read routes (``GET /machines``, ``GET /machines/{mac}``) are OPEN
4
+ because the machine list is what an on-call operator glances at.
5
+ Write routes require the pixie session cookie.
6
+
7
+ Discovery (creating a row on first PXE contact) lives under the
8
+ ``/pxe/`` router in :mod:`pixie.pxe._routes`; this module owns only
9
+ the operator-visible administration.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from fastapi import APIRouter, Depends, HTTPException, Request
17
+ from fastapi.responses import Response
18
+ from pydantic import BaseModel, Field
19
+
20
+ from pixie.events._kinds import MACHINE_BINDING_CHANGED, MACHINE_BOUND, MACHINE_DELETED
21
+ from pixie.machines._store import (
22
+ BOOT_MODES,
23
+ DEFAULT_BOOT_MODE,
24
+ BadMac,
25
+ MachinesStore,
26
+ normalise_mac,
27
+ parse_labels,
28
+ )
29
+ from pixie.web._auth import require_auth
30
+
31
+ router = APIRouter()
32
+
33
+
34
+ class BindBody(BaseModel):
35
+ """Operator binding: pick a boot mode + optional image ref by
36
+ content sha. Empty ``image_content_sha256`` clears the binding.
37
+
38
+ Extended fields (``labels`` / ``target_disk_serial``) let the
39
+ operator tag the machine + tune the flash chain without a second
40
+ round-trip. Fields default to no-op values so a pre-extension
41
+ client can PUT with only ``boot_mode`` and get the same behaviour
42
+ it did before.
43
+ """
44
+
45
+ boot_mode: str = Field(..., description=f"one of {sorted(BOOT_MODES)}")
46
+ image_content_sha256: str = Field(
47
+ default="",
48
+ description="64-char lowercase hex sha of a fetched catalog entry, or empty to clear",
49
+ )
50
+ labels: list[str] = Field(
51
+ default_factory=list,
52
+ description=(
53
+ "Free-form tags: alphanumeric-leading, a-z / 0-9 / space / ._-, "
54
+ "max 64 chars each, max 16 tags."
55
+ ),
56
+ )
57
+ target_disk_serial: str = Field(
58
+ default="",
59
+ description="Serial of the target disk for pixie-flash-*. Matched against the inventory.",
60
+ )
61
+ extra_cmdline: str = Field(
62
+ default="",
63
+ description=(
64
+ "Kernel-cmdline tokens appended per-machine to the "
65
+ "pixie-live-env + nbdboot chains. Blank means fall back to "
66
+ "the global PIXIE_LIVE_ENV_EXTRA_CMDLINE. Single line."
67
+ ),
68
+ )
69
+
70
+
71
+ def _get_machines(request: Request) -> MachinesStore:
72
+ store: MachinesStore | None = getattr(request.app.state, "machines_store", None)
73
+ if store is None:
74
+ raise HTTPException(status_code=503, detail="machines store not initialised")
75
+ return store
76
+
77
+
78
+ @router.get("/machines")
79
+ def list_machines(request: Request) -> dict[str, list[dict[str, Any]]]:
80
+ return {"machines": [m.to_dict() for m in _get_machines(request).list()]}
81
+
82
+
83
+ @router.get("/machines/{mac}")
84
+ def get_machine(request: Request, mac: str) -> dict[str, Any]:
85
+ try:
86
+ canon = normalise_mac(mac)
87
+ except BadMac as exc:
88
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
89
+ row = _get_machines(request).get(canon)
90
+ if row is None:
91
+ raise HTTPException(status_code=404, detail=f"no machine {canon}")
92
+ return row.to_dict()
93
+
94
+
95
+ @router.put("/machines/{mac}")
96
+ def upsert_machine(
97
+ request: Request,
98
+ mac: str,
99
+ body: BindBody,
100
+ _auth: None = Depends(require_auth),
101
+ ) -> dict[str, Any]:
102
+ try:
103
+ canon = normalise_mac(mac)
104
+ except BadMac as exc:
105
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
106
+ # Snapshot the pre-bind state so we can distinguish a fresh
107
+ # bind (no row existed, or existed but had no binding) from a
108
+ # binding CHANGE on an already-bound row. Two event kinds so an
109
+ # operator can filter for "first bind ever for this MAC" vs
110
+ # "someone swapped the image behind this MAC".
111
+ previous = _get_machines(request).get(canon)
112
+ try:
113
+ # ``labels`` in the JSON body is already a list; run it through
114
+ # ``parse_labels`` (via a comma-join) so the same validator
115
+ # rejects bogus tokens on both the JSON + form paths. Labels
116
+ # ride the bind body for JSON-API convenience but the bind
117
+ # form no longer offers them -- labels are edited on their
118
+ # own row on the machine detail page.
119
+ labels = parse_labels(", ".join(str(x) for x in (body.labels or [])))
120
+ # ``labels`` on ``BindBody`` defaults to an empty list. Passing
121
+ # that empty list to ``upsert_binding`` would clobber any
122
+ # existing labels on a bind that only wanted to touch the
123
+ # boot mode. Preserve the previous labels when the caller did
124
+ # not supply any new ones -- honours a partial-update PUT
125
+ # without needing PATCH.
126
+ labels_arg = labels if labels else (list(previous.labels) if previous else [])
127
+ row = _get_machines(request).upsert_binding(
128
+ canon,
129
+ boot_mode=body.boot_mode,
130
+ image_content_sha256=body.image_content_sha256.strip().lower(),
131
+ labels=labels_arg,
132
+ target_disk_serial=body.target_disk_serial,
133
+ extra_cmdline=body.extra_cmdline,
134
+ )
135
+ except ValueError as exc:
136
+ raise HTTPException(status_code=422, detail=str(exc)) from exc
137
+ log = getattr(request.app.state, "events_log", None)
138
+ if log is not None:
139
+ details: dict[str, Any] = {"boot_mode": row.boot_mode}
140
+ if row.image_content_sha256:
141
+ details["image_content_sha256"] = row.image_content_sha256
142
+ # ``machine.bound`` on a fresh MAC or on a row that was
143
+ # discovered-only (previous.boot_mode default with no bound
144
+ # image); ``machine.binding.changed`` when the mode or image
145
+ # actually shifted between the previous state and the new one.
146
+ was_bound = previous is not None and (
147
+ bool(previous.image_content_sha256) or previous.boot_mode != DEFAULT_BOOT_MODE
148
+ )
149
+ changed = previous is not None and (
150
+ previous.boot_mode != row.boot_mode
151
+ or previous.image_content_sha256 != row.image_content_sha256
152
+ )
153
+ if previous is not None and was_bound and changed:
154
+ details["previous_boot_mode"] = previous.boot_mode
155
+ if previous.image_content_sha256:
156
+ details["previous_image_content_sha256"] = previous.image_content_sha256
157
+ log.emit(
158
+ MACHINE_BINDING_CHANGED,
159
+ subject_kind="machine",
160
+ subject_id=row.mac,
161
+ summary=f"{row.mac}: {previous.boot_mode} -> {row.boot_mode}",
162
+ details=details,
163
+ )
164
+ else:
165
+ log.emit(
166
+ MACHINE_BOUND,
167
+ subject_kind="machine",
168
+ subject_id=row.mac,
169
+ summary=f"{row.mac} -> {row.boot_mode}",
170
+ details=details,
171
+ )
172
+ return row.to_dict()
173
+
174
+
175
+ @router.delete("/machines/{mac}", status_code=204)
176
+ def delete_machine(
177
+ request: Request,
178
+ mac: str,
179
+ _auth: None = Depends(require_auth),
180
+ ) -> Response:
181
+ try:
182
+ canon = normalise_mac(mac)
183
+ except BadMac as exc:
184
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
185
+ if not _get_machines(request).delete(canon):
186
+ raise HTTPException(status_code=404, detail=f"no machine {canon}")
187
+ log = getattr(request.app.state, "events_log", None)
188
+ if log is not None:
189
+ log.emit(MACHINE_DELETED, subject_kind="machine", subject_id=canon, summary=canon)
190
+ return Response(status_code=204)