pixie-lab 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. pixie/__init__.py +20 -0
  2. pixie/_util.py +38 -0
  3. pixie/catalog/__init__.py +25 -0
  4. pixie/catalog/_fetcher.py +513 -0
  5. pixie/catalog/_routes.py +392 -0
  6. pixie/catalog/_schema.py +263 -0
  7. pixie/catalog/_store.py +234 -0
  8. pixie/deploy/__init__.py +26 -0
  9. pixie/deploy/_main.py +321 -0
  10. pixie/deploy/_templates.py +153 -0
  11. pixie/disks.py +85 -0
  12. pixie/events/__init__.py +28 -0
  13. pixie/events/_kinds.py +203 -0
  14. pixie/events/_log.py +210 -0
  15. pixie/events/_routes.py +42 -0
  16. pixie/exports/__init__.py +17 -0
  17. pixie/exports/_routes.py +211 -0
  18. pixie/exports/_store.py +156 -0
  19. pixie/exports/_supervisor.py +240 -0
  20. pixie/flash.py +1971 -0
  21. pixie/images.py +473 -0
  22. pixie/machines/__init__.py +16 -0
  23. pixie/machines/_routes.py +190 -0
  24. pixie/machines/_store.py +623 -0
  25. pixie/oras.py +547 -0
  26. pixie/pivot/__init__.py +180 -0
  27. pixie/pivot/nbdboot +348 -0
  28. pixie/pxe/__init__.py +19 -0
  29. pixie/pxe/_renderer.py +244 -0
  30. pixie/pxe/_routes.py +386 -0
  31. pixie/tftp/__init__.py +21 -0
  32. pixie/tftp/_supervisor.py +129 -0
  33. pixie/tui/__init__.py +185 -0
  34. pixie/tui/_app.py +2219 -0
  35. pixie/tui_catalog.py +657 -0
  36. pixie/web/__init__.py +6 -0
  37. pixie/web/_auth.py +70 -0
  38. pixie/web/_settings_store.py +211 -0
  39. pixie/web/_static/.gitkeep +0 -0
  40. pixie/web/_static/bootstrap-icons.min.css +5 -0
  41. pixie/web/_static/bootstrap.min.css +12 -0
  42. pixie/web/_static/fonts/bootstrap-icons.woff +0 -0
  43. pixie/web/_static/fonts/bootstrap-icons.woff2 +0 -0
  44. pixie/web/_static/htmx.min.js +1 -0
  45. pixie/web/_static/pixie-favicon.png +0 -0
  46. pixie/web/_static/sse.js +290 -0
  47. pixie/web/_table_state.py +261 -0
  48. pixie/web/_templates/_partials/page_description.html +22 -0
  49. pixie/web/_templates/_partials/recent_events.html +47 -0
  50. pixie/web/_templates/_partials/table_helpers.html +189 -0
  51. pixie/web/_templates/catalog.html +364 -0
  52. pixie/web/_templates/catalog_detail.html +386 -0
  53. pixie/web/_templates/dashboard.html +256 -0
  54. pixie/web/_templates/events.html +125 -0
  55. pixie/web/_templates/ipxe/bootstrap.j2 +12 -0
  56. pixie/web/_templates/ipxe/exit.j2 +10 -0
  57. pixie/web/_templates/ipxe/nbdboot.j2 +57 -0
  58. pixie/web/_templates/ipxe/pixie-live-env.j2 +55 -0
  59. pixie/web/_templates/ipxe/unavailable.j2 +14 -0
  60. pixie/web/_templates/layout.html +345 -0
  61. pixie/web/_templates/login.html +40 -0
  62. pixie/web/_templates/machine_detail.html +786 -0
  63. pixie/web/_templates/machines.html +186 -0
  64. pixie/web/_templates/settings.html +265 -0
  65. pixie/web/main.py +1910 -0
  66. pixie_lab-0.1.0.dist-info/METADATA +107 -0
  67. pixie_lab-0.1.0.dist-info/RECORD +70 -0
  68. pixie_lab-0.1.0.dist-info/WHEEL +4 -0
  69. pixie_lab-0.1.0.dist-info/entry_points.txt +3 -0
  70. pixie_lab-0.1.0.dist-info/licenses/LICENSE +674 -0
