pixie-lab 0.2.0__py3-none-any.whl → 0.3.1__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 +25 -1
- pixie/catalog/_live_env.py +128 -0
- pixie/catalog/catalog.toml +81 -0
- pixie/events/_kinds.py +20 -0
- pixie/tui/_app.py +5 -3
- pixie/web/_overlays.py +225 -0
- pixie/web/_settings_store.py +23 -0
- pixie/web/_templates/catalog.html +2 -0
- pixie/web/_templates/dashboard.html +36 -29
- pixie/web/_templates/layout.html +6 -0
- pixie/web/_templates/live_env.html +169 -0
- pixie/web/_templates/machine_detail.html +62 -96
- pixie/web/_templates/overlays.html +248 -0
- pixie/web/_templates/settings.html +0 -58
- pixie/web/main.py +359 -44
- {pixie_lab-0.2.0.dist-info → pixie_lab-0.3.1.dist-info}/METADATA +1 -1
- {pixie_lab-0.2.0.dist-info → pixie_lab-0.3.1.dist-info}/RECORD +20 -15
- {pixie_lab-0.2.0.dist-info → pixie_lab-0.3.1.dist-info}/WHEEL +0 -0
- {pixie_lab-0.2.0.dist-info → pixie_lab-0.3.1.dist-info}/entry_points.txt +0 -0
- {pixie_lab-0.2.0.dist-info → pixie_lab-0.3.1.dist-info}/licenses/LICENSE +0 -0
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
|
-
|
|
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.
|
|
332
|
-
#
|
|
333
|
-
|
|
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
|
+
)
|
pixie/web/_settings_store.py
CHANGED
|
@@ -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
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
<i class="bi bi-exclamation-triangle me-1"></i>not staged
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
158
|
-
<code>
|
|
159
|
-
<code>pixie-
|
|
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>
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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 →</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 — 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">
|
pixie/web/_templates/layout.html
CHANGED
|
@@ -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>
|