pixie-lab 0.2.0__py3-none-any.whl → 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pixie/catalog/__init__.py CHANGED
@@ -22,4 +22,28 @@ readiness; no ready/pending vocabulary, no auto-fetch, no misses page.
22
22
 
23
23
  from __future__ import annotations
24
24
 
25
- DEFAULT_CATALOG_URL = "https://github.com/safl/nosi/releases/latest/download/catalog.toml"
25
+ from importlib.resources import files
26
+
27
+ # Default "Import catalog" + live-env TUI source. Points at PIXIE's own
28
+ # release copy of the curated catalog (a subset of nosi's, restricted to
29
+ # the netboot-capable images pixie tests + supports), NOT the upstream
30
+ # nosi catalog. Operators can still import the full nosi catalog by URL;
31
+ # this is just the convenience default.
32
+ DEFAULT_CATALOG_URL = "https://github.com/safl/pixie/releases/latest/download/catalog.toml"
33
+
34
+ # The same curated catalog shipped INSIDE the package, used to seed a
35
+ # fresh (empty) catalog on first start so a plain deploy comes up with
36
+ # the known-good set offline -- no network, no release dependency.
37
+ BUNDLED_CATALOG_RESOURCE = "catalog.toml"
38
+
39
+ # Startup seeding is gated on this env (default on). Tests + operators
40
+ # who want an empty catalog set it to ``0``. A one-shot settings marker
41
+ # (below) records that the seed already ran so a later restart never
42
+ # re-seeds after an operator curated the catalog down.
43
+ SEED_CATALOG_ENV = "PIXIE_SEED_CATALOG"
44
+ CATALOG_SEEDED_KEY = "catalog.seeded"
45
+
46
+
47
+ def bundled_catalog_bytes() -> bytes:
48
+ """The curated ``catalog.toml`` shipped inside the package."""
49
+ return (files("pixie.catalog") / BUNDLED_CATALOG_RESOURCE).read_bytes()
@@ -0,0 +1,128 @@
1
+ """Fetch + stage the pixie live-env (the netboot-pc bake).
2
+
3
+ The live env -- ``vmlinuz`` + ``initrd`` + ``live.squashfs`` that the
4
+ ``pixie-flash-*`` / ``pixie-inventory`` / ``pixie-tui`` boot modes chain
5
+ into -- was the one artifact pixie could not retrieve itself: it had to
6
+ be baked locally (``make build VARIANT=netboot-pc``) or hand-copied into
7
+ ``PIXIE_LIVE_ENV_DIR``. This module pulls it from a single tarball
8
+ ``src`` (the same ``https://`` / ``oras://`` schemes the catalog fetch
9
+ speaks) and stages the three files atomically, reusing the catalog
10
+ fetch pipeline's curl download + resume plumbing.
11
+
12
+ Contract with CI's ``publish-release`` job: the tarball carries exactly
13
+ ``vmlinuz`` + ``initrd`` + ``live.squashfs`` at the archive root. Any
14
+ other member is ignored; a missing one is a hard error (better a loud
15
+ "tarball is wrong" than a live env that half-stages and boots into a
16
+ mysterious squashfs-fetch hang).
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import os
22
+ import shutil
23
+ import tarfile
24
+ import tempfile
25
+ from dataclasses import dataclass, field
26
+ from pathlib import Path
27
+
28
+ from pixie._util import CHUNK
29
+ from pixie.catalog._fetcher import (
30
+ FetchError,
31
+ ProgressReporter,
32
+ _resolve_fetch_url,
33
+ _stream_to_tmpfile,
34
+ )
35
+
36
+ # The three files a target's iPXE chain pulls from
37
+ # ``/boot/pixie-live-env/``. Names match what ``pixie-live-env.j2``
38
+ # references (note ``live.squashfs``, not the bake's raw ``*.squashfs``
39
+ # -- the CI tar step renames it).
40
+ LIVE_ENV_FILES: frozenset[str] = frozenset({"vmlinuz", "initrd", "live.squashfs"})
41
+
42
+
43
+ @dataclass
44
+ class LiveEnvResult:
45
+ """Outcome of a successful stage: the src it came from, the sha256
46
+ of the downloaded tarball, its byte size, and the staged file
47
+ sizes."""
48
+
49
+ src: str
50
+ sha256: str
51
+ size: int
52
+ files: dict[str, int] = field(default_factory=dict)
53
+
54
+
55
+ def _unpack_live_env_tar(tar_path: Path, live_env_dir: Path) -> dict[str, int]:
56
+ """Extract the live-env trio from ``tar_path`` into ``live_env_dir``
57
+ atomically. Returns ``{name: size_bytes}``.
58
+
59
+ Hardening mirrors :func:`pixie.catalog._fetcher._unpack_netboot_bundle`:
60
+ only basenames in :data:`LIVE_ENV_FILES` at the archive root are
61
+ accepted; nested paths, parent traversal, and dotfiles are dropped.
62
+ Files land in a staging subdir first, then each is ``os.replace``'d
63
+ onto its final name so a target fetching mid-stage sees the old
64
+ file or the new one, never a half-written squashfs.
65
+ """
66
+ live_env_dir.mkdir(parents=True, exist_ok=True)
67
+ staging = Path(tempfile.mkdtemp(prefix=".staging-", dir=str(live_env_dir)))
68
+ try:
69
+ # ``r:*`` transparently handles .tar / .tar.gz / .tar.xz so the
70
+ # CI side can pick whatever compression without a pixie change.
71
+ with tarfile.open(str(tar_path), mode="r:*") as tar:
72
+ for member in tar.getmembers():
73
+ if not member.isfile():
74
+ continue
75
+ name = os.path.basename(member.name)
76
+ if not name or name.startswith(".") or name != member.name:
77
+ continue
78
+ if name not in LIVE_ENV_FILES:
79
+ continue
80
+ extracted = tar.extractfile(member)
81
+ if extracted is None:
82
+ continue
83
+ with (staging / name).open("wb") as out:
84
+ shutil.copyfileobj(extracted, out, length=CHUNK)
85
+
86
+ missing = LIVE_ENV_FILES - {p.name for p in staging.iterdir()}
87
+ if missing:
88
+ raise FetchError(
89
+ f"live-env tarball {tar_path.name} missing required file(s): {sorted(missing)}"
90
+ )
91
+
92
+ sizes: dict[str, int] = {}
93
+ for name in sorted(LIVE_ENV_FILES):
94
+ final = live_env_dir / name
95
+ os.replace(staging / name, final)
96
+ sizes[name] = final.stat().st_size
97
+ return sizes
98
+ finally:
99
+ shutil.rmtree(staging, ignore_errors=True)
100
+
101
+
102
+ def stage_live_env(
103
+ src: str,
104
+ live_env_dir: Path,
105
+ *,
106
+ progress: ProgressReporter = None,
107
+ ) -> LiveEnvResult:
108
+ """Download the live-env tarball at ``src`` and stage its three
109
+ files into ``live_env_dir``.
110
+
111
+ Reuses the catalog fetch's curl transport (resume + retry + stall
112
+ detection). The tarball lands in ``live_env_dir`` itself so the
113
+ unpack + ``os.replace`` stay on one filesystem. Raises
114
+ :class:`FetchError` on an empty/bad src, an unsupported scheme, a
115
+ download failure, or a tarball missing a required file.
116
+ """
117
+ if not (src or "").strip():
118
+ raise FetchError("no live-env src configured (set PIXIE_LIVE_ENV_SRC)")
119
+ url, headers = _resolve_fetch_url(src.strip())
120
+ live_env_dir.mkdir(parents=True, exist_ok=True)
121
+ tmp_tar, sha256, size = _stream_to_tmpfile(url, headers, live_env_dir, progress)
122
+ try:
123
+ if progress is not None:
124
+ progress({"phase": "unpacking"})
125
+ sizes = _unpack_live_env_tar(tmp_tar, live_env_dir)
126
+ finally:
127
+ tmp_tar.unlink(missing_ok=True)
128
+ return LiveEnvResult(src=src.strip(), sha256=sha256, size=size, files=sizes)
@@ -0,0 +1,81 @@
1
+ version = 1
2
+
3
+ # pixie curated catalog -- a strict SUBSET of the upstream nosi catalog
4
+ # (https://github.com/safl/nosi/releases/latest/download/catalog.toml),
5
+ # restricted to the images that ship a netboot bundle. Those are the
6
+ # only images pixie's nbdboot chain (and the flash / inventory / tui
7
+ # live-env chains that ride the same bundle) actually test + support,
8
+ # so shipping just this subset keeps a fresh pixie's catalog to the
9
+ # known-good set instead of the full nosi menu (desktop / proxmox /
10
+ # rpios / freebsd variants have no netboot bundle and are omitted).
11
+ #
12
+ # Pixie seeds a fresh (empty) catalog from this file on first start and
13
+ # defaults its "Import catalog" + live-env TUI to the pixie release copy
14
+ # of it, rather than the nosi catalog. Override either freely; this is a
15
+ # convenience default, not a lock-in.
16
+ #
17
+ # Refs use the rolling ``:latest`` oras tags so the shipped catalog
18
+ # stays current without re-releasing pixie; the layer digest is still
19
+ # verified at fetch time. Each disk image cross-references its bundle by
20
+ # URL via ``netboot_src`` (pixie's tight form), so the nbdboot renderer
21
+ # resolves the pair without the legacy name-string ``netboot_ref``.
22
+
23
+ [[images]]
24
+ name = "nosi debian-13-headless"
25
+ src = "oras://ghcr.io/safl/nosi/debian-13-headless:latest"
26
+ format = "img.gz"
27
+ arch = "x86_64"
28
+ description = "Debian 13 (trixie) headless. General-purpose headless base with the nosi dev toolset."
29
+ netboot_src = "oras://ghcr.io/safl/nosi/debian-13-headless-netboot:latest"
30
+
31
+ [[images]]
32
+ name = "nosi debian-13-headless netboot bundle"
33
+ src = "oras://ghcr.io/safl/nosi/debian-13-headless-netboot:latest"
34
+ format = "tar.gz"
35
+ arch = "x86_64"
36
+ description = "Netboot bundle for debian-13-headless: vmlinuz + initrd so pixie can nbdboot it over NBD."
37
+
38
+ [[images]]
39
+ name = "nosi ubuntu-2404-headless"
40
+ src = "oras://ghcr.io/safl/nosi/ubuntu-2404-headless:latest"
41
+ format = "img.gz"
42
+ arch = "x86_64"
43
+ description = "Ubuntu 24.04 LTS (noble) headless. Broad driver support for CUDA / ROCm devices and Mellanox NICs / DPUs."
44
+ netboot_src = "oras://ghcr.io/safl/nosi/ubuntu-2404-headless-netboot:latest"
45
+
46
+ [[images]]
47
+ name = "nosi ubuntu-2404-headless netboot bundle"
48
+ src = "oras://ghcr.io/safl/nosi/ubuntu-2404-headless-netboot:latest"
49
+ format = "tar.gz"
50
+ arch = "x86_64"
51
+ description = "Netboot bundle for ubuntu-2404-headless: vmlinuz + initrd so pixie can nbdboot it over NBD."
52
+
53
+ [[images]]
54
+ name = "nosi ubuntu-2604-headless"
55
+ src = "oras://ghcr.io/safl/nosi/ubuntu-2604-headless:latest"
56
+ format = "img.gz"
57
+ arch = "x86_64"
58
+ description = "Ubuntu 26.04 LTS (resolute) headless. A more recent kernel and user-land on Ubuntu LTS."
59
+ netboot_src = "oras://ghcr.io/safl/nosi/ubuntu-2604-headless-netboot:latest"
60
+
61
+ [[images]]
62
+ name = "nosi ubuntu-2604-headless netboot bundle"
63
+ src = "oras://ghcr.io/safl/nosi/ubuntu-2604-headless-netboot:latest"
64
+ format = "tar.gz"
65
+ arch = "x86_64"
66
+ description = "Netboot bundle for ubuntu-2604-headless: vmlinuz + initrd so pixie can nbdboot it over NBD."
67
+
68
+ [[images]]
69
+ name = "nosi fedora-44-headless"
70
+ src = "oras://ghcr.io/safl/nosi/fedora-44-headless:latest"
71
+ format = "img.gz"
72
+ arch = "x86_64"
73
+ description = "Fedora 44 headless. An RHEL-family alternative (dnf, SELinux, EL package universe)."
74
+ netboot_src = "oras://ghcr.io/safl/nosi/fedora-44-headless-netboot:latest"
75
+
76
+ [[images]]
77
+ name = "nosi fedora-44-headless netboot bundle"
78
+ src = "oras://ghcr.io/safl/nosi/fedora-44-headless-netboot:latest"
79
+ format = "tar.gz"
80
+ arch = "x86_64"
81
+ description = "Netboot bundle for fedora-44-headless: vmlinuz + initrd so pixie can nbdboot it over NBD."
pixie/events/_kinds.py CHANGED
@@ -193,6 +193,24 @@ EVENTS_CLEARED = "events.cleared"
193
193
  right after the wipe so the freshly-empty log still records who reset
194
194
  it and when; carries the deleted-row count in ``details``."""