pixie/web/main.py ADDED
@@ -0,0 +1,1910 @@
1
+ """FastAPI application factory.
2
+
3
+ At 0.2.0: skeleton auth + catalog router mounted. The app attaches a
4
+ :class:`pixie.catalog.CatalogStore` and a fetch thread pool onto
5
+ ``app.state`` so the routes can find them via a request-scoped dep.
6
+
7
+ Run locally with:
8
+
9
+ uv run uvicorn pixie.web.main:app --reload
10
+
11
+ State + fetch-pool configuration falls back to sane defaults, so a
12
+ plain ``uv run pytest`` never needs env-vars set. Overrides:
13
+
14
+ * ``PIXIE_DATA_DIR`` - state dir (default: /var/lib/pixie).
15
+ * ``PIXIE_FETCH_POOL_SIZE`` - concurrent fetches (default: 4).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import os
21
+ import secrets
22
+ import tempfile
23
+ from collections.abc import AsyncIterator
24
+ from concurrent.futures import ThreadPoolExecutor
25
+ from contextlib import asynccontextmanager
26
+ from contextlib import suppress as contextlib_suppress
27
+ from datetime import UTC, datetime
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ from fastapi import Depends, FastAPI, Form, HTTPException, Request, status
32
+ from fastapi.exceptions import RequestValidationError
33
+ from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
34
+ from fastapi.staticfiles import StaticFiles
35
+ from fastapi.templating import Jinja2Templates
36
+ from starlette.middleware.sessions import SessionMiddleware
37
+
38
+ import pixie
39
+ from pixie.catalog._routes import router as catalog_router
40
+ from pixie.catalog._store import CatalogStore
41
+ from pixie.events import EventsLog
42
+ from pixie.events._kinds import (
43
+ AUTH_LOGIN_FAILED,
44
+ AUTH_LOGIN_SUCCEEDED,
45
+ CATALOG_BLOB_DELETED,
46
+ CATALOG_ENTRY_ADDED,
47
+ CATALOG_ENTRY_DELETED,
48
+ CATALOG_ENTRY_UPDATED,
49
+ CATALOG_FETCH_DONE,
50
+ CATALOG_FETCH_FAILED,
51
+ CATALOG_FETCH_STARTED,
52
+ CATALOG_FETCH_UNCHANGED,
53
+ CATALOG_IMPORT_FAILED,
54
+ CATALOG_IMPORT_OK,
55
+ EXPORT_NBDKIT_SPAWNED,
56
+ TFTP_STARTED,
57
+ TFTP_STOPPED,
58
+ )
59
+ from pixie.events._log import Event
60
+ from pixie.events._routes import router as events_router
61
+ from pixie.exports._routes import router as exports_router
62
+ from pixie.exports._store import ExportsStore
63
+ from pixie.exports._supervisor import DEFAULT_PORT_BASE, NbdServer
64
+ from pixie.machines._routes import router as machines_router
65
+ from pixie.machines._store import MachinesStore
66
+ from pixie.pxe._renderer import PlanRenderer
67
+ from pixie.pxe._routes import router as pxe_router
68
+ from pixie.tftp import DEFAULT_TFTP_ROOT, TftpServer
69
+ from pixie.web._auth import (
70
+ SESSION_AUTHED_KEY,
71
+ SESSION_COOKIE,
72
+ check_password,
73
+ require_auth,
74
+ using_default_password,
75
+ )
76
+ from pixie.web._settings_store import (
77
+ KEY_DATETIME_FORMAT,
78
+ KEY_DISPLAY_TZ,
79
+ KEY_LIVE_ENV_EXTRA_CMDLINE,
80
+ SettingsStore,
81
+ SettingValueError,
82
+ format_ts,
83
+ )
84
+ from pixie.web._table_state import DEFAULT_PER_PAGE
85
+
86
+ _HERE = Path(__file__).resolve().parent
87
+ _TEMPLATES_DIR = _HERE / "_templates"
88
+ _STATIC_DIR = _HERE / "_static"
89
+
90
+ DEFAULT_STATE_DIR = Path("/var/lib/pixie")
91
+ STATE_DIR_ENV = "PIXIE_DATA_DIR"
92
+ LIVE_ENV_DIR_ENV = "PIXIE_LIVE_ENV_DIR"
93
+ FETCH_POOL_SIZE_ENV = "PIXIE_FETCH_POOL_SIZE"
94
+ DEFAULT_FETCH_POOL_SIZE = 4
95
+
96
+ # NBD supervisor knobs. In production ``--network=host`` covers the
97
+ # bind + port range; in dev / tests operators tweak.
98
+ NBD_PORT_BASE_ENV = "PIXIE_NBD_PORT_BASE"
99
+ NBD_BIND_ENV = "PIXIE_NBD_BIND"
100
+ DEFAULT_NBD_BIND = "0.0.0.0"
101
+ NBDKIT_BIN_ENV = "PIXIE_NBDKIT_BIN"
102
+ DEFAULT_NBDKIT_BIN = "nbdkit"
103
+
104
+ # TFTP subprocess supervision. OFF by default so unit-test / dev
105
+ # runs don't try to bind udp/69 (root-required); flipped on inside
106
+ # the container image by the compose file's default env.
107
+ TFTP_ENABLED_ENV = "PIXIE_TFTP_ENABLED"
108
+ TFTP_BIND_ENV = "PIXIE_TFTP_BIND"
109
+ TFTP_PORT_ENV = "PIXIE_TFTP_PORT"
110
+ TFTP_ROOT_ENV = "PIXIE_TFTP_ROOT"
111
+ TFTP_BIN_ENV = "PIXIE_TFTP_BIN"
112
+ DEFAULT_TFTP_BIND = "0.0.0.0"
113
+ DEFAULT_TFTP_PORT = 69
114
+ DEFAULT_TFTP_BIN = "in.tftpd"
115
+
116
+
117
+ def _resolve_nbd_port_base() -> int:
118
+ raw = (os.environ.get(NBD_PORT_BASE_ENV) or "").strip()
119
+ if not raw:
120
+ return DEFAULT_PORT_BASE
121
+ try:
122
+ return max(1, int(raw))
123
+ except ValueError:
124
+ return DEFAULT_PORT_BASE
125
+
126
+
127
+ def _resolve_nbd_bind() -> str:
128
+ return (os.environ.get(NBD_BIND_ENV) or "").strip() or DEFAULT_NBD_BIND
129
+
130
+
131
+ def _resolve_nbdkit_bin() -> str:
132
+ return (os.environ.get(NBDKIT_BIN_ENV) or "").strip() or DEFAULT_NBDKIT_BIN
133
+
134
+
135
+ def _tftp_enabled() -> bool:
136
+ return (os.environ.get(TFTP_ENABLED_ENV) or "").strip().lower() in ("1", "true", "yes", "on")
137
+
138
+
139
+ def _resolve_tftp_bind() -> str:
140
+ return (os.environ.get(TFTP_BIND_ENV) or "").strip() or DEFAULT_TFTP_BIND
141
+
142
+
143
+ def _resolve_tftp_port() -> int:
144
+ raw = (os.environ.get(TFTP_PORT_ENV) or "").strip()
145
+ if not raw:
146
+ return DEFAULT_TFTP_PORT
147
+ try:
148
+ return max(1, int(raw))
149
+ except ValueError:
150
+ return DEFAULT_TFTP_PORT
151
+
152
+
153
+ def _resolve_tftp_root() -> Path:
154
+ override = (os.environ.get(TFTP_ROOT_ENV) or "").strip()
155
+ return Path(override) if override else DEFAULT_TFTP_ROOT
156
+
157
+
158
+ def _resolve_tftp_bin() -> str:
159
+ return (os.environ.get(TFTP_BIN_ENV) or "").strip() or DEFAULT_TFTP_BIN
160
+
161
+
162
+ def _resolve_live_env_dir() -> Path | None:
163
+ """Directory holding the pixie netboot-pc bake artifacts
164
+ (vmlinuz + initrd + squashfs). Default: ``<state_dir>/live-env``.
165
+ Explicit override via ``PIXIE_LIVE_ENV_DIR``. Returns None when
166
+ the resolved path does not exist, so the renderer's
167
+ ``_live_env_ready()`` cleanly says "no" without the operator
168
+ having to set the env var to a special sentinel."""
169
+ override = (os.environ.get(LIVE_ENV_DIR_ENV) or "").strip()
170
+ if override:
171
+ return Path(override)
172
+ # Default sits under the state dir so an operator dropping
173
+ # artifacts into ``/opt/pixie/data/live-env/`` on a compose
174
+ # deploy is the whole install step.
175
+ default = _resolve_state_dir() / "live-env"
176
+ return default
177
+
178
+
179
+ def _resolve_state_dir() -> Path:
180
+ """Where pixie writes state.db + blobs/ + artifacts/.
181
+
182
+ Falls back to a per-invocation tempdir when the default
183
+ ``/var/lib/pixie`` is not writable (test environments), so tests
184
+ don't have to set env-vars to construct the app.
185
+ """
186
+ override = os.environ.get(STATE_DIR_ENV, "").strip()
187
+ if override:
188
+ return Path(override)
189
+ if os.access(str(DEFAULT_STATE_DIR.parent), os.W_OK):
190
+ DEFAULT_STATE_DIR.mkdir(parents=True, exist_ok=True)
191
+ return DEFAULT_STATE_DIR
192
+ return Path(tempfile.mkdtemp(prefix="pixie-state-"))
193
+
194
+
195
+ def _resolve_fetch_pool_size() -> int:
196
+ raw = (os.environ.get(FETCH_POOL_SIZE_ENV) or "").strip()
197
+ if not raw:
198
+ return DEFAULT_FETCH_POOL_SIZE
199
+ try:
200
+ n = int(raw)
201
+ except ValueError:
202
+ return DEFAULT_FETCH_POOL_SIZE
203
+ return max(1, n)
204
+
205
+
206
+ class NotAuthenticated(Exception):
207
+ """Raised by the UI dependency when a browser request lacks the
208
+ session cookie. The exception handler redirects to /ui/login."""
209
+
210
+
211
+ def _respawn_exports_at_startup(app: FastAPI) -> None:
212
+ """Walk ``exports_store.list()`` and spawn nbdkit for each row
213
+ whose catalog blob still exists on disk. Called once from the
214
+ lifespan startup path so an operator who bounces the container
215
+ (rebuild + recreate) does not come back to a wall of
216
+ ``status=error nbdkit exited`` rows in the /ui/exports table.
217
+
218
+ Runs BEFORE ``yield`` so the first ``GET /pxe/<mac>`` served
219
+ after startup sees the exports as ``running`` again. Errors on
220
+ an individual export (missing blob, nbdkit refuses to spawn)
221
+ update that row's ``status`` + ``error`` but do NOT abort
222
+ startup; the other exports still respawn.
223
+ """
224
+ import logging as _logging
225
+
226
+ log = _logging.getLogger(__name__)
227
+ catalog = app.state.catalog_store
228
+ exports_store = app.state.exports_store
229
+ nbd = app.state.nbd_server
230
+ for export in exports_store.list():
231
+ blob_path = catalog.blob_path(export.content_sha256)
232
+ if not blob_path.exists():
233
+ exports_store.update_runtime(
234
+ export.name,
235
+ nbd_port=0,
236
+ status="error",
237
+ error=f"blob missing at {blob_path}",
238
+ )
239
+ log.warning(
240
+ "export %s: blob missing at %s; leaving row in error state",
241
+ export.name,
242
+ blob_path,
243
+ )
244
+ continue
245
+ try:
246
+ port = nbd.spawn(export.name, blob_path)
247
+ except RuntimeError as exc:
248
+ exports_store.update_runtime(
249
+ export.name,
250
+ nbd_port=0,
251
+ status="error",
252
+ error=f"respawn failed: {exc}",
253
+ )
254
+ log.warning("export %s: respawn failed: %s", export.name, exc)
255
+ continue
256
+ exports_store.update_runtime(export.name, nbd_port=port, status="running", error="")
257
+ log.info("export %s: respawned on port %d", export.name, port)
258
+ events = getattr(app.state, "events_log", None)
259
+ if events is not None:
260
+ events.emit(
261
+ EXPORT_NBDKIT_SPAWNED,
262
+ subject_kind="export",
263
+ subject_id=export.name,
264
+ summary=f"nbdkit respawned on port {port} (startup)",
265
+ details={"nbd_port": port, "reason": "startup-respawn"},
266
+ )
267
+
268
+
269
+ def _require_ui_auth(request: Request) -> None:
270
+ if not request.session.get(SESSION_AUTHED_KEY):
271
+ raise NotAuthenticated
272
+
273
+
274
+ #: How many "recent events" tiles the per-portion cards show above
275
+ #: the /ui/events log link. Tuned once here rather than sprinkled
276
+ #: through the /ui/catalog + /ui/machines handlers as a magic 10.
277
+ _RECENT_EVENTS_LIMIT = 10
278
+
279
+
280
+ def _deployment_envvar_docs() -> list[dict[str, str]]:
281
+ """Documentation rows for the Settings > Deployment card. Kept in
282
+ ONE place so the docs and the actual env-var constants (elsewhere
283
+ in this module + in ``_auth``) can't drift silently. Rendered
284
+ verbatim in ``settings.html``; each row = one variable."""
285
+ from pixie.web._auth import ADMIN_PASSWORD_ENV
286
+
287
+ return [
288
+ {
289
+ "name": ADMIN_PASSWORD_ENV,
290
+ "default": "",
291
+ "purpose": (
292
+ "Password for the /ui/login form. MUST be set; pixie"
293
+ " refuses UI writes when unset. The compose bundle"
294
+ " ships with a random default operators are expected"
295
+ " to replace."
296
+ ),
297
+ },
298
+ {
299
+ "name": "PIXIE_PUBLIC_HOST",
300
+ "default": "127.0.0.1",
301
+ "purpose": (
302
+ "LAN hostname or IP pixie tells iPXE targets to fetch"
303
+ " artifacts from. Set to the pixie host's LAN address"
304
+ " on the deploy so PXE targets can reach it. The"
305
+ " compose bundle sources this from PIXIE_HOST_ADDR"
306
+ " in envvars."
307
+ ),
308
+ },
309
+ {
310
+ "name": "PIXIE_NBD_PUBLIC_HOST",
311
+ "default": "PIXIE_PUBLIC_HOST",
312
+ "purpose": (
313
+ "Separate NBD-server hostname if pixie is fronted by a"
314
+ " reverse proxy for HTTP but NBD is direct. Usually"
315
+ " equal to PIXIE_PUBLIC_HOST."
316
+ ),
317
+ },
318
+ {
319
+ "name": STATE_DIR_ENV,
320
+ "default": str(DEFAULT_STATE_DIR),
321
+ "purpose": (
322
+ "State root. Holds state.db, blobs (fetched image"
323
+ " bytes), artifacts (extracted netboot bundles), and"
324
+ " live-env by default."
325
+ ),
326
+ },
327
+ {
328
+ "name": LIVE_ENV_DIR_ENV,
329
+ "default": "$PIXIE_DATA_DIR/live-env",
330
+ "purpose": (
331
+ "Where the pixie live-env vmlinuz + initrd +"
332
+ " live.squashfs are staged. The pixie-* boot modes"
333
+ " degrade to an unavailable plan when this dir is"
334
+ " missing or incomplete."
335
+ ),
336
+ },
337
+ {
338
+ "name": "PIXIE_LIVE_ENV_EXTRA_CMDLINE",
339
+ "default": "",
340
+ "purpose": (
341
+ "Extra kernel-cmdline tokens appended to every"
342
+ " pixie-live-env chain. Global default; a machine"
343
+ " with its own extra_cmdline binding overrides. See"
344
+ " the Live-env card above for the live-editable"
345
+ " override."
346
+ ),
347
+ },
348
+ {
349
+ "name": FETCH_POOL_SIZE_ENV,
350
+ "default": str(DEFAULT_FETCH_POOL_SIZE),
351
+ "purpose": (
352
+ "How many catalog fetches run in parallel. Bump for"
353
+ " a fast pipe with lots of catalog entries; lower on"
354
+ " a shared network so a fetch storm doesn't starve"
355
+ " concurrent flashes."
356
+ ),
357
+ },
358
+ {
359
+ "name": NBD_BIND_ENV,
360
+ "default": DEFAULT_NBD_BIND,
361
+ "purpose": (
362
+ "Address nbdkit binds each per-image export to. Under"
363
+ " --network=host on the compose deploy, 0.0.0.0"
364
+ " reaches the LAN."
365
+ ),
366
+ },
367
+ {
368
+ "name": NBD_PORT_BASE_ENV,
369
+ "default": "10809",
370
+ "purpose": (
371
+ "First TCP port nbdkit reserves for exports; each"
372
+ " subsequent export lands on the next free port."
373
+ ),
374
+ },
375
+ {
376
+ "name": NBDKIT_BIN_ENV,
377
+ "default": DEFAULT_NBDKIT_BIN,
378
+ "purpose": "Path to the nbdkit binary. Defaults to `nbdkit` on $PATH.",
379
+ },
380
+ {
381
+ "name": TFTP_ENABLED_ENV,
382
+ "default": "false",
383
+ "purpose": (
384
+ "Set to 1 / true / yes / on to bring pixie's in-process"
385
+ " TFTP responder up on startup. Requires udp/69, hence"
386
+ " root; the compose file's --network=host + running as"
387
+ " root inside the container handles this."
388
+ ),
389
+ },
390
+ {
391
+ "name": TFTP_BIND_ENV,
392
+ "default": DEFAULT_TFTP_BIND,
393
+ "purpose": "Address pixie's TFTP responder listens on.",
394
+ },
395
+ {
396
+ "name": TFTP_PORT_ENV,
397
+ "default": str(DEFAULT_TFTP_PORT),
398
+ "purpose": "UDP port for pixie's TFTP responder. Standard PXE assumes 69.",
399
+ },
400
+ {
401
+ "name": TFTP_ROOT_ENV,
402
+ "default": "(package-bundled iPXE binaries)",
403
+ "purpose": (
404
+ "Directory pixie serves over TFTP. Defaults to the"
405
+ " iPXE binaries pixie ships (undionly.kpxe, ipxe.efi)."
406
+ ),
407
+ },
408
+ {
409
+ "name": TFTP_BIN_ENV,
410
+ "default": DEFAULT_TFTP_BIN,
411
+ "purpose": "Path to the in.tftpd binary. Only used when TFTP is enabled.",
412
+ },
413
+ ]
414
+
415
+
416
+ def _deployment_state() -> dict[str, Any]:
417
+ """Snapshot of the resolved deployment knobs for rendering in the
418
+ Settings > Deployment card's DHCP + listen sections. Reads the
419
+ resolvers above (each hides the env-var fallback chain) so the
420
+ card shows the actual values pixie is using right now."""
421
+ host = (os.environ.get("PIXIE_PUBLIC_HOST") or "").strip() or "127.0.0.1"
422
+ return {
423
+ "host": host,
424
+ "port": 8080, # pixie's HTTP listener is hard-coded on the compose bundle
425
+ "tftp_enabled": _tftp_enabled(),
426
+ "tftp_bind": _resolve_tftp_bind(),
427
+ "tftp_port": _resolve_tftp_port(),
428
+ "tftp_root": str(_resolve_tftp_root()),
429
+ "nbd_bind": _resolve_nbd_bind(),
430
+ "nbd_port_base": _resolve_nbd_port_base(),
431
+ }
432
+
433
+
434
+ def _fetch_would_be_noop(entry: Any, store: Any) -> bool:
435
+ """Would :func:`pixie.catalog._fetcher.fetch` take its fast path
436
+ for this entry? True iff the entry is flagged fetched AND the
437
+ corresponding on-disk artifact (blob for a disk image, unpacked
438
+ manifest.json for a tar.gz netboot bundle) is still present.
439
+
440
+ Used by the ``/ui/catalog/fetch`` handler to distinguish an
441
+ Update click that would produce no visible change from one that
442
+ would actually re-download bytes. The operator gets a
443
+ catalog.fetch.unchanged event + a short-lived "already at latest"
444
+ pill instead of a millisecond flicker on the fetching badge."""
445
+ if not getattr(entry, "content_sha256", ""):
446
+ return False
447
+ if entry.format == "tar.gz":
448
+ manifest = store.artifact_path(entry.content_sha256, "manifest.json")
449
+ return bool(manifest.is_file())
450
+ blob = store.blob_path(entry.content_sha256)
451
+ return bool(blob.is_file())
452
+
453
+
454
+ def _recent_events_for(events_log: EventsLog, subject_kind: str) -> list[Event]:
455
+ """Tail of the events log filtered to one subject kind. Callers
456
+ are the per-portion recent-events cards on the catalog and
457
+ machines list pages (the per-machine detail page uses a filtered
458
+ read with ``subject_id`` + a larger limit and does not go
459
+ through this helper)."""
460
+ return events_log.list(subject_kind=subject_kind, limit=_RECENT_EVENTS_LIMIT)
461
+
462
+
463
+ def create_app() -> FastAPI:
464
+ """Build the FastAPI app. Factory shape so tests can construct a
465
+ fresh app per fixture without global state."""
466
+
467
+ @asynccontextmanager
468
+ async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
469
+ # startup: state already attached below; kick off TFTP if
470
+ # enabled (bind udp/69 requires root; disabled in unit / dev).
471
+ if getattr(app.state, "tftp_server", None) is not None:
472
+ try:
473
+ app.state.tftp_server.start()
474
+ events = getattr(app.state, "events_log", None)
475
+ if events is not None:
476
+ events.emit(TFTP_STARTED, summary="tftp subprocess up")
477
+ except RuntimeError as exc:
478
+ import logging as _logging
479
+
480
+ _logging.getLogger(__name__).warning("tftp start failed: %s", exc)
481
+ # Re-spawn nbdkit for every stored export whose catalog blob
482
+ # still exists. A container recreate takes down nbdkit
483
+ # subprocesses (they are children of the previous uvicorn),
484
+ # but the export rows in state.db persist. Without this
485
+ # startup pass the rows come back as ``status=error``
486
+ # ``nbdkit exited`` forever until an operator deletes + POSTs
487
+ # them again. Idempotent per name; harmless on cold boot
488
+ # (empty export list -> no-op).
489
+ _respawn_exports_at_startup(app)
490
+ try:
491
+ yield
492
+ finally:
493
+ # Stop children on graceful shutdown so a Ctrl-C +
494
+ # restart doesn't leave orphan processes clinging to
495
+ # NBD ports or udp/69.
496
+ with contextlib_suppress(Exception):
497
+ app.state.nbd_server.stop()
498
+ if getattr(app.state, "tftp_server", None) is not None:
499
+ with contextlib_suppress(Exception):
500
+ app.state.tftp_server.stop()
501
+ events = getattr(app.state, "events_log", None)
502
+ if events is not None:
503
+ events.emit(TFTP_STOPPED, summary="tftp subprocess down")
504
+
505
+ app = FastAPI(
506
+ title="pixie",
507
+ version=pixie.__version__,
508
+ description="Bare-metal netboot appliance.",
509
+ lifespan=_lifespan,
510
+ )
511
+
512
+ # State on app.state. Route handlers read via
513
+ # ``request.app.state.<name>``.
514
+ state_dir = _resolve_state_dir()
515
+ app.state.catalog_store = CatalogStore(state_dir)
516
+ app.state.exports_store = ExportsStore(app.state.catalog_store.db_path)
517
+ app.state.machines_store = MachinesStore(app.state.catalog_store.db_path)
518
+ app.state.events_log = EventsLog(app.state.catalog_store.db_path)
519
+ app.state.settings_store = SettingsStore(app.state.catalog_store.db_path)
520
+ app.state.nbd_server = NbdServer(
521
+ port_base=_resolve_nbd_port_base(),
522
+ bind=_resolve_nbd_bind(),
523
+ nbdkit_bin=_resolve_nbdkit_bin(),
524
+ )
525
+ app.state.live_env_dir = _resolve_live_env_dir()
526
+ app.state.pxe_renderer = PlanRenderer(
527
+ catalog=app.state.catalog_store,
528
+ exports=app.state.exports_store,
529
+ nbd=app.state.nbd_server,
530
+ live_env_dir=app.state.live_env_dir,
531
+ )
532
+ app.state.tftp_server = (
533
+ TftpServer(
534
+ bind=_resolve_tftp_bind(),
535
+ port=_resolve_tftp_port(),
536
+ root=_resolve_tftp_root(),
537
+ bin=_resolve_tftp_bin(),
538
+ )
539
+ if _tftp_enabled()
540
+ else None
541
+ )
542
+ app.state.fetch_pool = ThreadPoolExecutor(
543
+ max_workers=_resolve_fetch_pool_size(),
544
+ thread_name_prefix="pixie-fetch",
545
+ )
546
+ app.state.fetch_states = {}
547
+
548
+ # SessionMiddleware signs the ``pixie-token`` cookie. Sliding TTL:
549
+ # 7 days from last touch. ``https_only=False`` because pixie is
550
+ # LAN-only by design; operators front with TLS if they want it.
551
+ app.add_middleware(
552
+ SessionMiddleware,
553
+ secret_key=secrets.token_urlsafe(32),
554
+ session_cookie=SESSION_COOKIE,
555
+ max_age=60 * 60 * 24 * 7,
556
+ same_site="strict",
557
+ https_only=False,
558
+ )
559
+
560
+ templates = Jinja2Templates(directory=str(_TEMPLATES_DIR))
561
+ # Register the table-helpers query-string builder as a Jinja
562
+ # global so the pagination + search macros can compose links
563
+ # without every template having to reach into ``request.url``.
564
+ from pixie.web._table_state import build_query_string as _qs_helper
565
+
566
+ templates.env.globals["_qs"] = _qs_helper
567
+
568
+ # ``fmt_ts`` folds a raw ISO-8601 timestamp (as pixie writes them
569
+ # to state.db) through the operator's current Settings picks:
570
+ # timezone + strftime pattern. A closure over ``app.state.settings_store``
571
+ # keeps the filter dependency-free at the call site
572
+ # (``{{ e.ts | fmt_ts }}``) while still picking up a live Settings
573
+ # change on the next render, since ``resolve_*`` reads the DB on
574
+ # every call.
575
+ def _fmt_ts_filter(raw: str) -> str:
576
+ return format_ts(raw or "", app.state.settings_store)
577
+
578
+ templates.env.filters["fmt_ts"] = _fmt_ts_filter
579
+ if _STATIC_DIR.is_dir():
580
+ app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
581
+ # Serve the netboot-pc bake artifacts (vmlinuz + initrd +
582
+ # squashfs) under ``/boot/pixie-live-env/`` so the live-env iPXE
583
+ # chain can fetch them. Only mounted when the directory exists;
584
+ # an operator dropping the three files in without a pixie
585
+ # restart is picked up by the renderer's ``_live_env_ready()``
586
+ # check per-render.
587
+ if app.state.live_env_dir and app.state.live_env_dir.is_dir():
588
+ app.mount(
589
+ "/boot/pixie-live-env",
590
+ StaticFiles(directory=str(app.state.live_env_dir)),
591
+ name="live-env",
592
+ )
593
+
594
+ # ---------- exception handlers -----------------------------------
595
+
596
+ @app.exception_handler(NotAuthenticated)
597
+ async def _redirect_to_login(_request: Request, _exc: NotAuthenticated) -> RedirectResponse:
598
+ return RedirectResponse(url="/ui/login", status_code=status.HTTP_303_SEE_OTHER)
599
+
600
+ @app.exception_handler(RequestValidationError)
601
+ async def _validation_error(_request: Request, exc: RequestValidationError) -> JSONResponse:
602
+ return JSONResponse(
603
+ status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
604
+ content={"detail": exc.errors()},
605
+ )
606
+
607
+ # ---------- open routes ------------------------------------------
608
+
609
+ @app.get("/healthz")
610
+ def healthz() -> dict[str, Any]:
611
+ return {"status": "ok", "service": "pixie", "version": pixie.__version__}
612
+
613
+ @app.api_route("/pivot/nbdboot.cpio.gz", methods=["GET", "HEAD"])
614
+ def pivot_nbdboot() -> Response:
615
+ """Serve the nbdboot pivot overlay as a gzipped newc-cpio.
616
+
617
+ Loaded by ``ipxe/nbdboot.j2`` as a supplementary ``initrd``
618
+ directive after nosi's own initrd. Linux concatenates cpio
619
+ streams during initramfs unpack; same-path entries from a
620
+ later overlay win, so ``/scripts/nbdboot`` here shadows any
621
+ legacy ``/scripts/ramboot`` in nosi's baked initrd -- and a
622
+ future nosi release can ship a generic initrd (no
623
+ pixie-flavour pivot script) without breaking anything on
624
+ pixie's side.
625
+
626
+ Route is OPEN by design (the target holds no session cookie
627
+ at boot). Built once per app startup from
628
+ :mod:`pixie.pivot`; the bytes are byte-identical across
629
+ builds so a downstream cacher can treat the URL as
630
+ immutable per pixie release."""
631
+ from pixie.pivot import build_pivot_cpio_gz
632
+
633
+ # Rebuilt lazily but memoised on ``app.state`` so the second
634
+ # + subsequent hits don't pay the (~ms) cpio-assembly cost.
635
+ blob = getattr(app.state, "pivot_nbdboot_cpio_gz", None)
636
+ if blob is None:
637
+ blob = build_pivot_cpio_gz()
638
+ app.state.pivot_nbdboot_cpio_gz = blob
639
+ return Response(content=blob, media_type="application/gzip")
640
+
641
+ @app.get("/", include_in_schema=False)
642
+ def root() -> RedirectResponse:
643
+ return RedirectResponse(url="/ui/", status_code=status.HTTP_303_SEE_OTHER)
644
+
645
+ # ---------- ui: login / logout / dashboard -----------------------
646
+
647
+ @app.get("/ui/login", response_class=HTMLResponse)
648
+ def ui_login_form(request: Request) -> HTMLResponse:
649
+ return templates.TemplateResponse(
650
+ request,
651
+ "login.html",
652
+ {
653
+ "version": pixie.__version__,
654
+ "error": None,
655
+ "authed": False,
656
+ "page": "login",
657
+ "using_default_password": using_default_password(),
658
+ },
659
+ )
660
+
661
+ @app.post("/ui/login", response_class=HTMLResponse)
662
+ def ui_login(request: Request, password: str = Form(...)) -> Any:
663
+ events = getattr(request.app.state, "events_log", None)
664
+ if not check_password(password):
665
+ if events is not None:
666
+ events.emit(
667
+ AUTH_LOGIN_FAILED,
668
+ summary="wrong password",
669
+ )
670
+ return templates.TemplateResponse(
671
+ request,
672
+ "login.html",
673
+ {
674
+ "version": pixie.__version__,
675
+ "error": "Invalid password.",
676
+ "authed": False,
677
+ "page": "login",
678
+ "using_default_password": using_default_password(),
679
+ },
680
+ status_code=status.HTTP_401_UNAUTHORIZED,
681
+ )
682
+ if events is not None:
683
+ events.emit(AUTH_LOGIN_SUCCEEDED, summary="admin logged in")
684
+ request.session[SESSION_AUTHED_KEY] = True
685
+ return RedirectResponse(url="/ui/", status_code=status.HTTP_303_SEE_OTHER)
686
+
687
+ @app.post("/ui/logout")
688
+ def ui_logout(request: Request) -> RedirectResponse:
689
+ request.session.clear()
690
+ return RedirectResponse(url="/ui/login", status_code=status.HTTP_303_SEE_OTHER)
691
+
692
+ @app.get("/ui/", response_class=HTMLResponse)
693
+ def ui_dashboard(
694
+ request: Request,
695
+ _auth: None = Depends(_require_ui_auth),
696
+ ) -> HTMLResponse:
697
+ # Landing page: summary counts + latest events. The catalog
698
+ # moved to its own /ui/catalog route so the brand pill's
699
+ # "Home" click lands on a page that reads as a status
700
+ # overview rather than a management surface.
701
+ events = request.app.state.events_log.list(limit=10)
702
+ stats = _dashboard_stats(request)
703
+ return templates.TemplateResponse(
704
+ request,
705
+ "dashboard.html",
706
+ {
707
+ "version": pixie.__version__,
708
+ "stats": stats,
709
+ "events": events,
710
+ "authed": True,
711
+ "page": "dashboard",
712
+ },
713
+ )
714
+
715
+ @app.get("/ui/catalog", response_class=HTMLResponse)
716
+ def ui_catalog(
717
+ request: Request,
718
+ q: str = "",
719
+ page: int = 1,
720
+ per_page: int = DEFAULT_PER_PAGE,
721
+ sort: str = "",
722
+ dir: str = "",
723
+ _auth: None = Depends(_require_ui_auth),
724
+ ) -> HTMLResponse:
725
+ """Catalog + exports in one view. Each disk-image entry
726
+ carries its NBD-serving state (port + status + nbdkit error);
727
+ netboot bundles just show their fetch state -- they are served
728
+ as HTTP artifacts from ``/artifacts/<sha>/{vmlinuz,initrd}``
729
+ rather than over NBD, so no port is meaningful for them."""
730
+ from pixie.exports._routes import _refresh_row
731
+ from pixie.web._table_state import (
732
+ filter_rows,
733
+ parse_pagination,
734
+ parse_sort,
735
+ sort_rows,
736
+ )
737
+
738
+ catalog = request.app.state.catalog_store
739
+ exports_store = request.app.state.exports_store
740
+ nbd_server = request.app.state.nbd_server
741
+ events_log = request.app.state.events_log
742
+ exports_by_sha: dict[str, Any] = {}
743
+ for row in exports_store.list():
744
+ refreshed = _refresh_row(row, nbd_server, exports_store, events_log)
745
+ exports_by_sha[refreshed.content_sha256] = refreshed
746
+ all_entries = catalog.list_entries()
747
+ filtered = filter_rows(
748
+ all_entries,
749
+ q,
750
+ fields=("name", "src", "netboot_src", "arch", "format", "description"),
751
+ )
752
+ sort_state = parse_sort(
753
+ dict(request.query_params),
754
+ allowed={
755
+ "name": "name",
756
+ "format": "format",
757
+ "arch": "arch",
758
+ "added_at": "added_at",
759
+ "size_bytes": "size_bytes",
760
+ },
761
+ default_column="name",
762
+ )
763
+ filtered = sort_rows(filtered, sort_state)
764
+ page_state = parse_pagination(dict(request.query_params), total=len(filtered))
765
+ page_entries = filtered[page_state.offset : page_state.offset + page_state.per_page]
766
+ preserved = {
767
+ k: v
768
+ for k, v in {
769
+ "q": q,
770
+ "sort": sort_state.column if sort_state.column != "name" else "",
771
+ "dir": sort_state.direction if sort_state.direction != "asc" else "",
772
+ "per_page": str(page_state.per_page)
773
+ if page_state.per_page != DEFAULT_PER_PAGE
774
+ else "",
775
+ }.items()
776
+ if v
777
+ }
778
+ # Most recent catalog-scoped events (add / delete / fetch
779
+ # started / done / failed). Renders under the entry table
780
+ # with a link to /ui/events for the full log. Same shape
781
+ # lands on the machines list page so a per-portion events
782
+ # surface is where the operator expects it to be.
783
+ catalog_events = _recent_events_for(events_log, "entry")
784
+ return templates.TemplateResponse(
785
+ request,
786
+ "catalog.html",
787
+ {
788
+ "version": pixie.__version__,
789
+ "entries": page_entries,
790
+ "fetch_states": request.app.state.fetch_states,
791
+ "exports_by_sha": exports_by_sha,
792
+ "q": q,
793
+ "sort": sort_state,
794
+ "page_state": page_state,
795
+ "preserved": preserved,
796
+ "catalog_events": catalog_events,
797
+ "authed": True,
798
+ "page": "catalog",
799
+ },
800
+ )
801
+
802
+ @app.get("/ui/catalog/{name}", response_model=None)
803
+ def ui_catalog_detail(
804
+ request: Request,
805
+ name: str,
806
+ _auth: None = Depends(_require_ui_auth),
807
+ ) -> HTMLResponse | RedirectResponse:
808
+ """Per-entry detail: description + sibling relations. A
809
+ disk-image entry with a ``netboot_src`` URL surfaces its
810
+ netboot-bundle sibling (if the operator has imported it); a
811
+ netboot-bundle entry surfaces every disk-image entry pointing
812
+ at its ``src`` via ``netboot_src``. Falls through to
813
+ /ui/catalog on an unknown name."""
814
+ from pixie.exports._routes import _refresh_row
815
+
816
+ catalog = request.app.state.catalog_store
817
+ entry = catalog.get_entry(name)
818
+ if entry is None:
819
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
820
+ all_entries = catalog.list_entries()
821
+ # Forward relation: this entry's netboot_src -> sibling bundle.
822
+ forward = None
823
+ if entry.netboot_src:
824
+ forward = next((e for e in all_entries if e.src == entry.netboot_src), None)
825
+ # Reverse relation: any disk image whose netboot_src == entry.src.
826
+ backward = [e for e in all_entries if e.netboot_src == entry.src] if entry.src else []
827
+ # NBD export state for the entry itself (disk images only).
828
+ exp = None
829
+ if entry.bindable and entry.content_sha256:
830
+ for row in request.app.state.exports_store.list():
831
+ if row.content_sha256 == entry.content_sha256:
832
+ exp = _refresh_row(
833
+ row,
834
+ request.app.state.nbd_server,
835
+ request.app.state.exports_store,
836
+ request.app.state.events_log,
837
+ )
838
+ break
839
+ return templates.TemplateResponse(
840
+ request,
841
+ "catalog_detail.html",
842
+ {
843
+ "version": pixie.__version__,
844
+ "entry": entry,
845
+ "fetch_state": (request.app.state.fetch_states.get(entry.name) or {}),
846
+ "netboot_sibling": forward,
847
+ "disk_image_users": backward,
848
+ "export": exp,
849
+ "warn_delete": bool(request.query_params.get("warn_delete")),
850
+ "warn_delete_blob": bool(request.query_params.get("warn_delete_blob")),
851
+ "warn_update": bool(request.query_params.get("warn_update")),
852
+ "orphans_bundle_name": (
853
+ forward.name
854
+ if (forward is not None and entry.bindable and forward.fetched)
855
+ else None
856
+ ),
857
+ "breaks_nbdboot_for": [e.name for e in backward] if not entry.bindable else [],
858
+ "blob_using_machines": [
859
+ m.mac
860
+ for m in request.app.state.machines_store.list()
861
+ if entry.content_sha256 and m.image_content_sha256 == entry.content_sha256
862
+ ],
863
+ "blob_running_exports": [
864
+ e.name
865
+ for e in request.app.state.exports_store.list()
866
+ if entry.content_sha256
867
+ and e.content_sha256 == entry.content_sha256
868
+ and e.status == "running"
869
+ ],
870
+ "authed": True,
871
+ "page": "catalog",
872
+ },
873
+ )
874
+
875
+ @app.get("/ui/exports")
876
+ def ui_exports_redirect() -> RedirectResponse:
877
+ """Exports merged into the Catalog view. Keep the URL alive
878
+ as a permanent redirect so any operator bookmarks and any
879
+ older docs still land on the right place."""
880
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_308_PERMANENT_REDIRECT)
881
+
882
+ @app.post("/ui/exports/delete")
883
+ def ui_exports_delete(
884
+ request: Request,
885
+ name: str = Form(...),
886
+ _auth: None = Depends(_require_ui_auth),
887
+ ) -> RedirectResponse:
888
+ request.app.state.nbd_server.terminate(name)
889
+ request.app.state.exports_store.delete(name)
890
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
891
+
892
+ @app.get("/ui/machines", response_class=HTMLResponse)
893
+ def ui_machines(
894
+ request: Request,
895
+ q: str = "",
896
+ page: int = 1,
897
+ per_page: int = DEFAULT_PER_PAGE,
898
+ sort: str = "",
899
+ dir: str = "",
900
+ _auth: None = Depends(_require_ui_auth),
901
+ ) -> HTMLResponse:
902
+ from pixie.web._table_state import (
903
+ filter_rows,
904
+ parse_pagination,
905
+ parse_sort,
906
+ sort_rows,
907
+ )
908
+
909
+ all_machines = request.app.state.machines_store.list()
910
+ filtered = filter_rows(
911
+ all_machines,
912
+ q,
913
+ fields=(
914
+ "mac",
915
+ "boot_mode",
916
+ "image_content_sha256",
917
+ "last_seen_ip",
918
+ "labels",
919
+ "target_disk_serial",
920
+ ),
921
+ )
922
+ sort_state = parse_sort(
923
+ dict(request.query_params),
924
+ allowed={
925
+ "mac": "mac",
926
+ "boot_mode": "boot_mode",
927
+ "last_seen_at": "last_seen_at",
928
+ "last_seen_ip": "last_seen_ip",
929
+ "discovered_at": "discovered_at",
930
+ },
931
+ default_column="last_seen_at",
932
+ default_direction="desc",
933
+ )
934
+ filtered = sort_rows(filtered, sort_state)
935
+ page_state = parse_pagination(dict(request.query_params), total=len(filtered))
936
+ page_machines = filtered[page_state.offset : page_state.offset + page_state.per_page]
937
+ preserved = {
938
+ k: v
939
+ for k, v in {
940
+ "q": q,
941
+ "sort": sort_state.column if sort_state.column != "last_seen_at" else "",
942
+ "dir": sort_state.direction if sort_state.direction != "desc" else "",
943
+ "per_page": str(page_state.per_page)
944
+ if page_state.per_page != DEFAULT_PER_PAGE
945
+ else "",
946
+ }.items()
947
+ if v
948
+ }
949
+ # Most recent machine-scoped events (bound / discovered /
950
+ # inventory / status). Rendered under the machine table with
951
+ # a link to /ui/events for the full log; the per-machine
952
+ # detail page already does the same but filtered on that one
953
+ # MAC.
954
+ machine_events = _recent_events_for(request.app.state.events_log, "machine")
955
+ return templates.TemplateResponse(
956
+ request,
957
+ "machines.html",
958
+ {
959
+ "version": pixie.__version__,
960
+ "machines": page_machines,
961
+ "q": q,
962
+ "sort": sort_state,
963
+ "page_state": page_state,
964
+ "preserved": preserved,
965
+ "machine_events": machine_events,
966
+ "authed": True,
967
+ "page": "machines",
968
+ },
969
+ )
970
+
971
+ @app.get("/ui/machines/{mac}", response_model=None)
972
+ def ui_machine_detail(
973
+ request: Request,
974
+ mac: str,
975
+ _auth: None = Depends(_require_ui_auth),
976
+ ) -> HTMLResponse | RedirectResponse:
977
+ """Per-machine detail: telemetry + boot-mode binding form +
978
+ the inventory blob pixie stored on the row (from
979
+ ``POST /pxe/<mac>/inventory``, driven by the live env's
980
+ pixie CLI). Falls through to /ui/machines on a bad MAC or a
981
+ row that doesn't exist yet."""
982
+ from pixie.machines._store import BOOT_MODE_META, BadMac
983
+
984
+ try:
985
+ machine = request.app.state.machines_store.get(mac)
986
+ except BadMac:
987
+ return RedirectResponse(url="/ui/machines", status_code=status.HTTP_303_SEE_OTHER)
988
+ if machine is None:
989
+ return RedirectResponse(url="/ui/machines", status_code=status.HTTP_303_SEE_OTHER)
990
+ events = request.app.state.events_log.list(
991
+ subject_kind="machine",
992
+ subject_id=machine.mac,
993
+ limit=25,
994
+ )
995
+ # Bindable entries: fetched disk images (bindable=True on the
996
+ # catalog schema means it has a content_sha256 an operator can
997
+ # point a machine at). Netboot-bundle rows are excluded --
998
+ # they are pointed at by the sibling disk-image row's
999
+ # ``netboot_src``, not bound directly.
1000
+ bindable_entries = [
1001
+ e
1002
+ for e in request.app.state.catalog_store.list_entries()
1003
+ if getattr(e, "bindable", False)
1004
+ ]
1005
+ return templates.TemplateResponse(
1006
+ request,
1007
+ "machine_detail.html",
1008
+ {
1009
+ "version": pixie.__version__,
1010
+ "machine": machine,
1011
+ "events": events,
1012
+ "bindable_entries": bindable_entries,
1013
+ "boot_mode_meta": BOOT_MODE_META,
1014
+ "global_live_env_extra_cmdline": (
1015
+ request.app.state.settings_store.resolve_live_env_extra_cmdline()
1016
+ ),
1017
+ "authed": True,
1018
+ "page": "machines",
1019
+ },
1020
+ )
1021
+
1022
+ @app.post("/ui/machines/bind")
1023
+ def ui_machines_bind(
1024
+ request: Request,
1025
+ mac: str = Form(...),
1026
+ boot_mode: str = Form(...),
1027
+ image_content_sha256: str = Form(""),
1028
+ target_disk_serial: str = Form(""),
1029
+ extra_cmdline: str = Form(""),
1030
+ _auth: None = Depends(_require_ui_auth),
1031
+ ) -> RedirectResponse:
1032
+ """Persist a boot-mode binding. Labels are edited on their
1033
+ own row (see ``/ui/machines/{mac}/labels/edit``) so a bind
1034
+ does not risk clobbering the operator's tagging.
1035
+
1036
+ UI-side: silently redirect back on invalid input; a full
1037
+ field-error flash chain lands in a follow-up. Labels on
1038
+ the row survive the bind untouched via ``upsert_binding``
1039
+ pulling them off the current row."""
1040
+ import contextlib as _contextlib
1041
+
1042
+ from pixie.machines._store import BadMac
1043
+
1044
+ with _contextlib.suppress(BadMac, ValueError):
1045
+ store = request.app.state.machines_store
1046
+ current = store.get(mac)
1047
+ existing_labels = list(current.labels) if current else []
1048
+ store.upsert_binding(
1049
+ mac,
1050
+ boot_mode=boot_mode,
1051
+ image_content_sha256=image_content_sha256.strip().lower(),
1052
+ labels=existing_labels,
1053
+ target_disk_serial=target_disk_serial,
1054
+ extra_cmdline=extra_cmdline,
1055
+ )
1056
+ return RedirectResponse(url="/ui/machines", status_code=status.HTTP_303_SEE_OTHER)
1057
+
1058
+ @app.post("/ui/machines/{mac}/labels/edit")
1059
+ def ui_machines_labels_edit(
1060
+ request: Request,
1061
+ mac: str,
1062
+ labels: str = Form(""),
1063
+ _auth: None = Depends(_require_ui_auth),
1064
+ ) -> RedirectResponse:
1065
+ """Edit the labels on one machine row. Independent of the
1066
+ bind form: labels are metadata (search, filter, grouping)
1067
+ and have no effect on the plan render, so a label edit does
1068
+ not need a boot_mode + image + target_disk_serial round-trip.
1069
+
1070
+ Blank clears the labels. ``parse_labels`` enforces the same
1071
+ alphanumeric-leading shape the JSON PUT path does; a bad
1072
+ label surfaces as 400 (previously silently swallowed, which
1073
+ made "why did my label edit not take" a debug ratdance)."""
1074
+ from pixie.machines._store import BadMac, parse_labels
1075
+
1076
+ try:
1077
+ parsed = parse_labels(labels)
1078
+ request.app.state.machines_store.set_labels(mac, parsed)
1079
+ except BadMac as exc:
1080
+ raise HTTPException(status_code=400, detail=f"invalid MAC: {exc}") from exc
1081
+ except ValueError as exc:
1082
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
1083
+ return RedirectResponse(url=f"/ui/machines/{mac}", status_code=status.HTTP_303_SEE_OTHER)
1084
+
1085
+ @app.post("/ui/machines/delete")
1086
+ def ui_machines_delete(
1087
+ request: Request,
1088
+ mac: str = Form(...),
1089
+ _auth: None = Depends(_require_ui_auth),
1090
+ ) -> RedirectResponse:
1091
+ request.app.state.machines_store.delete(mac)
1092
+ return RedirectResponse(url="/ui/machines", status_code=status.HTTP_303_SEE_OTHER)
1093
+
1094
+ @app.get("/ui/events", response_class=HTMLResponse)
1095
+ def ui_events(
1096
+ request: Request,
1097
+ q: str = "",
1098
+ kind: str = "",
1099
+ subject_kind: str = "",
1100
+ page: int = 1,
1101
+ per_page: int = DEFAULT_PER_PAGE,
1102
+ sort: str = "",
1103
+ dir: str = "",
1104
+ _auth: None = Depends(_require_ui_auth),
1105
+ ) -> HTMLResponse:
1106
+ from pixie.events._kinds import KNOWN_EVENT_KINDS
1107
+ from pixie.web._table_state import (
1108
+ filter_rows,
1109
+ parse_pagination,
1110
+ parse_sort,
1111
+ sort_rows,
1112
+ )
1113
+
1114
+ all_events = request.app.state.events_log.list(limit=2000)
1115
+ # Kind + subject_kind dropdowns are strict-equality filters
1116
+ # applied BEFORE the freeform ``q`` search, so a "delete all
1117
+ # entries" query narrowed to ``kind=catalog.entry.deleted`` doesn't
1118
+ # also drag in the ``catalog.import.ok`` rows whose summary
1119
+ # mentions "delete". Values are allowlisted against the event
1120
+ # kind registry + observed subject_kinds so an operator can't
1121
+ # accidentally hit the page with a bogus ``?kind=nope`` value
1122
+ # and see "0 of 0" without knowing why.
1123
+ kind_choices = sorted(KNOWN_EVENT_KINDS)
1124
+ subject_kind_choices = sorted({e.subject_kind for e in all_events if e.subject_kind})
1125
+ kind_selected = kind if kind in kind_choices else ""
1126
+ subject_kind_selected = subject_kind if subject_kind in subject_kind_choices else ""
1127
+ if kind_selected:
1128
+ all_events = [e for e in all_events if e.kind == kind_selected]
1129
+ if subject_kind_selected:
1130
+ all_events = [e for e in all_events if e.subject_kind == subject_kind_selected]
1131
+ filtered = filter_rows(
1132
+ all_events,
1133
+ q,
1134
+ fields=("kind", "subject_kind", "subject_id", "summary", "ts"),
1135
+ )
1136
+ sort_state = parse_sort(
1137
+ dict(request.query_params),
1138
+ allowed={
1139
+ "ts": "ts",
1140
+ "kind": "kind",
1141
+ "subject_id": "subject_id",
1142
+ },
1143
+ default_column="ts",
1144
+ default_direction="desc",
1145
+ )
1146
+ filtered = sort_rows(filtered, sort_state)
1147
+ page_state = parse_pagination(dict(request.query_params), total=len(filtered))
1148
+ page_events = filtered[page_state.offset : page_state.offset + page_state.per_page]
1149
+ preserved = {
1150
+ k: v
1151
+ for k, v in {
1152
+ "q": q,
1153
+ "kind": kind_selected,
1154
+ "subject_kind": subject_kind_selected,
1155
+ "sort": sort_state.column if sort_state.column != "ts" else "",
1156
+ "dir": sort_state.direction if sort_state.direction != "desc" else "",
1157
+ "per_page": str(page_state.per_page)
1158
+ if page_state.per_page != DEFAULT_PER_PAGE
1159
+ else "",
1160
+ }.items()
1161
+ if v
1162
+ }
1163
+ return templates.TemplateResponse(
1164
+ request,
1165
+ "events.html",
1166
+ {
1167
+ "version": pixie.__version__,
1168
+ "events": page_events,
1169
+ "q": q,
1170
+ "kind_choices": kind_choices,
1171
+ "kind_selected": kind_selected,
1172
+ "subject_kind_choices": subject_kind_choices,
1173
+ "subject_kind_selected": subject_kind_selected,
1174
+ "sort": sort_state,
1175
+ "page_state": page_state,
1176
+ "preserved": preserved,
1177
+ "authed": True,
1178
+ "page": "events",
1179
+ },
1180
+ )
1181
+
1182
+ # ---------- ping under session-auth ------------------------------
1183
+
1184
+ @app.get("/api/ping")
1185
+ def api_ping(
1186
+ _auth: None = Depends(require_auth),
1187
+ ) -> dict[str, Any]:
1188
+ return {"pong": True, "version": pixie.__version__}
1189
+
1190
+ # ---------- dashboard + events live refresh ---------------------
1191
+ #
1192
+ # Two more polling endpoints in the same shape as fetch-states +
1193
+ # machines-live. The dashboard cards + recent-events table poll
1194
+ # these so an operator watching a target fetch complete or a
1195
+ # binding change sees the count tick without a page reload.
1196
+
1197
+ def _dashboard_stats(request: Request) -> dict[str, Any]:
1198
+ """Same shape as ``ui_dashboard`` computes; factored so the
1199
+ JSON endpoint + the HTML render share the calculation."""
1200
+ from pixie.exports._routes import _refresh_row
1201
+
1202
+ catalog = request.app.state.catalog_store
1203
+ exports_store = request.app.state.exports_store
1204
+ machines_store = request.app.state.machines_store
1205
+ events_log = request.app.state.events_log
1206
+ nbd = request.app.state.nbd_server
1207
+ entries = catalog.list_entries()
1208
+ exports = [_refresh_row(e, nbd, exports_store, events_log) for e in exports_store.list()]
1209
+ machines = machines_store.list()
1210
+ images = [e for e in entries if getattr(e, "bindable", False)]
1211
+ bundles = [e for e in entries if not getattr(e, "bindable", False)]
1212
+ # Live-env media state. ``pixie-tui`` / ``pixie-inventory`` /
1213
+ # ``pixie-flash-*`` all need vmlinuz + initrd + live.squashfs
1214
+ # staged under ``PIXIE_LIVE_ENV_DIR`` (defaults to
1215
+ # ``<state>/live-env``). Surface the staged / missing signal
1216
+ # + per-file byte counts so the operator can tell at a glance
1217
+ # whether the pixie boot modes are live or would fall back to
1218
+ # the "unavailable" plan.
1219
+ live_env_dir = request.app.state.live_env_dir
1220
+ live_env_ready = False
1221
+ live_env_files: dict[str, int | None] = {
1222
+ "vmlinuz": None,
1223
+ "initrd": None,
1224
+ "live.squashfs": None,
1225
+ }
1226
+ if live_env_dir is not None:
1227
+ for name in live_env_files:
1228
+ p = live_env_dir / name
1229
+ try:
1230
+ live_env_files[name] = p.stat().st_size if p.is_file() else None
1231
+ except OSError:
1232
+ live_env_files[name] = None
1233
+ live_env_ready = all(v is not None for v in live_env_files.values())
1234
+ return {
1235
+ "machines_total": len(machines),
1236
+ "machines_bound": sum(1 for m in machines if m.image_content_sha256),
1237
+ "machines_with_inventory": sum(1 for m in machines if m.inventory),
1238
+ "catalog_total": len(entries),
1239
+ "catalog_fetched": sum(1 for e in entries if getattr(e, "content_sha256", "")),
1240
+ "catalog_images_total": len(images),
1241
+ "catalog_images_fetched": sum(1 for e in images if e.content_sha256),
1242
+ "catalog_bundles_total": len(bundles),
1243
+ "catalog_bundles_fetched": sum(1 for e in bundles if e.content_sha256),
1244
+ "exports_total": len(exports),
1245
+ "exports_running": sum(1 for e in exports if e.status == "running"),
1246
+ "exports_error": sum(1 for e in exports if e.status == "error"),
1247
+ "live_env_ready": live_env_ready,
1248
+ "live_env_dir": str(live_env_dir) if live_env_dir is not None else "",
1249
+ "live_env_files": live_env_files,
1250
+ }
1251
+
1252
+ @app.get("/ui/dashboard-live.json")
1253
+ def ui_dashboard_live(
1254
+ request: Request,
1255
+ _auth: None = Depends(_require_ui_auth),
1256
+ ) -> JSONResponse:
1257
+ return JSONResponse(_dashboard_stats(request))
1258
+
1259
+ @app.get("/ui/events-live.json")
1260
+ def ui_events_live(
1261
+ request: Request,
1262
+ since_ts: str = "",
1263
+ limit: int = 25,
1264
+ _auth: None = Depends(_require_ui_auth),
1265
+ ) -> JSONResponse:
1266
+ """Return the N most recent events. Optional ``since_ts``
1267
+ (a raw ISO string the caller got from a previous poll) trims
1268
+ to rows strictly newer than that stamp so the JS can insert
1269
+ just the new rows into the log. The clamp on ``limit``
1270
+ protects the endpoint against a runaway ``?limit=99999``."""
1271
+ limit = max(1, min(limit, 200))
1272
+ settings_store: SettingsStore = request.app.state.settings_store
1273
+ events = request.app.state.events_log.list(limit=limit)
1274
+ out: list[dict[str, Any]] = []
1275
+ for e in events:
1276
+ if since_ts and e.ts <= since_ts:
1277
+ continue
1278
+ out.append(
1279
+ {
1280
+ "ts": e.ts,
1281
+ "ts_display": format_ts(e.ts, settings_store),
1282
+ "kind": e.kind,
1283
+ "subject_kind": e.subject_kind,
1284
+ "subject_id": e.subject_id,
1285
+ "summary": e.summary or "",
1286
+ }
1287
+ )
1288
+ return JSONResponse({"events": out})
1289
+
1290
+ # ---------- live fetch progress ---------------------------------
1291
+ #
1292
+ # Small JSON echo of ``app.state.fetch_states``. The catalog page
1293
+ # polls this while any row is in flight so the operator sees
1294
+ # ``downloading 42 / 512 MiB`` -> ``decompressing`` -> ``unpacking``
1295
+ # -> ``done`` without a full page reload. Auth-required because
1296
+ # the payload names catalog entries; not sensitive by itself but
1297
+ # part of the admin-only surface.
1298
+
1299
+ @app.get("/ui/fetch-states.json")
1300
+ def ui_catalog_fetch_states(
1301
+ request: Request,
1302
+ _auth: None = Depends(_require_ui_auth),
1303
+ ) -> JSONResponse:
1304
+ return JSONResponse(dict(request.app.state.fetch_states))
1305
+
1306
+ # ---------- machines live refresh --------------------------------
1307
+ #
1308
+ # Compact JSON of the operator-visible per-machine fields the
1309
+ # list + detail templates render live. The machines page + detail
1310
+ # page poll this so a target booting into nbdboot updates
1311
+ # ``last_seen_at`` + ``last_seen_ip`` + inventory-disks count
1312
+ # without a page reload. Keyed by MAC so the JS updates the
1313
+ # matching row in place. Auth-required because the payload names
1314
+ # machines by MAC.
1315
+
1316
+ @app.get("/ui/machines-live.json")
1317
+ def ui_machines_live(
1318
+ request: Request,
1319
+ _auth: None = Depends(_require_ui_auth),
1320
+ ) -> JSONResponse:
1321
+ store: SettingsStore = request.app.state.settings_store
1322
+ out: dict[str, dict[str, Any]] = {}
1323
+ for m in request.app.state.machines_store.list():
1324
+ disks = (m.inventory or {}).get("disks") or []
1325
+ # Pre-formatted timestamps let the JS drop cells into the
1326
+ # DOM verbatim + stay consistent with the server-rendered
1327
+ # fmt_ts filter (same timezone + strftime picks from
1328
+ # Settings). Raw ISO stays alongside in case the browser
1329
+ # ever wants to compute "time since" on the client.
1330
+ out[m.mac] = {
1331
+ "boot_mode": m.boot_mode,
1332
+ "image_content_sha256": m.image_content_sha256,
1333
+ "labels": list(m.labels),
1334
+ "last_seen_at": m.last_seen_at,
1335
+ "last_seen_at_display": format_ts(m.last_seen_at, store),
1336
+ "last_seen_ip": m.last_seen_ip,
1337
+ "inventory_at": m.inventory_at or "",
1338
+ "inventory_at_display": format_ts(m.inventory_at or "", store),
1339
+ "disks_count": len(disks) if isinstance(disks, list) else 0,
1340
+ "has_lshw": bool((m.inventory or {}).get("lshw")),
1341
+ "target_disk_serial": m.target_disk_serial,
1342
+ }
1343
+ return JSONResponse(out)
1344
+
1345
+ # ---------- ui: catalog admin forms ------------------------------
1346
+ #
1347
+ # These forms redirect back to /ui/ so an operator's browser stays
1348
+ # on the dashboard after each mutation. Behaviour mirrors the JSON
1349
+ # /catalog routes but with form-encoded input + 303 redirect.
1350
+
1351
+ from pixie._util import now_iso as _now_iso
1352
+ from pixie.catalog._fetcher import FetchError
1353
+ from pixie.catalog._fetcher import fetch as _fetch
1354
+ from pixie.catalog._schema import CatalogEntry as _Entry
1355
+
1356
+ @app.post("/ui/catalog/add")
1357
+ def ui_catalog_add(
1358
+ request: Request,
1359
+ name: str = Form(...),
1360
+ src: str = Form(...),
1361
+ format: str = Form(...),
1362
+ arch: str = Form(""),
1363
+ description: str = Form(""),
1364
+ netboot_src: str = Form(""),
1365
+ _auth: None = Depends(_require_ui_auth),
1366
+ ) -> RedirectResponse:
1367
+ store = request.app.state.catalog_store
1368
+ if store.get_entry(name):
1369
+ # 303 back to /ui/catalog silently on conflict; UI shows
1370
+ # the row already exists.
1371
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
1372
+ entry = _Entry(
1373
+ name=name.strip(),
1374
+ src=src.strip(),
1375
+ format=format.strip(),
1376
+ arch=arch.strip(),
1377
+ description=description.strip(),
1378
+ netboot_src=netboot_src.strip(),
1379
+ added_at=_now_iso(),
1380
+ )
1381
+ store.upsert(entry)
1382
+ events = getattr(request.app.state, "events_log", None)
1383
+ if events is not None:
1384
+ events.emit(
1385
+ CATALOG_ENTRY_ADDED,
1386
+ subject_kind="entry",
1387
+ subject_id=entry.name,
1388
+ summary=f"{entry.name} ({entry.format})",
1389
+ details={"src": entry.src},
1390
+ )
1391
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
1392
+
1393
+ @app.post("/ui/catalog/fetch")
1394
+ def ui_catalog_fetch(
1395
+ request: Request,
1396
+ name: str = Form(...),
1397
+ force: str = Form(""),
1398
+ _auth: None = Depends(_require_ui_auth),
1399
+ ) -> RedirectResponse:
1400
+ store = request.app.state.catalog_store
1401
+ entry = store.get_entry(name)
1402
+ if entry is None:
1403
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
1404
+
1405
+ # Update-fetch guard. A fetch on an already-fetched entry
1406
+ # ("Update" in the UI) re-runs the pipeline; if the sha
1407
+ # shifts (moved oras:// tag, upstream re-tag) any machine
1408
+ # currently bound to the OLD sha silently rots. Bounce to
1409
+ # the detail page with warn_update=1 unless the operator
1410
+ # explicitly opts in via force=1 (from the banner).
1411
+ # A pristine fetch (no content_sha256 yet) skips the guard --
1412
+ # the entry is not in use so there is nothing to warn about.
1413
+ if entry.content_sha256 and not force:
1414
+ using_machines = [
1415
+ m.mac
1416
+ for m in request.app.state.machines_store.list()
1417
+ if m.image_content_sha256 == entry.content_sha256
1418
+ ]
1419
+ running_exports = [
1420
+ e.name
1421
+ for e in request.app.state.exports_store.list()
1422
+ if e.content_sha256 == entry.content_sha256 and e.status == "running"
1423
+ ]
1424
+ if using_machines or running_exports:
1425
+ return RedirectResponse(
1426
+ url=f"/ui/catalog/{name}?warn_update=1",
1427
+ status_code=status.HTTP_303_SEE_OTHER,
1428
+ )
1429
+
1430
+ states = request.app.state.fetch_states
1431
+ if states.get(name, {}).get("state") == "fetching":
1432
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
1433
+ is_update = bool(entry.content_sha256)
1434
+ events = getattr(request.app.state, "events_log", None)
1435
+
1436
+ # Unchanged fast path: if this is an Update click on an
1437
+ # entry that IS already fetched AND its blob (or unpacked
1438
+ # artifact set) is still on disk, the fetcher would take
1439
+ # its own fast path and return immediately -- the operator
1440
+ # sees no visible change, only a millisecond flicker on
1441
+ # the state pill. Detect that here and record a short-lived
1442
+ # "unchanged" acknowledgement instead so the row shows
1443
+ # "already at latest" for ~8 s and the events log carries
1444
+ # the operator's click as a catalog.fetch.unchanged event.
1445
+ if is_update and _fetch_would_be_noop(entry, store):
1446
+ states[name] = {
1447
+ "state": "unchanged",
1448
+ "at_iso": _now_iso(),
1449
+ "content_sha256": entry.content_sha256,
1450
+ }
1451
+ if events is not None:
1452
+ events.emit(
1453
+ CATALOG_FETCH_UNCHANGED,
1454
+ subject_kind="entry",
1455
+ subject_id=name,
1456
+ summary=f"{name}: already at latest (sha {entry.content_sha256[:12]})",
1457
+ details={"content_sha256": entry.content_sha256},
1458
+ )
1459
+
1460
+ # Auto-clear the acknowledgement after a short window so
1461
+ # a page revisit an hour later doesn't still show it. Uses
1462
+ # the fetch pool as the timer host (a real fetch would use
1463
+ # a pool slot too, so budgeting matches).
1464
+ def _clear_unchanged() -> None:
1465
+ import time as _time
1466
+
1467
+ _time.sleep(8.0)
1468
+ cur = states.get(name)
1469
+ if cur is not None and cur.get("state") == "unchanged":
1470
+ states.pop(name, None)
1471
+
1472
+ request.app.state.fetch_pool.submit(_clear_unchanged)
1473
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
1474
+
1475
+ states[name] = {"state": "fetching", "started_at": _now_iso(), "error": None}
1476
+ if events is not None:
1477
+ events.emit(
1478
+ CATALOG_FETCH_STARTED,
1479
+ subject_kind="entry",
1480
+ subject_id=name,
1481
+ summary=f"{name} <- {entry.src}",
1482
+ details={"src": entry.src, "update": is_update},
1483
+ )
1484
+
1485
+ def _report(payload: dict[str, Any]) -> None:
1486
+ # Merge each phase transition into the row's live state so
1487
+ # the UI polling endpoint (/ui/catalog/fetch-states.json)
1488
+ # sees ``phase`` + ``bytes_downloaded`` / ``total_bytes``
1489
+ # (during downloading) or ``format`` (during
1490
+ # decompressing). We keep ``state=='fetching'`` throughout
1491
+ # so existing "is this row in flight?" checks (the button-
1492
+ # disable in catalog.html, the fresh-fetch guard above)
1493
+ # still fire while the phase spins through its stages.
1494
+ row = states.get(name) or {}
1495
+ merged: dict[str, Any] = {
1496
+ "state": "fetching",
1497
+ "started_at": row.get("started_at"),
1498
+ "error": None,
1499
+ }
1500
+ merged.update(payload)
1501
+ states[name] = merged
1502
+
1503
+ def _run() -> None:
1504
+ try:
1505
+ result = _fetch(entry, store, progress=_report)
1506
+ states[name] = {"state": "done", "started_at": states[name].get("started_at")}
1507
+ if events is not None:
1508
+ events.emit(
1509
+ CATALOG_FETCH_DONE,
1510
+ subject_kind="entry",
1511
+ subject_id=name,
1512
+ summary=(
1513
+ f"{name}: {result.size_bytes} bytes, sha {result.content_sha256[:12]}"
1514
+ ),
1515
+ details={
1516
+ "content_sha256": result.content_sha256,
1517
+ "size_bytes": result.size_bytes,
1518
+ },
1519
+ )
1520
+ if is_update:
1521
+ events.emit(
1522
+ CATALOG_ENTRY_UPDATED,
1523
+ subject_kind="entry",
1524
+ subject_id=name,
1525
+ summary=f"{name}: bytes refreshed",
1526
+ details={"content_sha256": result.content_sha256},
1527
+ )
1528
+ except FetchError as exc:
1529
+ states[name] = {
1530
+ "state": "error",
1531
+ "started_at": states[name].get("started_at"),
1532
+ "error": str(exc),
1533
+ }
1534
+ if events is not None:
1535
+ events.emit(
1536
+ CATALOG_FETCH_FAILED,
1537
+ subject_kind="entry",
1538
+ subject_id=name,
1539
+ summary=str(exc),
1540
+ details={"error": str(exc)[:200]},
1541
+ )
1542
+ except Exception as exc: # pragma: no cover -- defensive
1543
+ states[name] = {
1544
+ "state": "error",
1545
+ "started_at": states[name].get("started_at"),
1546
+ "error": f"internal: {exc}",
1547
+ }
1548
+ if events is not None:
1549
+ events.emit(
1550
+ CATALOG_FETCH_FAILED,
1551
+ subject_kind="entry",
1552
+ subject_id=name,
1553
+ summary=f"internal: {exc}",
1554
+ details={"error": f"internal: {exc}"[:200]},
1555
+ )
1556
+
1557
+ request.app.state.fetch_pool.submit(_run)
1558
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
1559
+
1560
+ @app.post("/ui/catalog/delete")
1561
+ def ui_catalog_delete(
1562
+ request: Request,
1563
+ name: str = Form(...),
1564
+ force: str = Form(""),
1565
+ _auth: None = Depends(_require_ui_auth),
1566
+ ) -> RedirectResponse:
1567
+ """Delete a catalog entry. Relation-aware: when the entry has
1568
+ a sibling that would be left dangling (a disk image that
1569
+ names an already-fetched netboot bundle, or a netboot bundle
1570
+ that is named by any disk image's ``netboot_src``), bounce
1571
+ the operator back to the entry's detail page with a
1572
+ ``warn_delete`` marker so the second click is intentional.
1573
+ A hidden ``force=1`` from that confirmation form skips the
1574
+ bounce and deletes.
1575
+
1576
+ Skipping this check on the JSON API is deliberate: automation
1577
+ callers set force=1 or use the raw ``DELETE /catalog/entries``
1578
+ endpoint that never had a warning path."""
1579
+ store = request.app.state.catalog_store
1580
+ entry = store.get_entry(name)
1581
+ if entry is None:
1582
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
1583
+ if not force:
1584
+ # Compute the relations for this entry so we know whether
1585
+ # deletion would break someone.
1586
+ all_entries = store.list_entries()
1587
+ breaks_nbdboot_for: list[str] = []
1588
+ orphans_bundle: str | None = None
1589
+ if entry.bindable:
1590
+ # Deleting a disk image orphans its sibling bundle
1591
+ # (harmless -- the bundle is still fetched -- but the
1592
+ # operator should know).
1593
+ if entry.netboot_src:
1594
+ sibling = next(
1595
+ (e for e in all_entries if e.src == entry.netboot_src),
1596
+ None,
1597
+ )
1598
+ if sibling is not None and sibling.fetched:
1599
+ orphans_bundle = sibling.name
1600
+ else:
1601
+ # Deleting a bundle breaks nbdboot for every disk
1602
+ # image whose netboot_src pointed at it.
1603
+ if entry.src:
1604
+ breaks_nbdboot_for = [e.name for e in all_entries if e.netboot_src == entry.src]
1605
+ if breaks_nbdboot_for or orphans_bundle:
1606
+ # Bounce with a marker so /ui/catalog/<name> can
1607
+ # render the warning inline.
1608
+ return RedirectResponse(
1609
+ url=f"/ui/catalog/{name}?warn_delete=1",
1610
+ status_code=status.HTTP_303_SEE_OTHER,
1611
+ )
1612
+ store.delete(name)
1613
+ request.app.state.fetch_states.pop(name, None)
1614
+ events = getattr(request.app.state, "events_log", None)
1615
+ if events is not None:
1616
+ events.emit(
1617
+ CATALOG_ENTRY_DELETED,
1618
+ subject_kind="entry",
1619
+ subject_id=name,
1620
+ summary=name,
1621
+ details={"forced": bool(force)},
1622
+ )
1623
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
1624
+
1625
+ @app.post("/ui/catalog/delete-blob")
1626
+ def ui_catalog_delete_blob(
1627
+ request: Request,
1628
+ name: str = Form(...),
1629
+ force: str = Form(""),
1630
+ _auth: None = Depends(_require_ui_auth),
1631
+ ) -> RedirectResponse:
1632
+ """Delete the on-disk BYTES for an entry (blob file + artifact
1633
+ dir if any) while keeping the catalog row. The row's
1634
+ content_sha256 + size + fetched_at are cleared so Fetch runs
1635
+ the full pipeline again next time.
1636
+
1637
+ Relation-aware: if any machine has ``image_content_sha256 ==
1638
+ entry.content_sha256`` (i.e. is bound to nbdboot for this
1639
+ entry) OR a running NBD export serves the blob, bounce to
1640
+ the entry's detail page with ``warn_delete_blob=1`` so the
1641
+ operator can either point the machine at a different image
1642
+ or explicitly confirm the delete via a hidden ``force=1``
1643
+ input on that warning banner."""
1644
+ import shutil
1645
+
1646
+ store = request.app.state.catalog_store
1647
+ entry = store.get_entry(name)
1648
+ if entry is None or not entry.content_sha256:
1649
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
1650
+ sha = entry.content_sha256
1651
+ if not force:
1652
+ using_machines = [
1653
+ m.mac
1654
+ for m in request.app.state.machines_store.list()
1655
+ if m.image_content_sha256 == sha
1656
+ ]
1657
+ running_exports = [
1658
+ e.name
1659
+ for e in request.app.state.exports_store.list()
1660
+ if e.content_sha256 == sha and e.status == "running"
1661
+ ]
1662
+ if using_machines or running_exports:
1663
+ return RedirectResponse(
1664
+ url=f"/ui/catalog/{name}?warn_delete_blob=1",
1665
+ status_code=status.HTTP_303_SEE_OTHER,
1666
+ )
1667
+ # Force path (or unused blob): stop any nbdkit process serving
1668
+ # the blob first so the file handle drops, then remove bytes.
1669
+ for exp in request.app.state.exports_store.list():
1670
+ if exp.content_sha256 == sha:
1671
+ request.app.state.nbd_server.terminate(exp.name)
1672
+ request.app.state.exports_store.delete(exp.name)
1673
+ blob = store.blob_path(sha)
1674
+ with contextlib_suppress(FileNotFoundError, OSError):
1675
+ blob.unlink()
1676
+ # Best-effort remove the enclosing ``<sha>/`` dir when
1677
+ # empty. Content-addressed storage means other entries
1678
+ # could share the same sha; the rmdir call only succeeds
1679
+ # when we're the last reference.
1680
+ with contextlib_suppress(OSError):
1681
+ blob.parent.rmdir()
1682
+ artifact_dir = store.artifact_dir(sha)
1683
+ with contextlib_suppress(FileNotFoundError, OSError):
1684
+ shutil.rmtree(artifact_dir)
1685
+ store.mark_unfetched(name)
1686
+ request.app.state.fetch_states.pop(name, None)
1687
+ log = getattr(request.app.state, "events_log", None)
1688
+ if log is not None:
1689
+ log.emit(
1690
+ CATALOG_BLOB_DELETED,
1691
+ subject_kind="entry",
1692
+ subject_id=name,
1693
+ summary=f"blob deleted for {name} (sha {sha[:12]})",
1694
+ details={"sha": sha, "forced": bool(force)},
1695
+ )
1696
+ return RedirectResponse(url=f"/ui/catalog/{name}", status_code=status.HTTP_303_SEE_OTHER)
1697
+
1698
+ @app.post("/ui/catalog/import")
1699
+ def ui_catalog_import(
1700
+ request: Request,
1701
+ url: str = Form(...),
1702
+ _auth: None = Depends(_require_ui_auth),
1703
+ ) -> RedirectResponse:
1704
+ """Fetch a catalog TOML from the given URL and upsert every
1705
+ entry it declares. Matches the shape bty publishes at
1706
+ ``GET /catalog.toml``: ``version = 1`` + ``[[images]]`` array
1707
+ with ``name``/``src``/``format`` required, ``arch`` +
1708
+ ``netboot_src`` + ``description`` optional. Existing rows are
1709
+ overwritten by name; unfetched rows stay unfetched (import
1710
+ stages entries only, doesn't fetch bytes)."""
1711
+ import httpx
1712
+
1713
+ from pixie.catalog._schema import parse_catalog_toml
1714
+
1715
+ target_url = (url or "").strip()
1716
+ if not target_url:
1717
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
1718
+ try:
1719
+ r = httpx.get(target_url, timeout=15.0, follow_redirects=True)
1720
+ r.raise_for_status()
1721
+ entries = parse_catalog_toml(r.content)
1722
+ except (httpx.HTTPError, ValueError) as exc:
1723
+ log = getattr(request.app.state, "events_log", None)
1724
+ if log is not None:
1725
+ log.emit(
1726
+ CATALOG_IMPORT_FAILED,
1727
+ subject_kind="catalog",
1728
+ subject_id=target_url,
1729
+ summary=f"import from {target_url} failed",
1730
+ details={"error": str(exc)[:200]},
1731
+ )
1732
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
1733
+ store = request.app.state.catalog_store
1734
+ added = 0
1735
+ for entry in entries:
1736
+ if not store.get_entry(entry.name):
1737
+ added += 1
1738
+ store.upsert(entry)
1739
+ log = getattr(request.app.state, "events_log", None)
1740
+ if log is not None:
1741
+ log.emit(
1742
+ CATALOG_IMPORT_OK,
1743
+ subject_kind="catalog",
1744
+ subject_id=target_url,
1745
+ summary=f"imported {len(entries)} entries from {target_url} ({added} new)",
1746
+ details={"url": target_url, "count": len(entries), "new": added},
1747
+ )
1748
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
1749
+
1750
+ # ---------- settings pane ---------------------------------------
1751
+
1752
+ def _settings_context(request: Request, flash_error: str | None = None) -> dict[str, Any]:
1753
+ """Build the render context for /ui/settings. Each row exposes
1754
+ the effective value (what pixie will use), the stored override
1755
+ (blank when unset), and the source bucket (override / env /
1756
+ default) so the operator sees the provenance chain at a
1757
+ glance."""
1758
+ store: SettingsStore = request.app.state.settings_store
1759
+ tz_override = store.get(KEY_DISPLAY_TZ) or ""
1760
+ try:
1761
+ tz_effective = str(store.resolve_display_timezone())
1762
+ except SettingValueError as exc:
1763
+ tz_effective = f"(invalid: {exc})"
1764
+ fmt_override = store.get(KEY_DATETIME_FORMAT) or ""
1765
+ fmt_effective = store.resolve_datetime_format()
1766
+ cmdline_override = store.get(KEY_LIVE_ENV_EXTRA_CMDLINE) or ""
1767
+ cmdline_effective = store.resolve_live_env_extra_cmdline()
1768
+ deployment_envvars = _deployment_envvar_docs()
1769
+ deployment_state = _deployment_state()
1770
+ return {
1771
+ "version": pixie.__version__,
1772
+ "authed": True,
1773
+ "page": "settings",
1774
+ "deployment_envvars": deployment_envvars,
1775
+ "deployment": deployment_state,
1776
+ "display_tz": {
1777
+ "override": tz_override,
1778
+ "effective": tz_effective,
1779
+ "default": "UTC",
1780
+ "env": "PIXIE_DISPLAY_TZ",
1781
+ "updated_at": store.updated_at(KEY_DISPLAY_TZ) or "",
1782
+ },
1783
+ "datetime_format": {
1784
+ "override": fmt_override,
1785
+ "effective": fmt_effective,
1786
+ "default": "%Y-%m-%d %H:%M:%S %Z",
1787
+ "env": "PIXIE_DATETIME_FORMAT",
1788
+ "updated_at": store.updated_at(KEY_DATETIME_FORMAT) or "",
1789
+ },
1790
+ "live_env_extra_cmdline": {
1791
+ "override": cmdline_override,
1792
+ "effective": cmdline_effective,
1793
+ "default": "",
1794
+ "env": "PIXIE_LIVE_ENV_EXTRA_CMDLINE",
1795
+ "updated_at": store.updated_at(KEY_LIVE_ENV_EXTRA_CMDLINE) or "",
1796
+ },
1797
+ "flash_error": flash_error,
1798
+ }
1799
+
1800
+ @app.get("/ui/settings", response_class=HTMLResponse)
1801
+ def ui_settings(
1802
+ request: Request,
1803
+ _auth: None = Depends(_require_ui_auth),
1804
+ ) -> HTMLResponse:
1805
+ return templates.TemplateResponse(request, "settings.html", _settings_context(request))
1806
+
1807
+ @app.post("/ui/settings/display/edit", response_model=None)
1808
+ def ui_settings_display_edit(
1809
+ request: Request,
1810
+ timezone: str = Form(""),
1811
+ datetime_format: str = Form(""),
1812
+ _auth: None = Depends(_require_ui_auth),
1813
+ ) -> HTMLResponse | RedirectResponse:
1814
+ """Persist the two Display settings. Blank inputs CLEAR the
1815
+ override so the value falls back to env / default. Both fields
1816
+ are validated BEFORE any write so a bad tz + a good format
1817
+ don't leave the DB in a half-updated state."""
1818
+ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
1819
+
1820
+ store: SettingsStore = request.app.state.settings_store
1821
+ tz_raw = (timezone or "").strip()
1822
+ fmt_raw = (datetime_format or "").strip()
1823
+ # Validate tz + fmt up-front so a bad value on either side
1824
+ # rejects the whole submit rather than partially applying.
1825
+ if tz_raw:
1826
+ try:
1827
+ ZoneInfo(tz_raw)
1828
+ except ZoneInfoNotFoundError:
1829
+ return templates.TemplateResponse(
1830
+ request,
1831
+ "settings.html",
1832
+ _settings_context(
1833
+ request,
1834
+ flash_error=f"'{tz_raw}' is not a known IANA timezone.",
1835
+ ),
1836
+ status_code=400,
1837
+ )
1838
+ if fmt_raw:
1839
+ try:
1840
+ datetime.now(UTC).strftime(fmt_raw)
1841
+ except ValueError as exc:
1842
+ return templates.TemplateResponse(
1843
+ request,
1844
+ "settings.html",
1845
+ _settings_context(
1846
+ request,
1847
+ flash_error=f"invalid datetime format: {exc}",
1848
+ ),
1849
+ status_code=400,
1850
+ )
1851
+ if tz_raw:
1852
+ store.set_value(KEY_DISPLAY_TZ, tz_raw)
1853
+ else:
1854
+ store.clear(KEY_DISPLAY_TZ)
1855
+ if fmt_raw:
1856
+ store.set_value(KEY_DATETIME_FORMAT, fmt_raw)
1857
+ else:
1858
+ store.clear(KEY_DATETIME_FORMAT)
1859
+ return RedirectResponse(url="/ui/settings", status_code=status.HTTP_303_SEE_OTHER)
1860
+
1861
+ @app.post("/ui/settings/live-env/edit", response_model=None)
1862
+ def ui_settings_live_env_edit(
1863
+ request: Request,
1864
+ extra_cmdline: str = Form(""),
1865
+ _auth: None = Depends(_require_ui_auth),
1866
+ ) -> HTMLResponse | RedirectResponse:
1867
+ """Persist the live-env extra cmdline. Blank clears the
1868
+ override so the value falls back to $PIXIE_LIVE_ENV_EXTRA_CMDLINE
1869
+ then empty. Rejects any newline in the input -- the tokens go
1870
+ onto a single-line iPXE ``kernel`` directive and a newline
1871
+ would truncate the render before the ``initrd``/``boot`` lines
1872
+ that follow."""
1873
+ store: SettingsStore = request.app.state.settings_store
1874
+ raw = (extra_cmdline or "").strip()
1875
+ if "\n" in raw or "\r" in raw:
1876
+ return templates.TemplateResponse(
1877
+ request,
1878
+ "settings.html",
1879
+ _settings_context(
1880
+ request,
1881
+ flash_error=(
1882
+ "Live-env extra cmdline must be a single line "
1883
+ "(newlines truncate the iPXE render)."
1884
+ ),
1885
+ ),
1886
+ status_code=400,
1887
+ )
1888
+ if raw:
1889
+ store.set_value(KEY_LIVE_ENV_EXTRA_CMDLINE, raw)
1890
+ else:
1891
+ store.clear(KEY_LIVE_ENV_EXTRA_CMDLINE)
1892
+ return RedirectResponse(url="/ui/settings", status_code=status.HTTP_303_SEE_OTHER)
1893
+
1894
+ # ---------- feature routers --------------------------------------
1895
+ #
1896
+ # Catalog + blob + artifacts routes live at the same URL shape they
1897
+ # had in the trio (``/catalog``, ``/b/``, ``/artifacts/``) so
1898
+ # operator muscle memory + iPXE templates keep working.
1899
+ app.include_router(catalog_router)
1900
+ app.include_router(exports_router)
1901
+ app.include_router(machines_router)
1902
+ app.include_router(pxe_router)
1903
+ app.include_router(events_router)
1904
+
1905
+ return app
1906
+
1907
+
1908
+ # Module-level app so ``uvicorn pixie.web.main:app`` works without a
1909
+ # factory flag.
1910
+ app = create_app()