pixie-lab 0.4.0__py3-none-any.whl → 0.4.2__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/_partition.py CHANGED
@@ -25,11 +25,41 @@ from __future__ import annotations
25
25
  import json
26
26
  import shutil
27
27
  import subprocess
28
+ from collections.abc import Callable
28
29
  from dataclasses import dataclass
29
30
  from pathlib import Path
31
+ from typing import Any
30
32
 
31
33
  _SECTOR_BYTES = 512
32
34
 
35
+ # GPT partition type GUIDs (lowercase) that name a Linux root filesystem
36
+ # across architectures. sfdisk reports GPT ``type`` as the type GUID.
37
+ _LINUX_ROOT_GUIDS = frozenset(
38
+ {
39
+ "4f68bce3-e8cd-4db1-96e7-fbcaf984b709", # root (x86-64)
40
+ "44479540-f297-41b2-9af7-d131d5f0458a", # root (x86)
41
+ "b921b045-1df0-41c3-af44-4c6f280d3fae", # root (arm64/aarch64)
42
+ "69dad710-2ce4-4e3c-b16c-21a1d49abed3", # root (arm/32-bit)
43
+ "72ec70a6-cf74-40e6-bd49-4bda08e8f224", # root (riscv64)
44
+ }
45
+ )
46
+ # Generic "Linux filesystem" data GUID (and the MBR type 0x83), used when
47
+ # an image does not tag its root with an arch-specific root GUID.
48
+ _LINUX_FS_TYPES = frozenset({"0fc63daf-8483-4772-8e79-3d69d8477de4", "83"})
49
+ # Partition types that are never the root: firmware / boot-chain / swap.
50
+ # Skipped in the last-resort "largest remaining" tier so a BIOS-boot stub
51
+ # or the ESP is never mistaken for the root.
52
+ _NON_ROOT_TYPES = frozenset(
53
+ {
54
+ "c12a7328-f81f-11d2-ba4b-00a0c93ec93b", # EFI System
55
+ "21686148-6449-6e6f-744e-656564454649", # BIOS boot
56
+ "0657fd6d-a4ab-43c4-84e5-0933c84b4f4f", # Linux swap
57
+ "bc13c2ff-59e6-4262-a352-b275fd6f7172", # XBOOTLDR (/boot)
58
+ "ef", # MBR EFI System
59
+ "82", # MBR Linux swap
60
+ }
61
+ )
62
+
33
63
 
34
64
  class PartitionNotFound(RuntimeError):
35
65
  """Raised when a requested partition number is not present on
@@ -47,15 +77,13 @@ class PartitionInfo:
47
77
  size_bytes: int
48
78
 
49
79
 
50
- def partition_info(blob: Path, partition_number: int = 1) -> PartitionInfo:
51
- """Return the byte range of ``partition_number`` on ``blob``.
52
-
53
- Raises :class:`PartitionNotFound` if the blob is unpartitioned,
54
- the specified partition is absent, or sfdisk fails to parse.
80
+ def _sfdisk_partitions(blob: Path) -> list[dict[str, Any]]:
81
+ """Return sfdisk's partition list for ``blob`` (``node``/``start``/
82
+ ``size``/``type`` dicts). Raises :class:`PartitionNotFound` on a
83
+ missing blob, a missing / non-JSON sfdisk, or an unpartitioned image.
55
84
  """
56
85
  if not blob.is_file():
57
86
  raise PartitionNotFound(f"blob {blob!s} does not exist")
58
-
59
87
  try:
