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