195
195
 
196
+ # ---------- live-env fetch ------------------------------------------
197
+ #
198
+ # The operator "Fetch live-env" action pulls the netboot-pc bake into
199
+ # PIXIE_LIVE_ENV_DIR. No subject (``subject_kind`` = ""): the live env
200
+ # is per-pixie, not per-resource. Dotted ``.done`` / ``.failed`` names
201
+ # mirror the catalog-fetch kinds so ``.failed`` auto-classifies as an
202
+ # error via the ERROR_KINDS ``LIKE '%.failed'`` probe.
203
+
204
+ LIVE_ENV_FETCH_DONE = "live_env.fetch.done"
205
+ """``POST /ui/live-env/fetch`` downloaded + staged vmlinuz + initrd +
206
+ live.squashfs into PIXIE_LIVE_ENV_DIR. Carries the src + content sha in
207
+ ``details``."""
208
+
209
+ LIVE_ENV_FETCH_FAILED = "live_env.fetch.failed"
210
+ """The live-env fetch failed (bad src scheme, download error, or a
211
+ tarball missing a required file). Carries the src + error in
212
+ ``details``."""
213
+
196
214
 
197
215
  # The canonical closed set. Every kind above is registered here; the
198
216
  # ``EventsLog.emit`` call rejects anything not in this frozenset.
