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/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()
|