60
88
  result = subprocess.run(
61
89
  ["sfdisk", "--json", str(blob)],
@@ -69,14 +97,70 @@ def partition_info(blob: Path, partition_number: int = 1) -> PartitionInfo:
69
97
  raise PartitionNotFound(
70
98
  f"sfdisk --json exited rc={exc.returncode}: {exc.stderr.strip()}"
71
99
  ) from exc
72
-
73
100
  try:
74
101
  data = json.loads(result.stdout)
75
102
  except json.JSONDecodeError as exc:
76
103
  raise PartitionNotFound(f"sfdisk output was not JSON: {exc}") from exc
77
-
78
104
  table = data.get("partitiontable") or {}
79
- parts = table.get("partitions") or []
105
+ return table.get("partitions") or []
106
+
107
+
108
+ def _partition_number(node: str) -> int | None:
109
+ """Trailing partition index off an sfdisk ``node`` (``/dev/loop0p3``
110
+ -> 3, ``disk.img2`` -> 2). ``None`` when there is no trailing digit."""
111
+ i = len(node)
112
+ while i > 0 and node[i - 1].isdigit():
113
+ i -= 1
114
+ return int(node[i:]) if i < len(node) else None
115
+
116
+
117
+ def root_partition_number(blob: Path) -> int:
118
+ """Pick the partition most likely to hold the Linux root filesystem.
119
+
120
+ nosi disk images do NOT agree on partition order: the ubuntu / debian
121
+ cloud images put root at p1 (ESP + BIOS-boot land at p13-15), while
122
+ arch / fedora put BIOS-boot + ESP first and root at p3. Hardcoding p1
123
+ extracts a 1 MiB BIOS-boot stub on the latter, which then fails to
124
+ mount at boot. Select by GPT type instead: an explicit Linux-root
125
+ type GUID wins; else the largest generic-Linux-filesystem partition;
126
+ else the largest partition that is not firmware / boot / swap.
127
+
128
+ Raises :class:`PartitionNotFound` if the table has no viable candidate.
129
+ """
130
+ parts = _sfdisk_partitions(blob)
131
+ cands = []
132
+ for entry in parts:
133
+ num = _partition_number(str(entry.get("node") or ""))
134
+ size = entry.get("size")
135
+ if num is None or size is None:
136
+ continue
137
+ cands.append((num, str(entry.get("type") or "").strip().lower(), int(size)))
138
+ if not cands:
139
+ raise PartitionNotFound(f"no partitions on {blob!s}")
140
+
141
+ tiers: tuple[Callable[[str], bool], ...] = (
142
+ lambda t: t in _LINUX_ROOT_GUIDS,
143
+ lambda t: t in _LINUX_FS_TYPES,
144
+ lambda t: t not in _NON_ROOT_TYPES,
145
+ )
146
+ for tier in tiers:
147
+ matched = [c for c in cands if tier(c[1])]
148
+ if matched:
149
+ # Largest matching partition wins the tier.
150
+ return max(matched, key=lambda c: c[2])[0]
151
+
152
+ raise PartitionNotFound(
153
+ f"no root-filesystem candidate among {len(cands)} partition(s) on {blob!s}"
154
+ )
155
+
156
+
157
+ def partition_info(blob: Path, partition_number: int = 1) -> PartitionInfo:
158
+ """Return the byte range of ``partition_number`` on ``blob``.
159
+
160
+ Raises :class:`PartitionNotFound` if the blob is unpartitioned,
161
+ the specified partition is absent, or sfdisk fails to parse.
162
+ """
163
+ parts = _sfdisk_partitions(blob)
80
164
 
81
165
  # sfdisk names partitions as ``<blob_path><N>`` for MBR / GPT
82
166
  # alike; match by the trailing number after the shared prefix.
pixie/catalog/_fetcher.py CHANGED
@@ -35,7 +35,11 @@ from pathlib import Path
35
35
  from typing import Any
36
36
 
37
37
  from pixie import oras
38
- from pixie._partition import PartitionNotFound, extract_partition
38
+ from pixie._partition import (
39
+ PartitionNotFound,
40
+ extract_partition,
41
+ root_partition_number,
42
+ )
39
43
  from pixie._util import CHUNK, now_iso
40
44
  from pixie.catalog._schema import CatalogEntry
41
45
  from pixie.catalog._store import CatalogStore
@@ -445,7 +449,12 @@ def _extract_rootfs_if_partitioned(
445
449
  blob_path: Path,
446
450
  progress: Callable[[dict[str, Any]], None] | None,
447
451
  ) -> None:
448
- """Try to extract partition 1 into ``blobs/<sha>/rootfs.raw``.
452
+ """Extract the Linux root partition into ``blobs/<sha>/rootfs.raw``.
453
+
454
+ The root is selected by GPT type, not by a fixed partition number:
455
+ nosi images disagree on order (ubuntu/debian root=p1, arch/fedora
456
+ root=p3 behind BIOS-boot + ESP), so a hardcoded p1 would extract a
457
+ 1 MiB BIOS-boot stub on the latter and fail to mount at boot.
449
458
 
450
459
  Silent on PartitionNotFound: the fetch still succeeds and the
451
460
  ephemeral / persist serve paths fall back to using the blob
@@ -458,7 +467,8 @@ def _extract_rootfs_if_partitioned(
458
467
  if progress is not None:
459
468
  progress({"phase": "extracting-rootfs"})
460
469
  try:
461
- info = extract_partition(blob_path, rootfs, partition_number=1)
470
+ part_num = root_partition_number(blob_path)
471
+ info = extract_partition(blob_path, rootfs, partition_number=part_num)
462
472
  except PartitionNotFound as exc:
463
473
  _log.info(
464
474
  "fetch %r: skipping rootfs extract (%s); ephemeral / persist will serve the whole blob",
@@ -467,8 +477,9 @@ def _extract_rootfs_if_partitioned(
467
477
  )
468
478
  return
469
479
  _log.info(
470
- "fetch %r: extracted partition 1 (%d bytes at offset %d) -> %s",
480
+ "fetch %r: extracted partition %d (%d bytes at offset %d) -> %s",
471
481
  entry.name,
482
+ part_num,
472
483
  info.size_bytes,
473
484
  info.start_bytes,
474
485
  rootfs,
@@ -79,3 +79,27 @@ src = "oras://ghcr.io/safl/nosi/fedora-44-headless-netboot:latest"
79
79
  format = "tar.gz"
80
80
  arch = "x86_64"
81
81
  description = "Netboot bundle for fedora-44-headless: vmlinuz + initrd so pixie can nbdboot it over NBD."
82
+
83
+ # pixie-live-env -- pixie's OWN image (not a nosi subset entry): the
84
+ # nosi arch-headless base with the pixie CLI + pixie-on-tty1 service
85
+ # injected. This is what the live-env boot modes (pixie-flash-once /
86
+ # -always / pixie-inventory / pixie-tui) nbdboot ephemerally. Fetch it
87
+ # from the Catalog tab, then set PIXIE_LIVE_ENV_IMAGE_SHA to the
88
+ # content_sha256 pixie assigns after the fetch (the same sha the build
89
+ # pipeline publishes as the pixie-live-env-x86_64.content-sha256 release
90
+ # sidecar). Its netboot_src is arch-headless's own bundle: the transform
91
+ # only touches userspace, so the kernel/initrd are reused unchanged.
92
+ [[images]]
93
+ name = "pixie-live-env"
94
+ src = "https://github.com/safl/pixie/releases/latest/download/pixie-live-env-x86_64.img.gz"
95
+ format = "img.gz"
96
+ arch = "x86_64"
97
+ description = "pixie live-env (nosi arch-headless + the pixie CLI + pixie-on-tty1 service). nbdbooted ephemerally for the live-env boot modes; set PIXIE_LIVE_ENV_IMAGE_SHA to its content_sha256."
98
+ netboot_src = "oras://ghcr.io/safl/nosi/arch-headless-netboot:latest"
99
+
100
+ [[images]]
101
+ name = "nosi arch-headless netboot bundle"
102
+ src = "oras://ghcr.io/safl/nosi/arch-headless-netboot:latest"
103
+ format = "tar.gz"
104
+ arch = "x86_64"
105
+ description = "Netboot bundle for arch-headless: vmlinuz + initrd so pixie can nbdboot the pixie-live-env image over NBD."
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
- DEFAULT_BOOT_MODE,
23
+ UNCOMMITTED_BOOT_MODES,
24
24
  BadMac,
25
25
  MachinesStore,
26
26
  normalise_mac,
@@ -165,12 +165,14 @@ def upsert_machine(
165
165
  details: dict[str, Any] = {"boot_mode": row.boot_mode}
166
166
  if row.image_content_sha256:
167
167
  details["image_content_sha256"] = row.image_content_sha256
168
- # ``machine.bound`` on a fresh MAC or on a row that was
169
- # discovered-only (previous.boot_mode default with no bound
170
- # image); ``machine.binding.changed`` when the mode or image
171
- # actually shifted between the previous state and the new one.
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.
172
174
  was_bound = previous is not None and (
173
- bool(previous.image_content_sha256) or previous.boot_mode != DEFAULT_BOOT_MODE
175
+ bool(previous.image_content_sha256) or previous.boot_mode not in UNCOMMITTED_BOOT_MODES
174
176
  )
175
177
  changed = previous is not None and (
176
178
  previous.boot_mode != row.boot_mode
pixie/machines/_store.py CHANGED
@@ -46,7 +46,24 @@ target boots into ``unavailable.j2``. Kept close to :data:`BOOT_MODES`
46
46
  so a reader adding a mode sees both spots at once."""
47
47
 
48
48
  BOOT_MODES: frozenset[str] = frozenset({"ipxe-exit", "nbdboot"}) | LIVE_ENV_MODES
49
- DEFAULT_BOOT_MODE = "ipxe-exit"
49
+
50
+ # A freshly-discovered MAC auto-registers with this mode. Default is
51
+ # ``pixie-inventory``: non-destructive (boots the live env, collects
52
+ # lshw + disks, POSTs them back, reboots to firmware) and immediately
53
+ # useful -- it populates the machine's inventory and unblocks the flash
54
+ # modes, which require it. Inventory is one-shot: on the first inventory
55
+ # POST the binding auto-flips to ``ipxe-exit`` (see pxe/_routes.py) so a
56
+ # PXE-first machine doesn't re-inventory + boot-loop. Overridable per
57
+ # deploy via ``PIXIE_DEFAULT_BOOT_MODE`` (resolved in web/main.py).
58
+ # ``ipxe-exit`` degrades safely when no live env is staged: the renderer
59
+ # falls back to an exit plan, so a bare deploy behaves like the old
60
+ # default until the operator fetches the live env.
61
+ DEFAULT_BOOT_MODE = "pixie-inventory"
62
+
63
+ # The non-committal auto states: a machine sitting in one of these with
64
+ # no bound image has not been meaningfully operator-bound yet. Used to
65
+ # tell a first ``machine.bound`` from a later ``machine.binding.changed``.
66
+ UNCOMMITTED_BOOT_MODES: frozenset[str] = frozenset({"ipxe-exit", "pixie-inventory"})
50
67
 
51
68
  # Presentation metadata for the six boot modes. Ordered
52
69
  # passthrough -> diagnostic -> interactive -> destructive so the
@@ -480,10 +497,18 @@ class MachinesStore:
480
497
  row = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
481
498
  return _row(row)
482
499
 
483
- def touch_seen(self, mac: str, *, ip: str = "") -> Machine:
500
+ def touch_seen(
501
+ self, mac: str, *, ip: str = "", default_boot_mode: str = DEFAULT_BOOT_MODE
502
+ ) -> Machine:
484
503
  """Discovery-side write: create-or-update ``last_seen_at`` +
485
- optionally ``last_seen_ip``. Does NOT touch operator fields."""
504
+ optionally ``last_seen_ip``. Does NOT touch operator fields.
505
+
506
+ A brand-new MAC is auto-registered with ``default_boot_mode``
507
+ (the deploy's ``PIXIE_DEFAULT_BOOT_MODE``, resolved by the
508
+ caller; falls back to :data:`DEFAULT_BOOT_MODE`). Unknown values
509
+ are refused here so a bad env can't seed an unrenderable mode."""
486
510
  canon = normalise_mac(mac)
511
+ mode = default_boot_mode if default_boot_mode in BOOT_MODES else DEFAULT_BOOT_MODE
487
512
  now = now_iso()
488
513
  with _DB_WRITE_LOCK, self._conn() as conn:
489
514
  existing = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
@@ -495,7 +520,7 @@ class MachinesStore:
495
520
  discovered_at, last_seen_at, last_seen_ip, updated_at
496
521
  ) VALUES (?, ?, '', ?, ?, ?, ?)
497
522
  """,
498
- (canon, DEFAULT_BOOT_MODE, now, now, ip, now),
523
+ (canon, mode, now, now, ip, now),
499
524
  )
500
525
  else:
501
526
  conn.execute(
pixie/oras_pull.py ADDED
@@ -0,0 +1,85 @@
1
+ """CLI: download a single ``oras://`` artifact blob to a file.
2
+
3
+ Build pipelines -- the ``pixie-live-env`` image bake under ``cijoe/`` --
4
+ need to pull a nosi base disk image the same way the operator UI's
5
+ Fetch does, i.e. through :mod:`pixie.oras`. Those pipelines run under
6
+ cijoe's own interpreter where ``import pixie`` is unavailable, so this
7
+ module exposes the pull as a ``python -m pixie.oras_pull`` entry point.
8
+ A build step then shells out to the repo's uv env::
9
+
10
+ uv run python -m pixie.oras_pull <oras-ref> <out-path>
11
+
12
+ and reuses the exact resolve + streaming download the appliance uses,
13
+ with no ``oras`` CLI dependency.
14
+
15
+ Reuses :func:`pixie.oras.resolve_ref` (anonymous token -> manifest ->
16
+ layer pick) and :func:`pixie.catalog._fetcher._stream_to_tmpfile` (the
17
+ curl Range-resume download) so the downloaded bytes and their digest
18
+ match a live Fetch byte-for-byte. The downloaded blob's sha256 is
19
+ verified against the layer digest the registry resolved to; a mismatch
20
+ is a hard error rather than a silently corrupt base image.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import contextlib
27
+ import sys
28
+ from pathlib import Path
29
+
30
+ from pixie.catalog._fetcher import FetchError, _stream_to_tmpfile
31
+ from pixie.oras import OrasError, resolve_ref
32
+
33
+
34
+ def pull(ref: str, out: Path) -> str:
35
+ """Resolve ``ref`` and download its blob to ``out`` atomically.
36
+
37
+ Returns the ``sha256:<hex>`` digest the registry resolved the
38
+ reference to (for a tag ref this is frozen at resolve time). Raises
39
+ :class:`pixie.oras.OrasError` on resolution failure,
40
+ :class:`pixie.catalog._fetcher.FetchError` on the download, and
41
+ :class:`ValueError` if the downloaded bytes do not hash to the
42
+ resolved digest.
43
+ """
44
+ resolved = resolve_ref(ref)
45
+ out.parent.mkdir(parents=True, exist_ok=True)
46
+ # Stage into a scratch dir on the SAME filesystem as ``out`` so the
47
+ # final rename is atomic.
48
+ tmp_dir = out.parent / ".oras-pull-tmp"
49
+ tmp_path, sha256, _size = _stream_to_tmpfile(resolved.blob_url, resolved.headers, tmp_dir)
50
+
51
+ expected = resolved.digest.split(":", 1)[1] if ":" in resolved.digest else resolved.digest
52
+ if sha256 != expected:
53
+ tmp_path.unlink(missing_ok=True)
54
+ raise ValueError(
55
+ f"oras blob digest mismatch for {ref}: registry resolved "
56
+ f"{resolved.digest} but downloaded bytes hash to sha256:{sha256}"
57
+ )
58
+
59
+ tmp_path.replace(out)
60
+ # Best-effort scratch-dir cleanup; harmless if other files remain.
61
+ with contextlib.suppress(OSError):
62
+ tmp_dir.rmdir()
63
+ return resolved.digest
64
+
65
+
66
+ def main(argv: list[str] | None = None) -> int:
67
+ parser = argparse.ArgumentParser(
68
+ prog="python -m pixie.oras_pull",
69
+ description="Download a single oras:// artifact blob to a file.",
70
+ )
71
+ parser.add_argument("ref", help="oras:// reference (tag or @sha256:<digest>)")
72
+ parser.add_argument("out", type=Path, help="output file path")
73
+ args = parser.parse_args(argv)
74
+
75
+ try:
76
+ digest = pull(args.ref, args.out)
77
+ except (OrasError, FetchError, ValueError, OSError) as exc:
78
+ print(f"pixie.oras_pull: {exc}", file=sys.stderr)
79
+ return 1
80
+ print(f"pulled {args.ref} -> {args.out} ({digest})")
81
+ return 0
82
+
83
+
84
+ if __name__ == "__main__": # pragma: no cover
85
+ raise SystemExit(main())
pixie/pxe/_renderer.py CHANGED
@@ -90,6 +90,15 @@ class RenderContext:
90
90
  # Empty by default; the template shims it in unconditionally so
91
91
  # no-op is a legal value.
92
92
  extra_cmdline: str = ""
93
+ # Disk-image content sha the live-env boot modes boot over
94
+ # ephemeral nbdboot instead of the Debian live-boot squashfs.
95
+ # Operator-set via PIXIE_LIVE_ENV_IMAGE_SHA / the Live env page.
96
+ # Empty (default) keeps the squashfs path so existing deploys are
97
+ # untouched; when set, ``render`` streams this image over NBD for
98
+ # every LIVE_ENV_MODES machine (the machine's own bind still drives
99
+ # what GET /pxe/<mac>/plan returns, so the on-target CLI dispatches
100
+ # inventory / interactive / flash correctly).
101
+ live_env_image_sha: str = ""
93
102
 
94
103
 
95
104
  class PlanRenderer:
@@ -155,23 +164,7 @@ class PlanRenderer:
155
164
  if mode == "nbdboot":
156
165
  return self._render_nbdboot(machine, ctx, effective_extra)
157
166
  if mode in LIVE_ENV_MODES:
158
- if not self._live_env_ready():
159
- # netboot-pc bake artifacts have not been staged on
160
- # this deploy yet; degrade to the readable
161
- # unavailable plan so a bound target lands on a
162
- # legible screen instead of a bty-media initrd.
163
- return self._unavailable(
164
- machine,
165
- f"boot_mode={mode!r} needs pixie live-env media; "
166
- f"stage vmlinuz+initrd+squashfs under $PIXIE_LIVE_ENV_DIR",
167
- )
168
- return self._env.get_template("pixie-live-env.j2").render(
169
- mac=machine.mac,
170
- boot_mode=mode,
171
- host=ctx.host,
172
- port=ctx.port,
173
- extra_cmdline=effective_extra,
174
- )
167
+ return self._render_live_env(machine, ctx, mode, effective_extra)
175
168
  # Unknown mode: refuse loudly rather than falling through.
176
169
  return self._env.get_template("unavailable.j2").render(
177
170
  mac=machine.mac,
@@ -190,46 +183,10 @@ class PlanRenderer:
190
183
  return self._unavailable(
191
184
  machine, "no image bound; set image_content_sha256 to a fetched entry"
192
185
  )
193
-
194
- disk_entry = self._catalog_entry_by_sha(image_sha)
195
- if disk_entry is None:
196
- return self._unavailable(
197
- machine,
198
- f"no catalog entry with content_sha256={image_sha[:12]}; re-fetch it",
199
- )
200
- if not disk_entry.netboot_src:
201
- return self._unavailable(
202
- machine,
203
- f"catalog entry {disk_entry.name!r} has no netboot_src; "
204
- "advertise a sibling bundle before selecting nbdboot",
205
- )
206
- bundle_entry = self._catalog.get_entry_by_src(disk_entry.netboot_src)
207
- if bundle_entry is None:
208
- return self._unavailable(
209
- machine,
210
- f"netboot_src {disk_entry.netboot_src} has no catalog entry",
211
- )
212
- if not bundle_entry.content_sha256:
213
- return self._unavailable(
214
- machine,
215
- f"netboot bundle {bundle_entry.name!r} not fetched yet",
216
- )
217
- artifact_dir = self._catalog.artifact_dir(bundle_entry.content_sha256)
218
- if not (artifact_dir / "manifest.json").is_file():
219
- return self._unavailable(
220
- machine,
221
- f"netboot bundle {bundle_entry.name!r} not unpacked "
222
- f"(manifest.json missing under artifacts/{bundle_entry.content_sha256[:12]})",
223
- )
224
-
225
- # Blob for the disk image must exist too; that's what the NBD
226
- # export streams over the wire.
227
- blob = self._catalog.blob_path(image_sha)
228
- if not blob.is_file():
229
- return self._unavailable(
230
- machine,
231
- f"disk image {disk_entry.name!r} blob missing on disk; re-fetch it",
232
- )
186
+ resolved = self._resolve_bundle_and_blob(image_sha)
187
+ if isinstance(resolved, str):
188
+ return self._unavailable(machine, resolved)
189
+ bundle_entry, blob = resolved
233
190
 
234
191
  # Persistent overlay path: if the machine's bind carries a
235
192
  # non-blank ``overlay_alias``, serve that alias's qcow2 via
@@ -299,7 +256,64 @@ class PlanRenderer:
299
256
  )
300
257
 
301
258
  # Ephemeral path: nbdkit's cow filter on the shared blob.
302
- # Idempotent per name+blob.
259
+ # Idempotent per name+blob. Shared with the configured live-env
260
+ # image path (see :meth:`_render_live_env`).
261
+ return self._render_ephemeral_nbdboot(
262
+ machine, ctx, image_sha, bundle_entry, blob, extra_cmdline
263
+ )
264
+
265
+ def _resolve_bundle_and_blob(self, image_sha: str) -> tuple[CatalogEntry, Path] | str:
266
+ """Walk ``catalog[image_sha] -> netboot_src -> bundle`` and check
267
+ the bundle is unpacked + the disk-image blob is present on disk.
268
+
269
+ Returns ``(bundle_entry, blob_path)`` on success, or an
270
+ operator-readable reason string on the first missing link.
271
+ Shared by the machine-bound ``nbdboot`` mode and the configured
272
+ live-env image path so BOTH degrade to the same
273
+ ``unavailable`` reasons -- the live-env nbdboot is deliberately
274
+ the identical resolution the ``nbdboot`` mode uses, just against
275
+ a deploy-configured sha instead of the machine's bound one."""
276
+ disk_entry = self._catalog_entry_by_sha(image_sha)
277
+ if disk_entry is None:
278
+ return f"no catalog entry with content_sha256={image_sha[:12]}; re-fetch it"
279
+ if not disk_entry.netboot_src:
280
+ return (
281
+ f"catalog entry {disk_entry.name!r} has no netboot_src; "
282
+ "advertise a sibling bundle before selecting nbdboot"
283
+ )
284
+ bundle_entry = self._catalog.get_entry_by_src(disk_entry.netboot_src)
285
+ if bundle_entry is None:
286
+ return f"netboot_src {disk_entry.netboot_src} has no catalog entry"
287
+ if not bundle_entry.content_sha256:
288
+ return f"netboot bundle {bundle_entry.name!r} not fetched yet"
289
+ artifact_dir = self._catalog.artifact_dir(bundle_entry.content_sha256)
290
+ if not (artifact_dir / "manifest.json").is_file():
291
+ return (
292
+ f"netboot bundle {bundle_entry.name!r} not unpacked "
293
+ f"(manifest.json missing under artifacts/{bundle_entry.content_sha256[:12]})"
294
+ )
295
+ # Blob for the disk image must exist too; that's what the NBD
296
+ # export streams over the wire.
297
+ blob = self._catalog.blob_path(image_sha)
298
+ if not blob.is_file():
299
+ return f"disk image {disk_entry.name!r} blob missing on disk; re-fetch it"
300
+ return (bundle_entry, blob)
301
+
302
+ def _render_ephemeral_nbdboot(
303
+ self,
304
+ machine: Machine,
305
+ ctx: RenderContext,
306
+ image_sha: str,
307
+ bundle_entry: CatalogEntry,
308
+ blob: Path,
309
+ extra_cmdline: str,
310
+ ) -> str:
311
+ """Ensure an ephemeral nbdkit export for ``blob`` (idempotent per
312
+ name+blob) and render ``nbdboot.j2`` with ``persist=False`` -- an
313
+ overlay-on-tmpfs root, nothing persists across reboots. The
314
+ cmdline keeps ``pixie.server`` / ``pixie.mac`` (the template
315
+ always emits them) so an nbdboot'd live-env image's on-target
316
+ pixie CLI phones home to ``GET /pxe/<mac>/plan``."""
303
317
  export_name = _export_name_for(image_sha)
304
318
  try:
305
319
  port = self._ensure_export(export_name, image_sha, blob)
@@ -307,7 +321,6 @@ class PlanRenderer:
307
321
  return self._unavailable(
308
322
  machine, f"nbdkit refused to start for export {export_name!r}: {exc}"
309
323
  )
310
-
311
324
  return self._env.get_template("nbdboot.j2").render(
312
325
  mac=machine.mac,
313
326
  host=ctx.host,
@@ -321,6 +334,61 @@ class PlanRenderer:
321
334
  persist=False,
322
335
  )
323
336
 
337
+ # ---------- live-env resolution --------------------------------
338
+
339
+ def _render_live_env(
340
+ self, machine: Machine, ctx: RenderContext, mode: str, extra_cmdline: str
341
+ ) -> str:
342
+ """Render a plan for one of the ``LIVE_ENV_MODES``.
343
+
344
+ Two deliveries, picked by ``ctx.live_env_image_sha``
345
+ (PIXIE_LIVE_ENV_IMAGE_SHA / the Live env page):
346
+
347
+ * Set -> boot that disk image over EPHEMERAL nbdboot, the same
348
+ code path the ``nbdboot`` mode uses. Proven on hardware to
349
+ bring the NIC up via dracut where the squashfs live-boot
350
+ hangs in the initramfs on some boards. If the configured
351
+ image or its netboot bundle isn't fetched, degrade to
352
+ ``unavailable`` (matching how ``nbdboot`` degrades) rather
353
+ than silently falling back -- a configured-but-broken live
354
+ env is an operator error worth surfacing.
355
+ * Unset -> the historical Debian live-boot squashfs
356
+ (``pixie-live-env.j2``), or ``unavailable`` when the
357
+ netboot-pc bake artifacts have not been staged.
358
+
359
+ Either way the machine's OWN ``boot_mode`` still drives what
360
+ ``GET /pxe/<mac>/plan`` returns, so the on-target pixie CLI
361
+ dispatches inventory / interactive / flash exactly as before."""
362
+ live_env_sha = ctx.live_env_image_sha
363
+ if live_env_sha:
364
+ resolved = self._resolve_bundle_and_blob(live_env_sha)
365
+ if isinstance(resolved, str):
366
+ return self._unavailable(
367
+ machine,
368
+ f"live-env image {live_env_sha[:12]} not bootable: {resolved}",
369
+ )
370
+ bundle_entry, blob = resolved
371
+ return self._render_ephemeral_nbdboot(
372
+ machine, ctx, live_env_sha, bundle_entry, blob, extra_cmdline
373
+ )
374
+ if not self._live_env_ready():
375
+ # netboot-pc bake artifacts have not been staged on this
376
+ # deploy yet; degrade to the readable unavailable plan so a
377
+ # bound target lands on a legible screen instead of a
378
+ # bty-media initrd.
379
+ return self._unavailable(
380
+ machine,
381
+ f"boot_mode={mode!r} needs pixie live-env media; "
382
+ f"stage vmlinuz+initrd+squashfs under $PIXIE_LIVE_ENV_DIR",
383
+ )
384
+ return self._env.get_template("pixie-live-env.j2").render(
385
+ mac=machine.mac,
386
+ boot_mode=mode,
387
+ host=ctx.host,
388
+ port=ctx.port,
389
+ extra_cmdline=extra_cmdline,
390
+ )
391
+
324
392
  def _unavailable(self, machine: Machine, reason: str) -> str:
325
393
  _log.info("pxe %s unavailable: %s", machine.mac, reason)
326
394
  slug = reason.split(";", 1)[0].replace(" ", "-").lower()[:60]
pixie/pxe/_routes.py CHANGED
@@ -21,13 +21,14 @@ from fastapi import APIRouter, HTTPException, Request
21
21
  from fastapi.responses import PlainTextResponse
22
22
 
23
23
  from pixie.events._kinds import (
24
+ MACHINE_BINDING_CHANGED,
24
25
  MACHINE_DISCOVERED,
25
26
  MACHINE_INVENTORY_UPDATED,
26
27
  PXE_PLAN_RENDERED,
27
28
  PXE_PLAN_UNAVAILABLE,
28
29
  PXE_STATUS_RECEIVED,
29
30
  )
30
- from pixie.machines._store import BadMac, MachinesStore, normalise_mac
31
+ from pixie.machines._store import DEFAULT_BOOT_MODE, BadMac, MachinesStore, normalise_mac
31
32
  from pixie.pxe._renderer import PlanRenderer, RenderContext
32
33
 
33
34
  _log = logging.getLogger(__name__)
@@ -79,10 +80,23 @@ def _render_context(request: Request) -> RenderContext:
79
80
  # (envvars-style pin) -> empty. Known-good values per hardware are
80
81
  # documented in docs/src/hardware-quirks.md.
81
82
  extra_cmdline = ""
83
+ # Disk-image content sha the live-env boot modes nbdboot ephemerally
84
+ # instead of fetching the Debian live-boot squashfs. Same resolution
85
+ # shape: /ui/live-env override (state.db) -> $PIXIE_LIVE_ENV_IMAGE_SHA
86
+ # -> empty (empty keeps the squashfs path). Proven on hardware; see
87
+ # the renderer's _render_live_env.
88
+ live_env_image_sha = ""
82
89
  settings = getattr(request.app.state, "settings_store", None)
83
90
  if settings is not None:
84
91
  extra_cmdline = settings.resolve_live_env_extra_cmdline()
85
- return RenderContext(host=host, port=port, nbd_host=nbd_host, extra_cmdline=extra_cmdline)
92
+ live_env_image_sha = settings.resolve_live_env_image_sha()
93
+ return RenderContext(
94
+ host=host,
95
+ port=port,
96
+ nbd_host=nbd_host,
97
+ extra_cmdline=extra_cmdline,
98
+ live_env_image_sha=live_env_image_sha,
99
+ )
86
100
 
87
101
 
88
102
  @router.get("/pxe-bootstrap.ipxe", response_class=PlainTextResponse)
@@ -132,6 +146,33 @@ async def pxe_inventory(request: Request, mac: str) -> PlainTextResponse:
132
146
  summary=f"{canon} posted inventory",
133
147
  details=details,
134
148
  )
149
+
150
+ # Inventory is one-shot: once a machine has reported, flip a
151
+ # pixie-inventory binding to ipxe-exit so a PXE-first machine does
152
+ # not re-inventory and boot-loop. Mirrors pixie-flash-once's
153
+ # self-terminating flip (see pxe_status). Preserve the row's other
154
+ # fields; the collected inventory stays. Rebinding to
155
+ # pixie-inventory buys exactly one more pass.
156
+ row = machines.get(canon)
157
+ if row is not None and row.boot_mode == "pixie-inventory":
158
+ with contextlib.suppress(ValueError):
159
+ machines.upsert_binding(
160
+ canon,
161
+ boot_mode="ipxe-exit",
162
+ image_content_sha256=row.image_content_sha256,
163
+ labels=list(row.labels),
164
+ target_disk_serial=row.target_disk_serial,
165
+ extra_cmdline=row.extra_cmdline,
166
+ overlay_alias=row.overlay_alias,
167
+ )
168
+ if log is not None:
169
+ log.emit(
170
+ MACHINE_BINDING_CHANGED,
171
+ subject_kind="machine",
172
+ subject_id=canon,
173
+ summary=f"{canon}: inventory collected, auto-exit (pixie-inventory is one-shot)",
174
+ details={"boot_mode": "ipxe-exit", "reason": "inventory-once"},
175
+ )
135
176
  return PlainTextResponse("", status_code=204)
136
177
 
137
178
 
@@ -168,7 +209,8 @@ def pxe_plan(request: Request, mac: str) -> PlainTextResponse:
168
209
  # event fires exactly once (touch_seen itself can't emit -- stores
169
210
  # hold no EventsLog by design).
170
211
  is_new = machines.get(canon) is None
171
- row = machines.touch_seen(canon, ip=_client_ip(request))
212
+ default_mode = getattr(request.app.state, "default_boot_mode", DEFAULT_BOOT_MODE)
213
+ row = machines.touch_seen(canon, ip=_client_ip(request), default_boot_mode=default_mode)
172
214
 
173
215
  ctx = _render_context(request)
174
216
  body = _get_renderer(request).render(row, ctx)
@@ -18,6 +18,7 @@ from __future__ import annotations
18
18
 
19
19
  import contextlib
20
20
  import os
21
+ import re
21
22
  import sqlite3
22
23
  from collections.abc import Generator
23
24
  from datetime import UTC, datetime
@@ -66,6 +67,13 @@ DEFAULT_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S"
66
67
  KEY_LIVE_ENV_EXTRA_CMDLINE = "live_env.extra_cmdline"
67
68
  ENV_LIVE_ENV_EXTRA_CMDLINE = "PIXIE_LIVE_ENV_EXTRA_CMDLINE"
68
69
 
70
+ # A value that is nothing but an unexpanded shell placeholder --
71
+ # ``${VAR}`` / ``${VAR:-default}`` / ``${VAR:+x}`` -- which is what a
72
+ # compose runner that doesn't expand ``${VAR:-}`` (podman-compose)
73
+ # leaks into the container env. Such a value is treated as "unset" so
74
+ # it never reaches a kernel cmdline.
75
+ _UNEXPANDED_ENV_PLACEHOLDER = re.compile(r"^\$\{[A-Za-z_][A-Za-z0-9_]*(:[-+?=]?[^}]*)?\}$")
76
+
69
77
  # Where the operator "Fetch live-env" action pulls the netboot-pc bake
70
78
  # from: a single tarball ``src`` (``https://`` or ``oras://``, the same
71
79
  # schemes the catalog fetch speaks) that unpacks to vmlinuz + initrd +
@@ -78,6 +86,23 @@ DEFAULT_LIVE_ENV_SRC = (
78
86
  "https://github.com/safl/pixie/releases/latest/download/pixie-live-env-x86_64.tar.gz"
79
87
  )
80
88
 
89
+ # Disk-image content sha the live-env boot modes
90
+ # (pixie-inventory / -tui / -flash-once / -flash-always) boot over
91
+ # EPHEMERAL nbdboot instead of the Debian live-boot squashfs. When set
92
+ # (and the named image + its netboot bundle are fetched) the renderer
93
+ # streams this image over NBD -- proven on hardware to bring the NIC up
94
+ # via dracut where the squashfs live-boot hangs in the initramfs on some
95
+ # boards. Empty (the default) keeps the historical squashfs path so
96
+ # existing deploys are untouched. Resolution: DB override -> env ->
97
+ # empty. A value that is not 64 lowercase hex chars is treated as unset
98
+ # so a fat-fingered setting degrades to the squashfs path rather than
99
+ # sending every live-env boot into the unavailable plan.
100
+ KEY_LIVE_ENV_IMAGE_SHA = "live_env.image_sha"
101
+ ENV_LIVE_ENV_IMAGE_SHA = "PIXIE_LIVE_ENV_IMAGE_SHA"
102
+
103
+ # 64 lowercase hex chars: a catalog entry's ``content_sha256``.
104
+ _SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
105
+
81
106
 
82
107
  class SettingValueError(ValueError):
83
108
  """A stored override failed validation at resolve time. The
@@ -175,12 +200,21 @@ class SettingsStore:
175
200
  silently.
176
201
 
177
202
  Empty when both DB override + env are unset -- the template
178
- expects a string and skips the append when it's empty."""
179
- return (
180
- self.get(KEY_LIVE_ENV_EXTRA_CMDLINE)
181
- or (os.environ.get(ENV_LIVE_ENV_EXTRA_CMDLINE) or "").strip()
182
- or ""
183
- )
203
+ expects a string and skips the append when it's empty.
204
+
205
+ Defends against a compose runner that fails to expand
206
+ ``${VAR:-}``: podman-compose passes the unset default through
207
+ LITERALLY, so the container env holds the string
208
+ ``${PIXIE_LIVE_ENV_EXTRA_CMDLINE:-}``. A value that is nothing
209
+ but an unexpanded ``${...}`` placeholder is treated as empty so
210
+ it can't leak onto every live-env / nbdboot kernel cmdline."""
211
+ override = self.get(KEY_LIVE_ENV_EXTRA_CMDLINE)
212
+ if override:
213
+ return override
214
+ env = (os.environ.get(ENV_LIVE_ENV_EXTRA_CMDLINE) or "").strip()
215
+ if env and not _UNEXPANDED_ENV_PLACEHOLDER.match(env):
216
+ return env
217
+ return ""
184
218
 
185
219
  def resolve_live_env_src(self) -> str:
186
220
  """Effective live-env fetch src: DB override -> env -> default
@@ -193,6 +227,29 @@ class SettingsStore:
193
227
  or DEFAULT_LIVE_ENV_SRC
194
228
  )
195
229
 
230
+ def resolve_live_env_image_sha(self) -> str:
231
+ """Effective live-env disk-image content sha: DB override -> env
232
+ -> empty.
233
+
234
+ When non-empty (and the named image + its netboot bundle are
235
+ fetched) the live-env boot modes nbdboot this image ephemerally
236
+ instead of fetching the Debian live-boot squashfs. Empty is the
237
+ default and keeps the squashfs path, so an existing deploy that
238
+ never sets this is untouched.
239
+
240
+ A value that is not 64 lowercase hex chars is treated as unset:
241
+ the renderer then falls back to the squashfs path rather than
242
+ degrading every live-env boot to the unavailable plan. The
243
+ Settings / Live env form validates the shape at set time, so a
244
+ bad value can only reach here from a stray env-var."""
245
+ override = self.get(KEY_LIVE_ENV_IMAGE_SHA)
246
+ if override and _SHA256_RE.match(override.strip()):
247
+ return override.strip()
248
+ env = (os.environ.get(ENV_LIVE_ENV_IMAGE_SHA) or "").strip()
249
+ if env and _SHA256_RE.match(env):
250
+ return env
251
+ return ""
252
+
196
253
 
197
254
  def parse_iso_utc(raw: str) -> datetime | None:
198
255
  """Parse the ISO-8601 strings pixie writes to state.db. Accepts a
@@ -158,6 +158,42 @@
158
158
  </span>
159
159
  </div>
160
160
  </form>
161
+
162
+ <hr class="my-4">
163
+
164
+ <form method="post" action="/ui/live-env/image/edit" class="row g-3">
165
+ <div class="col-12">
166
+ <label for="live_env_image_sha" class="form-label">
167
+ Boot live env as disk image (nbdboot)
168
+ <span class="badge text-bg-{% if live_env_image_sha.override %}primary{% else %}secondary{% endif %} ms-1">
169
+ {% if live_env_image_sha.override %}override{% elif live_env_image_sha.effective %}env{% else %}default{% endif %}
170
+ </span>
171
+ </label>
172
+ <input type="text" id="live_env_image_sha" name="live_env_image_sha"
173
+ class="form-control font-monospace"
174
+ value="{{ live_env_image_sha.override }}"
175
+ placeholder="(unset - use the live-boot squashfs above)"
176
+ pattern="[0-9a-fA-F]{64}">
177
+ <div class="form-text">
178
+ A fetched disk-image's <code>content_sha256</code> (64 hex). When set - and that
179
+ image plus its netboot bundle are fetched - the <code>pixie-inventory</code> /
180
+ <code>pixie-tui</code> / <code>pixie-flash-*</code> modes boot it over
181
+ <strong>ephemeral nbdboot</strong> instead of fetching the squashfs. dracut brings
182
+ the NIC up where the live-boot squashfs hangs in the initramfs on some boards; the
183
+ on-target pixie CLI still phones home so inventory / flash work unchanged. Blank
184
+ falls back to <code>{{ live_env_image_sha.env }}</code> then the squashfs path.
185
+ Effective:
186
+ {% if live_env_image_sha.effective %}<code>{{ live_env_image_sha.effective }}</code>{% else %}<em class="text-muted">(none - squashfs)</em>{% endif %}.
187
+ {% if live_env_image_sha.updated_at %}<br>Last updated: <code>{{ live_env_image_sha.updated_at | fmt_ts }}</code>.{% endif %}
188
+ </div>
189
+ </div>
190
+ <div class="col-12">
191
+ <button type="submit" class="btn btn-primary"><i class="bi bi-check2 me-1"></i>Save</button>
192
+ <span class="text-muted small ms-2">
193
+ Takes effect on the NEXT <code>GET /pxe/&lt;mac&gt;</code>; a target already mid-boot must be power-cycled.
194
+ </span>
195
+ </div>
196
+ </form>
161
197
  </div>
162
198
  </div>
163
199
 
pixie/web/main.py CHANGED
@@ -66,7 +66,12 @@ from pixie.exports._routes import router as exports_router
66
66
  from pixie.exports._store import ExportsStore, OverlaysStore
67
67
  from pixie.exports._supervisor import DEFAULT_PORT_BASE, NbdServer
68
68
  from pixie.machines._routes import router as machines_router
69
- from pixie.machines._store import IMAGE_BOOT_MODES, MachinesStore
69
+ from pixie.machines._store import (
70
+ BOOT_MODES,
71
+ DEFAULT_BOOT_MODE,
72
+ IMAGE_BOOT_MODES,
73
+ MachinesStore,
74
+ )
70
75
  from pixie.pxe._renderer import PlanRenderer
71
76
  from pixie.pxe._routes import router as pxe_router
72
77
  from pixie.tftp import DEFAULT_TFTP_ROOT, TftpServer
@@ -82,6 +87,7 @@ from pixie.web._settings_store import (
82
87
  KEY_DATETIME_FORMAT,
83
88
  KEY_DISPLAY_TZ,
84
89
  KEY_LIVE_ENV_EXTRA_CMDLINE,
90
+ KEY_LIVE_ENV_IMAGE_SHA,
85
91
  KEY_LIVE_ENV_SRC,
86
92
  SettingsStore,
87
93
  SettingValueError,
@@ -183,6 +189,18 @@ def _resolve_live_env_dir() -> Path | None:
183
189
  return default
184
190
 
185
191
 
192
+ def _resolve_default_boot_mode() -> str:
193
+ """Boot mode a freshly-discovered MAC auto-registers with.
194
+
195
+ ``PIXIE_DEFAULT_BOOT_MODE`` override -> :data:`DEFAULT_BOOT_MODE`
196
+ (``pixie-inventory``). An unknown value falls back to the default
197
+ rather than seeding an unrenderable mode on every new machine."""
198
+ override = (os.environ.get("PIXIE_DEFAULT_BOOT_MODE") or "").strip()
199
+ if override in BOOT_MODES:
200
+ return override
201
+ return DEFAULT_BOOT_MODE
202
+
203
+
186
204
  def _resolve_state_dir() -> Path:
187
205
  """Where pixie writes state.db + blobs/ + artifacts/.
188
206
 
@@ -405,6 +423,18 @@ def _deployment_envvar_docs() -> list[dict[str, str]]:
405
423
  " the Live env page for the live-editable override."
406
424
  ),
407
425
  },
426
+ {
427
+ "name": "PIXIE_DEFAULT_BOOT_MODE",
428
+ "default": "pixie-inventory",
429
+ "purpose": (
430
+ "Boot mode a freshly-discovered MAC auto-registers with."
431
+ " Default 'pixie-inventory' is non-destructive (collects"
432
+ " lshw + disks, posts them, exits) and one-shot: the first"
433
+ " inventory POST flips it to 'ipxe-exit' so a PXE-first"
434
+ " box does not re-inventory. Set 'ipxe-exit' for the old"
435
+ " exit-by-default behaviour."
436
+ ),
437
+ },
408
438
  {
409
439
  "name": "PIXIE_LIVE_ENV_SRC",
410
440
  "default": (
@@ -419,6 +449,20 @@ def _deployment_envvar_docs() -> list[dict[str, str]]:
419
449
  " Live env page."
420
450
  ),
421
451
  },
452
+ {
453
+ "name": "PIXIE_LIVE_ENV_IMAGE_SHA",
454
+ "default": "",
455
+ "purpose": (
456
+ "Disk-image content sha (64 hex) the live-env boot modes"
457
+ " boot over EPHEMERAL nbdboot instead of the Debian"
458
+ " live-boot squashfs. When set (and the image + its"
459
+ " netboot bundle are fetched) the pixie-inventory / -tui /"
460
+ " -flash-* modes stream this image over NBD -- dracut"
461
+ " brings the NIC up where the squashfs live-boot hangs on"
462
+ " some boards. Empty keeps the squashfs path. Live-editable"
463
+ " override on the Live env page."
464
+ ),
465
+ },
422
466
  {
423
467
  "name": "PIXIE_SEED_CATALOG",
424
468
  "default": "1",
@@ -726,6 +770,7 @@ def create_app() -> FastAPI:
726
770
  nbdkit_bin=_resolve_nbdkit_bin(),
727
771
  )
728
772
  app.state.live_env_dir = _resolve_live_env_dir()
773
+ app.state.default_boot_mode = _resolve_default_boot_mode()
729
774
  # Root for per-machine qcow2 overlay files. Sub-directory under
730
775
  # the state dir so a single ``PIXIE_DATA_DIR`` override still
731
776
  # relocates everything (catalog blobs + overlays + state.db)
@@ -2520,6 +2565,13 @@ def create_app() -> FastAPI:
2520
2565
  "env": "PIXIE_LIVE_ENV_EXTRA_CMDLINE",
2521
2566
  "updated_at": store.updated_at(KEY_LIVE_ENV_EXTRA_CMDLINE) or "",
2522
2567
  },
2568
+ "live_env_image_sha": {
2569
+ "override": store.get(KEY_LIVE_ENV_IMAGE_SHA) or "",
2570
+ "effective": store.resolve_live_env_image_sha(),
2571
+ "default": "",
2572
+ "env": "PIXIE_LIVE_ENV_IMAGE_SHA",
2573
+ "updated_at": store.updated_at(KEY_LIVE_ENV_IMAGE_SHA) or "",
2574
+ },
2523
2575
  "flash_error": flash_error,
2524
2576
  }
2525
2577
 
@@ -2580,6 +2632,43 @@ def create_app() -> FastAPI:
2580
2632
  store.clear(KEY_LIVE_ENV_SRC)
2581
2633
  return RedirectResponse(url="/ui/live-env", status_code=status.HTTP_303_SEE_OTHER)
2582
2634
 
2635
+ @app.post("/ui/live-env/image/edit", response_model=None)
2636
+ def ui_live_env_image_edit(
2637
+ request: Request,
2638
+ live_env_image_sha: str = Form(""),
2639
+ _auth: None = Depends(_require_ui_auth),
2640
+ ) -> HTMLResponse | RedirectResponse:
2641
+ """Persist the live-env disk-image content sha. When set (and the
2642
+ image + its netboot bundle are fetched) the live-env boot modes
2643
+ nbdboot this image ephemerally instead of fetching the squashfs.
2644
+ Blank clears the override so the value falls back to
2645
+ $PIXIE_LIVE_ENV_IMAGE_SHA then empty (the squashfs path). Rejects
2646
+ anything that is not 64 lowercase hex chars so a fat-fingered sha
2647
+ doesn't quietly send every live-env boot into the unavailable
2648
+ plan."""
2649
+ import re as _re
2650
+
2651
+ store: SettingsStore = request.app.state.settings_store
2652
+ raw = (live_env_image_sha or "").strip().lower()
2653
+ if raw and not _re.match(r"^[0-9a-f]{64}$", raw):
2654
+ return templates.TemplateResponse(
2655
+ request,
2656
+ "live_env.html",
2657
+ _live_env_context(
2658
+ request,
2659
+ flash_error=(
2660
+ "Live-env image sha must be 64 hex chars "
2661
+ "(a fetched disk-image's content_sha256)."
2662
+ ),
2663
+ ),
2664
+ status_code=400,
2665
+ )
2666
+ if raw:
2667
+ store.set_value(KEY_LIVE_ENV_IMAGE_SHA, raw)
2668
+ else:
2669
+ store.clear(KEY_LIVE_ENV_IMAGE_SHA)
2670
+ return RedirectResponse(url="/ui/live-env", status_code=status.HTTP_303_SEE_OTHER)
2671
+
2583
2672
  @app.post("/ui/live-env/fetch")
2584
2673
  def ui_live_env_fetch(
2585
2674
  request: Request,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pixie-lab
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Bare-metal netboot appliance: catalog + fetch + NBD + PXE + TFTP + operator TUI in one container
5
5
  Project-URL: Homepage, https://github.com/safl/pixie
6
6
  Project-URL: Repository, https://github.com/safl/pixie
@@ -1,18 +1,19 @@
1
1
  pixie/__init__.py,sha256=XhfE52sqfEOUpzoYuuO8GpRNTCjigL28LBiNKVrHf2A,615
2
- pixie/_partition.py,sha256=1VS6YutVde5IhgSdzSxmFm67GupfmZyB7gPb8uAofBc,5853
2
+ pixie/_partition.py,sha256=xLQdXGACAAEmt4e1w1SuyhAy-5jZ3hm8oEpCBXw_0Ag,9461
3
3
  pixie/_util.py,sha256=dmaialB1eGUJd85oynoJTEAIVb1o2X8XdV_cNbrf6eU,554
4
4
  pixie/disks.py,sha256=RTATvHaBEpDJzC5y1YiLe1P1GzkHfTDRRMUZDbCdPio,3388
5
5
  pixie/flash.py,sha256=X1gnwq2mhzo8CmrZQgcA5RowFjHKLbRPkHUc8976bVc,78373
6
6
  pixie/images.py,sha256=zteGZkPeMdwCiA5oUXYl2mhqjAITi30HuWRvkY1z6BQ,17450
7
7
  pixie/oras.py,sha256=zKKqYcSghgguOIIrGcJ6C3hZusmORZmDfv7zZDDFslM,22509
8
+ pixie/oras_pull.py,sha256=Xyqga4o8vtCZrUSv5YgLggqmXs36y6FXd-_E9a_iRVo,3326
8
9
  pixie/tui_catalog.py,sha256=nKiVd-SC_qfS3m6phQXRnqQdKN_nTqZbQFl2cIscIoQ,26305
9
10
  pixie/catalog/__init__.py,sha256=DWBJOL0goBieMLWFO2RXTc_w6e0OQ3f6qQVn-oiTq3c,2309
10
- pixie/catalog/_fetcher.py,sha256=URy7doSFFzWgDQOgw4uV3fr0u5_hjHuhNH76l5-2UFU,26295
11
+ pixie/catalog/_fetcher.py,sha256=xiMLccBoJsjNumy1DT38aJiqM4-wYSzHGtklRKnKWlE,26698
11
12
  pixie/catalog/_live_env.py,sha256=DWbPecbipSZ4KQAx60yi4q_gFncOZt4f1yj-QZ7ZBV0,5068
12
13
  pixie/catalog/_routes.py,sha256=8s_8bEDYL5zwg6gNAtxR7NOGPLY7ho9spqjPU83RIPE,14729
13
14
  pixie/catalog/_schema.py,sha256=KC6RcKlToX1DgcRjn25djKMx78-eS-4WzNRY267h0I0,9769
14
15
  pixie/catalog/_store.py,sha256=S7OM7A05Y5ES4RuhmwqQupDGNWXF--p_UMfUNOgQHsQ,9044
15
- pixie/catalog/catalog.toml,sha256=UQZPLk0f_rw6XVZDJQ7zq3yMRhfKEFxNVbDP_BA6_vs,3552
16
+ pixie/catalog/catalog.toml,sha256=nL97qrh0IkfTFmaSMMzWPBi465V9_R6aFcSjzKHKi14,4883
16
17
  pixie/deploy/__init__.py,sha256=3PvSvrIRdqxLCz0nTdVSBiPzEtDl359XSUBgyMBkMT8,1019
17
18
  pixie/deploy/_main.py,sha256=Or2espz9oWNmnGfyEzIsEyvRwWEHL4uxyE6U9OdMZJs,15403
18
19
  pixie/deploy/_templates.py,sha256=WkkKRDU00FDWVZM0XeKR-BCtSBPvaZ2QHM1TBeq0RYg,5418
@@ -25,13 +26,13 @@ pixie/exports/_routes.py,sha256=y6exOqYA4KtN3mHbYJWqHTOaQHk_OaaUWosotAX6NgM,7569
25
26
  pixie/exports/_store.py,sha256=MCRwzsbCD2VdFGLHGyshCqnmD0Axm9GyQIZBOgK2FxE,13829
26
27
  pixie/exports/_supervisor.py,sha256=jOR8S1f8btmvngEQNVGZQ2-pcxmn_xU1rlviCjMUrW4,14623
27
28
  pixie/machines/__init__.py,sha256=iSyEkYm6_oP5TNRVks-HrudMjX2Wr7v6t3RSOyl3eps,725
28
- pixie/machines/_routes.py,sha256=gVtDYF3R9RxeX4EtvtKW5O1VURRE2vuy2Odbfbf4uKs,8601
29
- pixie/machines/_store.py,sha256=e490q7pLJFHf7sru10njcUcyBQJEfBJdwBGL8iJ3nzk,28894
29
+ pixie/machines/_routes.py,sha256=I5o_pLYIUya-N7Z23YRZZUEY9-_PMJJbHtZFTWm8SDM,8731
30
+ pixie/machines/_store.py,sha256=zh89zj2s5CurMFO258i10ANW20VCTQk0kT8MgRhnWi0,30379
30
31
  pixie/pivot/__init__.py,sha256=k_yIUOrIu-DKCJ_8XmRtnoVV7Rr9URagHU2Y76HB2ew,6195
31
32
  pixie/pivot/nbdboot,sha256=stBWGXnSXMFJMMzmtip77dPwWxQLfOewXo9YOgw5u0I,19558
32
33
  pixie/pxe/__init__.py,sha256=AKBRKQYdlH7DdNBvES9EwYTzPOkeNbWBN1S7IH1UEyw,784
33
- pixie/pxe/_renderer.py,sha256=FE9Ih_ZUoDLwXxLuUVX2h1LWK2TZnfW2oOBIfdn04Nc,15016
34
- pixie/pxe/_routes.py,sha256=a903n3t43LGNqLYJ4f704o4C73cDZuMVluC3eWPAibM,17239
34
+ pixie/pxe/_renderer.py,sha256=Tg2oMZQ_vSkEmsc2Rn_SXZ6Ed14LXUmZTOgzkLQC56g,18833
35
+ pixie/pxe/_routes.py,sha256=VhWH5VXaaAC39Bt3asZPpp9ckTCOJdJx3DOLFVTn-2M,19169
35
36
  pixie/tftp/__init__.py,sha256=FuHn1KF5_NoGOM_dHXSbubX5lJr0tuMugJGFxxwuUws,774
36
37
  pixie/tftp/_supervisor.py,sha256=khpjgWqdMzdnhRaPYM4j25PoKijbBnvr-GjJLybP_rY,4659
37
38
  pixie/tui/__init__.py,sha256=EgULtZPOjzs66FosDeSGLFqacsSkal_xBmpBy0fIeZ8,8563
@@ -44,9 +45,9 @@ pixie/web/_images.py,sha256=RhtTmzRhapr6zRbQN4SKaW_o3dhUhf6hyZGIlbxMh_k,6993
44
45
  pixie/web/_inventory.py,sha256=rJDNAfejmtlNPCPUr2mhLey19C5Tu_dmpia9KVeyT7E,10189
45
46
  pixie/web/_overlay_bind.py,sha256=7aKYqMaujxt-XRa1jB6mVEkbt-w_koXjLvUzBTzy2o8,4125
46
47
  pixie/web/_overlays.py,sha256=5yZp6pDiFj_WKdLx6vCoEKD8mXG3KjkMOQt4OGYYdyc,8571
47
- pixie/web/_settings_store.py,sha256=qV_BB7PvCM61jbOR-RV9sxR30XJ28xOKPkc1TDMKYXo,9429
48
+ pixie/web/_settings_store.py,sha256=TrhjRN2ME1K3VsgJPJif7ZA3Tf7pM9k9oL57Hhh_YH0,12409
48
49
  pixie/web/_table_state.py,sha256=zGH5oZ-XlMqUFQqmYglYqT_fu1Yrtaw-g5yhPtAKAEc,9524
49
- pixie/web/main.py,sha256=Be-abz0S7ORp-CEyIWf2FZcElmUHRJUYaA5n0U0Xjb4,113150
50
+ pixie/web/main.py,sha256=1eu183QjBjcN5yKuFn0TGD5rNaDuF6U4TPED29EsSJ0,117004
50
51
  pixie/web/_static/.gitkeep,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
51
52
  pixie/web/_static/bootstrap-icons.min.css,sha256=pdY4ejLKO67E0CM2tbPtq1DJ3VGDVVdqAR6j3ZwdiE4,87008
52
53
  pixie/web/_static/bootstrap.min.css,sha256=NXgoHLHuUWz0lbqh8PRwCIl7_QhZE74v-FCwrYdCW1E,234717
@@ -62,7 +63,7 @@ pixie/web/_templates/events.html,sha256=ifawe--m5FMcicKZ2daCCycV7rGgpLJz7A309UrT
62
63
  pixie/web/_templates/image_detail.html,sha256=--DjHCDgvripwr-9_lmnmaLCe4Iw2W_gXIGuKLMvcJw,6926
63
64
  pixie/web/_templates/images.html,sha256=PO8r0hZ6tXMqHZo_m1t_wgh4NEZP6u_72owH_Jbz2Zo,7266
64
65
  pixie/web/_templates/layout.html,sha256=9kQ1sWyAqMl9YgwJO1jIjwRbiG8bt2sUpJLNrbBZmzo,13988
65
- pixie/web/_templates/live_env.html,sha256=Z9EuvhUpwljXm8Y_wC2B0y4STvQUZYoBMG8aQvj2PsQ,9122
66
+ pixie/web/_templates/live_env.html,sha256=0goOWgrCXvONHEyZSGfqG6h_B0rPaEHvw7Cbt-UpL4I,11557
66
67
  pixie/web/_templates/login.html,sha256=d60N8kmr7YaAoTKRBaOiGN5Srdy8-BgB8_Zz9TQvGwc,1810
67
68
  pixie/web/_templates/machine_detail.html,sha256=ijI5FKuyqOdDwCdLGM_H7yHgqn6fFMm6pOPVBLT9G00,49797
68
69
  pixie/web/_templates/machines.html,sha256=modlu9MNqpxOkDd90o-yV1g5yuEiC61I6JtXQk7_PUo,9803
@@ -76,8 +77,8 @@ pixie/web/_templates/ipxe/exit.j2,sha256=pbTXTwjgiEsPZlRuv7_J0Q3BepXEqXnb82mv1sx
76
77
  pixie/web/_templates/ipxe/nbdboot.j2,sha256=s1CwIKou5W6LRoC2MdZYwA3RcRN8AU3B1gbJmGLS9MM,3950
77
78
  pixie/web/_templates/ipxe/pixie-live-env.j2,sha256=dPWuYCFrkXwwatPSnTRSfWuMUMgvs3VLDHqbJTG1pP0,3048
78
79
  pixie/web/_templates/ipxe/unavailable.j2,sha256=zt4DYfYRXmOIjL13diYPxEApTP3CRnn5Beszeh3BPBw,440
79
- pixie_lab-0.4.0.dist-info/METADATA,sha256=lAZwK9_NRBjEnkA2LQ_6skrbDSho5lrgpBWKFd-CwfI,4452
80
- pixie_lab-0.4.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
81
- pixie_lab-0.4.0.dist-info/entry_points.txt,sha256=G3vtKpg_gQtisiyS31EjdvC4QC52Kfw2K7iMsfsnj5A,71
82
- pixie_lab-0.4.0.dist-info/licenses/LICENSE,sha256=jOtLnuWt7d5Hsx6XXB2QxzrSe2sWWh3NgMfFRetluQM,35147
83
- pixie_lab-0.4.0.dist-info/RECORD,,
80
+ pixie_lab-0.4.2.dist-info/METADATA,sha256=KVZwvaxEXtrV_qtKhwUyIqJtMBUxyeYx6qyWC4zz_nc,4452
81
+ pixie_lab-0.4.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
82
+ pixie_lab-0.4.2.dist-info/entry_points.txt,sha256=G3vtKpg_gQtisiyS31EjdvC4QC52Kfw2K7iMsfsnj5A,71
83
+ pixie_lab-0.4.2.dist-info/licenses/LICENSE,sha256=jOtLnuWt7d5Hsx6XXB2QxzrSe2sWWh3NgMfFRetluQM,35147
84
+ pixie_lab-0.4.2.dist-info/RECORD,,