@@ -228,6 +246,8 @@ KNOWN_EVENT_KINDS: frozenset[str] = frozenset(
228
246
  AUTH_LOGIN_SUCCEEDED,
229
247
  AUTH_LOGIN_FAILED,
230
248
  EVENTS_CLEARED,
249
+ LIVE_ENV_FETCH_DONE,
250
+ LIVE_ENV_FETCH_FAILED,
231
251
  }
232
252
  )
233
253
 
pixie/tui/_app.py CHANGED
@@ -328,9 +328,11 @@ def collect_lshw(*, timeout: float = 30.0) -> object | None:
328
328
  # v0.46 stopped publishing a pixie-side catalog.toml mirror and pointed
329
329
  # pixie at the upstream image-builder (``safl/nosi``); the wizard's
330
330
  # ``[d] default`` shortcut tracks the same source so both consumers
331
- # resolve to one catalog. The pixie release no longer ships a
332
- # catalog.toml asset, so the old URL would 404.
333
- _BTY_DEFAULT_CATALOG_URL = "https://github.com/safl/nosi/releases/latest/download/catalog.toml"
331
+ # resolve to one catalog. Points at pixie's curated catalog (the
332
+ # netboot-capable nosi subset) shipped as a pixie release asset, so the
333
+ # live-env wizard defaults to the same known-good set the appliance
334
+ # seeds -- not the full upstream nosi catalog.
335
+ _BTY_DEFAULT_CATALOG_URL = "https://github.com/safl/pixie/releases/latest/download/catalog.toml"
334
336
 
335
337
 
336
338
  # ---------------------------------------------------------------------------
pixie/web/_overlays.py ADDED
@@ -0,0 +1,225 @@
1
+ """View-model + stats for the persistent-overlay management surface.
2
+
3
+ The ``overlays`` table (see :class:`pixie.exports._store.OverlaysStore`)
4
+ holds one row per ``(mac, image_sha, profile)`` triple, each pointing at
5
+ a qcow2 on pixie's data volume. The per-machine bind form only ever
6
+ shows ONE machine's profiles, so an operator watching disk pressure
7
+ across the fleet has no aggregate view. This module joins each overlay
8
+ row against the machine registry (is this the target's *current*
9
+ binding?), the catalog (what base image does it wrap?), the NBD
10
+ supervisor (is a qemu-nbd serving it right now?), and the qcow2 on disk
11
+ (how much has it actually grown to?) so the Overlays page can render one
12
+ honest row per overlay.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from datetime import UTC, datetime
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from pixie.catalog._store import CatalogStore
23
+ from pixie.exports._store import Overlay, OverlaysStore
24
+ from pixie.exports._supervisor import NbdServer
25
+ from pixie.machines._store import MachinesStore
26
+ from pixie.pxe._renderer import _overlay_export_name
27
+
28
+ # State classification. ``active`` = backs the machine's next boot;
29
+ # ``idle`` = kept but not the current binding; ``missing`` = a row whose
30
+ # qcow2 is gone from disk; ``orphaned`` = a qcow2 for a machine pixie no
31
+ # longer tracks. Only ``missing`` + ``orphaned`` are auto-reclaimable --
32
+ # an ``idle`` overlay is a deliberate keep for a future rebind, so Prune
33
+ # leaves it alone.
34
+ STATE_ACTIVE = "active"
35
+ STATE_IDLE = "idle"
36
+ STATE_MISSING = "missing"
37
+ STATE_ORPHANED = "orphaned"
38
+ RECLAIMABLE_STATES = frozenset({STATE_MISSING, STATE_ORPHANED})
39
+
40
+ # Sort buckets: live first, deliberate keeps next, junk last. Biggest
41
+ # consumer first within a bucket so the operator's eye lands on what is
42
+ # both live and heavy before what is reclaimable.
43
+ _STATE_ORDER = {STATE_ACTIVE: 0, STATE_IDLE: 1, STATE_ORPHANED: 2, STATE_MISSING: 3}
44
+
45
+
46
+ def overlay_key(mac: str, image_sha: str, profile: str) -> str:
47
+ """Stable per-overlay DOM/JSON key. ``|`` cannot appear in a MAC, a
48
+ hex sha, or the profile allowlist, so it round-trips cleanly and the
49
+ live-refresh JS can address each row without ambiguity."""
50
+ return f"{mac}|{image_sha}|{profile}"
51
+
52
+
53
+ @dataclass
54
+ class OverlayView:
55
+ """One overlay row joined against machine + catalog + NBD + disk."""
56
+
57
+ mac: str
58
+ image_sha: str
59
+ profile: str
60
+ qcow2_path: str
61
+ export_name: str
62
+ created_at: str
63
+ last_boot_at: str
64
+ file_exists: bool
65
+ used_bytes: int
66
+ apparent_bytes: int
67
+ mtime: str
68
+ running: bool
69
+ nbd_port: int
70
+ machine_exists: bool
71
+ machine_labels: list[str] = field(default_factory=list)
72
+ is_active: bool = False
73
+ image_name: str = ""
74
+ base_bytes: int = 0
75
+ state: str = STATE_IDLE
76
+
77
+ @property
78
+ def key(self) -> str:
79
+ return overlay_key(self.mac, self.image_sha, self.profile)
80
+
81
+ @property
82
+ def reclaimable(self) -> bool:
83
+ return self.state in RECLAIMABLE_STATES
84
+
85
+ def to_json(self) -> dict[str, Any]:
86
+ """The subset the live-refresh poll needs to update in place."""
87
+ return {
88
+ "key": self.key,
89
+ "used_bytes": self.used_bytes,
90
+ "apparent_bytes": self.apparent_bytes,
91
+ "mtime": self.mtime,
92
+ "running": self.running,
93
+ "nbd_port": self.nbd_port,
94
+ "state": self.state,
95
+ "file_exists": self.file_exists,
96
+ }
97
+
98
+
99
+ def _stat_file(path: Path) -> tuple[bool, int, int, str]:
100
+ """``(exists, allocated_bytes, apparent_bytes, mtime_iso)``.
101
+
102
+ A growing qcow2's *allocated* size (``st_blocks * 512``) is the
103
+ honest "space consumed" figure -- ``st_size`` is the apparent length,
104
+ which over-counts a sparse/COW file. Missing or unreadable returns
105
+ all-zero + an empty mtime so the row still renders (as ``missing``).
106
+ ``mtime`` is emitted in the same ``...Z`` shape as ``now_iso`` so the
107
+ ``fmt_ts`` filter / ``format_ts`` render it in the operator's tz.
108
+ """
109
+ try:
110
+ st = path.stat()
111
+ except OSError:
112
+ return (False, 0, 0, "")
113
+ allocated = int(getattr(st, "st_blocks", 0)) * 512
114
+ mtime = datetime.fromtimestamp(st.st_mtime, UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
115
+ return (True, allocated, int(st.st_size), mtime)
116
+
117
+
118
+ def build_overlay_view(
119
+ ov: Overlay,
120
+ *,
121
+ machines: MachinesStore,
122
+ image_names: dict[str, str],
123
+ image_sizes: dict[str, int],
124
+ nbd: NbdServer,
125
+ ) -> OverlayView:
126
+ """Enrich one :class:`Overlay` row into an :class:`OverlayView`."""
127
+ export_name = _overlay_export_name(ov)
128
+ exists, used, apparent, mtime = _stat_file(Path(ov.qcow2_path))
129
+ port = nbd.port_for(export_name)
130
+ machine = machines.get(ov.mac)
131
+ is_active = bool(
132
+ machine is not None
133
+ and machine.boot_mode == "nbdboot"
134
+ and machine.image_content_sha256 == ov.image_sha
135
+ and machine.overlay_profile == ov.profile
136
+ )
137
+ if not exists:
138
+ state = STATE_MISSING
139
+ elif machine is None:
140
+ state = STATE_ORPHANED
141
+ elif is_active:
142
+ state = STATE_ACTIVE
143
+ else:
144
+ state = STATE_IDLE
145
+ return OverlayView(
146
+ mac=ov.mac,
147
+ image_sha=ov.image_sha,
148
+ profile=ov.profile,
149
+ qcow2_path=ov.qcow2_path,
150
+ export_name=export_name,
151
+ created_at=ov.created_at,
152
+ last_boot_at=ov.last_boot_at,
153
+ file_exists=exists,
154
+ used_bytes=used,
155
+ apparent_bytes=apparent,
156
+ mtime=mtime,
157
+ running=port is not None,
158
+ nbd_port=port or 0,
159
+ machine_exists=machine is not None,
160
+ machine_labels=list(machine.labels) if machine is not None else [],
161
+ is_active=is_active,
162
+ image_name=image_names.get(ov.image_sha, ""),
163
+ base_bytes=image_sizes.get(ov.image_sha, 0),
164
+ state=state,
165
+ )
166
+
167
+
168
+ def build_overlay_views(
169
+ *,
170
+ overlays: OverlaysStore,
171
+ machines: MachinesStore,
172
+ catalog: CatalogStore,
173
+ nbd: NbdServer,
174
+ ) -> list[OverlayView]:
175
+ """Every overlay, enriched + sorted for the Overlays page."""
176
+ entries = catalog.list_entries()
177
+ image_names = {e.content_sha256: e.name for e in entries if e.content_sha256}
178
+ image_sizes = {
179
+ e.content_sha256: int(getattr(e, "size_bytes", 0) or 0) for e in entries if e.content_sha256
180
+ }
181
+ views = [
182
+ build_overlay_view(
183
+ ov,
184
+ machines=machines,
185
+ image_names=image_names,
186
+ image_sizes=image_sizes,
187
+ nbd=nbd,
188
+ )
189
+ for ov in overlays.list_all()
190
+ ]
191
+ views.sort(key=lambda v: (_STATE_ORDER.get(v.state, 9), -v.used_bytes, v.mac, v.profile))
192
+ return views
193
+
194
+
195
+ @dataclass
196
+ class OverlayTotals:
197
+ """Fleet-wide roll-up shown in the summary strip + polled live."""
198
+
199
+ count: int
200
+ used_bytes: int
201
+ running: int
202
+ active: int
203
+ reclaimable: int
204
+ reclaimable_bytes: int
205
+
206
+ def to_json(self) -> dict[str, Any]:
207
+ return {
208
+ "count": self.count,
209
+ "used_bytes": self.used_bytes,
210
+ "running": self.running,
211
+ "active": self.active,
212
+ "reclaimable": self.reclaimable,
213
+ "reclaimable_bytes": self.reclaimable_bytes,
214
+ }
215
+
216
+
217
+ def overlay_totals(views: list[OverlayView]) -> OverlayTotals:
218
+ return OverlayTotals(
219
+ count=len(views),
220
+ used_bytes=sum(v.used_bytes for v in views),
221
+ running=sum(1 for v in views if v.running),
222
+ active=sum(1 for v in views if v.state == STATE_ACTIVE),
223
+ reclaimable=sum(1 for v in views if v.reclaimable),
224
+ reclaimable_bytes=sum(v.used_bytes for v in views if v.reclaimable),
225
+ )
@@ -64,6 +64,18 @@ DEFAULT_DATETIME_FORMAT = "%Y-%m-%d %H:%M:%S %Z"
64
64
  KEY_LIVE_ENV_EXTRA_CMDLINE = "live_env.extra_cmdline"
65
65
  ENV_LIVE_ENV_EXTRA_CMDLINE = "PIXIE_LIVE_ENV_EXTRA_CMDLINE"
66
66
 
67
+ # Where the operator "Fetch live-env" action pulls the netboot-pc bake
68
+ # from: a single tarball ``src`` (``https://`` or ``oras://``, the same
69
+ # schemes the catalog fetch speaks) that unpacks to vmlinuz + initrd +
70
+ # live.squashfs. Defaults to the pixie GitHub release's stable-named
71
+ # asset via ``/releases/latest/download/`` so a plain deploy can pull
72
+ # the live env with no config; override for an air-gapped mirror.
73
+ KEY_LIVE_ENV_SRC = "live_env.src"
74
+ ENV_LIVE_ENV_SRC = "PIXIE_LIVE_ENV_SRC"
75
+ DEFAULT_LIVE_ENV_SRC = (
76
+ "https://github.com/safl/pixie/releases/latest/download/pixie-live-env-x86_64.tar.gz"
77
+ )
78
+
67
79
 
68
80
  class SettingValueError(ValueError):
69
81
  """A stored override failed validation at resolve time. The
@@ -168,6 +180,17 @@ class SettingsStore:
168
180
  or ""
169
181
  )
170
182
 
183
+ def resolve_live_env_src(self) -> str:
184
+ """Effective live-env fetch src: DB override -> env -> default
185
+ (the pixie GitHub release asset). Never empty; the caller feeds
186
+ it straight to the live-env fetch, which raises on a bad
187
+ scheme."""
188
+ return (
189
+ self.get(KEY_LIVE_ENV_SRC)
190
+ or (os.environ.get(ENV_LIVE_ENV_SRC) or "").strip()
191
+ or DEFAULT_LIVE_ENV_SRC
192
+ )
193
+
171
194
 
172
195
  def parse_iso_utc(raw: str) -> datetime | None:
173
196
  """Parse the ISO-8601 strings pixie writes to state.db. Accepts a
@@ -21,7 +21,9 @@
21
21
  <input type="url" id="catalog_url" name="url"
22
22
  class="form-control form-control-sm"
23
23
  style="max-width: 22rem; min-width: 0;"
24
+ value="{{ default_catalog_url }}"
24
25
  placeholder="catalog.toml URL"
26
+ title="Defaults to pixie's curated catalog (the netboot-capable subset). Replace to import a different catalog.toml, e.g. the full nosi catalog."
25
27
  required>
26
28
  <button type="submit" class="btn btn-sm btn-primary flex-shrink-0 text-nowrap">
27
29
  Import
@@ -132,44 +132,45 @@
132
132
  </div>
133
133
  </div>
134
134
 
135
- {# Live-env media state. The pixie live env (netboot-pc bake) has to
136
- be staged under PIXIE_LIVE_ENV_DIR before any pixie-* boot mode
137
- works; without it the plan renderer emits an "unavailable" iPXE
138
- chain and a bound target sits at a legible "media not staged"
139
- screen. Surface the readiness signal + per-file bytes so an
140
- operator can tell at a glance whether the boot modes are live. #}
135
+ {# Live-env media readiness (OUTPUT ONLY). The dashboard reports
136
+ whether the pixie-* boot modes have their media; the Fetch action +
137
+ source / cmdline config live on the dedicated /ui/live-env pane. #}
138
+ {% set lfs = stats.live_env_fetch_state or {} %}
141
139
  <div class="card shadow-sm mb-4">
142
- <div class="card-header bg-light d-flex align-items-center">
140
+ <div class="card-header bg-light d-flex align-items-center gap-2">
143
141
  <h2 class="h5 mb-0"><i class="bi bi-boombox me-2"></i>Live-env media</h2>
144
- {% if stats.live_env_ready %}
145
- <span class="badge text-bg-success ms-auto">
146
- <i class="bi bi-check-circle me-1"></i>ready
147
- </span>
148
- {% else %}
149
- <span class="badge text-bg-warning ms-auto">
150
- <i class="bi bi-exclamation-triangle me-1"></i>not staged
151
- </span>
152
- {% endif %}
142
+ <div class="ms-auto d-flex align-items-center gap-2">
143
+ {% if lfs.state == 'fetching' %}
144
+ <span class="badge text-bg-info"><span class="spinner-border spinner-border-sm me-1" role="status"></span>fetching</span>
145
+ {% elif stats.live_env_ready %}
146
+ <span class="badge text-bg-success"><i class="bi bi-check-circle me-1"></i>ready</span>
147
+ {% else %}
148
+ <span class="badge text-bg-warning"><i class="bi bi-exclamation-triangle me-1"></i>not staged</span>
149
+ {% endif %}
150
+ <a href="/ui/live-env" class="btn btn-sm btn-outline-primary text-nowrap"
151
+ title="Fetch + configure the live env">
152
+ <i class="bi bi-boombox me-1"></i>Manage
153
+ </a>
154
+ </div>
153
155
  </div>
154
156
  <div class="card-body">
155
157
  {% if stats.live_env_ready %}
156
158
  <p class="small mb-2">
157
- The three files below are staged at
158
- <code>{{ stats.live_env_dir }}</code> and
159
- <code>pixie-tui</code>, <code>pixie-inventory</code>, and
160
- <code>pixie-flash-*</code> can serve the live env from
161
- <code>/boot/pixie-live-env/</code>.
159
+ Staged at <code>{{ stats.live_env_dir }}</code>;
160
+ <code>pixie-tui</code> / <code>pixie-inventory</code> /
161
+ <code>pixie-flash-*</code> can serve the live env.
162
162
  </p>
163
163
  {% else %}
164
164
  <p class="small text-warning mb-2">
165
- Live-env media is missing under
166
- <code>{{ stats.live_env_dir or '(unset)' }}</code>.
167
- Every <code>pixie-*</code> boot_mode falls back to the
168
- "unavailable" iPXE plan until the three files below are
169
- present. Copy vmlinuz + initrd + live.squashfs from the
170
- latest <code>pixie-netboot-pc-x86_64</code> bake into
171
- that directory. Restart the container is NOT required
172
- (the renderer checks the files each render).
165
+ Live-env media is missing under <code>{{ stats.live_env_dir or '(unset)' }}</code>.
166
+ Every <code>pixie-*</code> boot_mode falls back to the "unavailable" iPXE
167
+ plan until it is staged. <a href="/ui/live-env">Fetch it &rarr;</a>
168
+ </p>
169
+ {% endif %}
170
+ {% if lfs.state == 'error' %}
171
+ <p class="small text-danger mb-2">
172
+ <i class="bi bi-exclamation-triangle me-1"></i>Last fetch failed &mdash; see
173
+ <a href="/ui/live-env">Live env</a>.
173
174
  </p>
174
175
  {% endif %}
175
176
  <dl class="row small mb-0">
@@ -186,6 +187,12 @@
186
187
  </dl>
187
188
  </div>
188
189
  </div>
190
+ {% if lfs.state == 'fetching' %}
191
+ {# The fetch runs on the server's thread pool; poll the page so the
192
+ spinner flips to "ready" (or an error) on its own. A big squashfs
193
+ download takes a while, so 5 s is frequent enough without hammering. #}
194
+ <script>setTimeout(function () { location.reload(); }, 5000);</script>
195
+ {% endif %}
189
196
 
190
197
  <div class="card shadow-sm">
191
198
  <div class="card-header bg-light d-flex align-items-center">
@@ -285,6 +285,12 @@
285
285
  <a class="nav-btn {% if page == 'catalog' %}active{% endif %}" href="/ui/catalog">
286
286
  <i class="bi bi-collection"></i>Catalog
287
287
  </a>
288
+ <a class="nav-btn {% if page == 'overlays' %}active{% endif %}" href="/ui/overlays">
289
+ <i class="bi bi-layers"></i>Overlays
290
+ </a>
291
+ <a class="nav-btn {% if page == 'live-env' %}active{% endif %}" href="/ui/live-env">
292
+ <i class="bi bi-boombox"></i>Live env
293
+ </a>
288
294
  <a class="nav-btn {% if page == 'events' %}active{% endif %}" href="/ui/events">
289
295
  <i class="bi bi-clock-history"></i>Events
290
296
  </a>