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
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Deploy artifact templates.
|
|
2
|
+
|
|
3
|
+
Everything the operator ever reads on a fresh deploy: compose.yml +
|
|
4
|
+
envvars.example + README.md. Kept as Python strings (not Jinja files)
|
|
5
|
+
because the substitutions are trivial and shipping a second templating
|
|
6
|
+
layer for three files reads as ceremony.
|
|
7
|
+
|
|
8
|
+
The compose file emits ONE service on ``--network=host``. The image
|
|
9
|
+
tag is baked in at generation time so ``pixie-lab init`` on a v0.4.0
|
|
10
|
+
CLI pins the container at ``ghcr.io/safl/pixie:0.4.0``; the operator
|
|
11
|
+
can bump manually to track ``:latest`` if they want to.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
DEFAULT_IMAGE_REPO = "ghcr.io/safl/pixie"
|
|
19
|
+
DEFAULT_ADMIN_PASSWORD = "pixie"
|
|
20
|
+
|
|
21
|
+
# NBD supervisor's default port range base -- kept in lockstep with
|
|
22
|
+
# ``pixie.exports._supervisor.DEFAULT_PORT_BASE``.
|
|
23
|
+
DEFAULT_NBD_PORT_BASE = 10809
|
|
24
|
+
DEFAULT_HTTP_PORT = 8080
|
|
25
|
+
|
|
26
|
+
COMPOSE_YAML = """\
|
|
27
|
+
# pixie compose deployment. One service, one HTTP port + NBD range,
|
|
28
|
+
# session-auth on write routes gated by ``PIXIE_ADMIN_PASSWORD``.
|
|
29
|
+
#
|
|
30
|
+
# Bring up with:
|
|
31
|
+
# cp envvars.example envvars
|
|
32
|
+
# "${{EDITOR:-vi}}" envvars # set PIXIE_HOST_ADDR + PIXIE_ADMIN_PASSWORD
|
|
33
|
+
# COMPOSE_ENV_FILES=envvars podman compose up -d
|
|
34
|
+
#
|
|
35
|
+
# ``--network=host`` is the whole point of pixie's one-container
|
|
36
|
+
# shape: udp/69 (once the in-process TFTP router lands), tcp/8080
|
|
37
|
+
# (HTTP), and the NBD port range all reach the LAN without any
|
|
38
|
+
# port-publish hoops. Operators fronting pixie behind a reverse proxy
|
|
39
|
+
# can drop network_mode: host and publish 8080 explicitly.
|
|
40
|
+
services:
|
|
41
|
+
pixie:
|
|
42
|
+
image: {image}
|
|
43
|
+
container_name: pixie
|
|
44
|
+
restart: unless-stopped
|
|
45
|
+
network_mode: host
|
|
46
|
+
environment:
|
|
47
|
+
PIXIE_ADMIN_PASSWORD: ${{PIXIE_ADMIN_PASSWORD:-{admin_password}}}
|
|
48
|
+
PIXIE_PUBLIC_HOST: ${{PIXIE_HOST_ADDR:-127.0.0.1}}
|
|
49
|
+
PIXIE_NBD_PUBLIC_HOST: ${{PIXIE_HOST_ADDR:-127.0.0.1}}
|
|
50
|
+
# Optional per-target kernel workarounds appended to the
|
|
51
|
+
# pixie-live-env cmdline. Empty by default. Known-good values
|
|
52
|
+
# for specific hardware live in the src/pixie/pxe/_routes.py
|
|
53
|
+
# docstring; set here if any of your PXE targets need one.
|
|
54
|
+
# Example (GIGABYTE MC12-LE0): pci=realloc=on,nocrs
|
|
55
|
+
PIXIE_LIVE_ENV_EXTRA_CMDLINE: ${{PIXIE_LIVE_ENV_EXTRA_CMDLINE:-}}
|
|
56
|
+
volumes:
|
|
57
|
+
- ./data:/var/lib/pixie:Z
|
|
58
|
+
# Container HEALTHCHECK is baked in the Containerfile; compose
|
|
59
|
+
# picks it up automatically.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
ENVVARS_EXAMPLE = """\
|
|
63
|
+
# Fill this in and rename to ``envvars``. Both fields below are
|
|
64
|
+
# REQUIRED for a functional deploy.
|
|
65
|
+
#
|
|
66
|
+
# COMPOSE_ENV_FILES=envvars podman compose up -d
|
|
67
|
+
#
|
|
68
|
+
# PIXIE_HOST_ADDR is the LAN address pixie tells iPXE targets to
|
|
69
|
+
# use. Set it to the address ON YOUR LAN this box answers on; if
|
|
70
|
+
# left unset the plans point at 127.0.0.1 and a target that boots
|
|
71
|
+
# the plan can't reach pixie's HTTP / NBD.
|
|
72
|
+
PIXIE_HOST_ADDR=
|
|
73
|
+
|
|
74
|
+
# Session-auth password for the operator UI + write routes.
|
|
75
|
+
# Default is public knowledge; set your own.
|
|
76
|
+
PIXIE_ADMIN_PASSWORD={admin_password}
|
|
77
|
+
|
|
78
|
+
# Per-target kernel workarounds appended to the pixie-live-env
|
|
79
|
+
# cmdline. Empty by default; set on a target-by-target basis.
|
|
80
|
+
#
|
|
81
|
+
# Known-good values:
|
|
82
|
+
# GIGABYTE MC12-LE0 (Ryzen server board):
|
|
83
|
+
# PIXIE_LIVE_ENV_EXTRA_CMDLINE=pci=realloc=on,nocrs
|
|
84
|
+
# That board's BIOS has a ROM BAR overlap defect that leaves no
|
|
85
|
+
# MMIO space for the Intel i210 NICs; without the workaround the
|
|
86
|
+
# live env boots with no working network, live-boot never fetches
|
|
87
|
+
# the squashfs, and boot hangs silently.
|
|
88
|
+
PIXIE_LIVE_ENV_EXTRA_CMDLINE=
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
README_MD = """\
|
|
92
|
+
# pixie deploy
|
|
93
|
+
|
|
94
|
+
Generated by ``pixie-lab init``.
|
|
95
|
+
|
|
96
|
+
## Layout
|
|
97
|
+
|
|
98
|
+
* ``compose.yml`` -- compose stack. One service on ``--network=host``.
|
|
99
|
+
* ``envvars.example`` -- environment overrides. Copy to ``envvars`` and edit.
|
|
100
|
+
* ``data/`` -- persistent state (blobs + artifacts + state.db).
|
|
101
|
+
|
|
102
|
+
## Bring up
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
cp envvars.example envvars
|
|
106
|
+
# Set PIXIE_HOST_ADDR to the LAN address this box answers on.
|
|
107
|
+
# Set PIXIE_ADMIN_PASSWORD to something other than the default.
|
|
108
|
+
"${{EDITOR:-vi}}" envvars
|
|
109
|
+
|
|
110
|
+
COMPOSE_ENV_FILES=envvars podman compose up -d
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
The container's HTTP server binds ``127.0.0.1:{http_port}`` and, when
|
|
114
|
+
targets bind nbdboot, spawns one nbdkit per export starting at TCP
|
|
115
|
+
``{nbd_port}``. ``pixie-lab deploy`` runs the compose bring-up for
|
|
116
|
+
you, filling in envvars if it's empty.
|
|
117
|
+
|
|
118
|
+
## Tear down
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
podman compose --env-file envvars down -v
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
Or ``pixie-lab purge`` from this directory.
|
|
125
|
+
|
|
126
|
+
## Docs
|
|
127
|
+
|
|
128
|
+
See ``PLAN.md`` at github.com/safl/pixie for the design rationale
|
|
129
|
+
and roadmap.
|
|
130
|
+
"""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def compose_yaml(*, image: str, admin_password: str) -> str:
|
|
134
|
+
return COMPOSE_YAML.format(image=image, admin_password=admin_password)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def envvars_example(*, admin_password: str) -> str:
|
|
138
|
+
return ENVVARS_EXAMPLE.format(admin_password=admin_password)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def readme_md(*, http_port: int = DEFAULT_HTTP_PORT, nbd_port: int = DEFAULT_NBD_PORT_BASE) -> str:
|
|
142
|
+
return README_MD.format(http_port=http_port, nbd_port=nbd_port)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def emitted_files(dest: str, image: str) -> dict[str, Any]:
|
|
146
|
+
"""The three files a fresh ``pixie-lab init`` writes, plus the
|
|
147
|
+
``data/`` scaffold. Returned as a dict so tests can assert
|
|
148
|
+
specific bytes without touching the filesystem."""
|
|
149
|
+
return {
|
|
150
|
+
f"{dest}/compose.yml": compose_yaml(image=image, admin_password=DEFAULT_ADMIN_PASSWORD),
|
|
151
|
+
f"{dest}/envvars.example": envvars_example(admin_password=DEFAULT_ADMIN_PASSWORD),
|
|
152
|
+
f"{dest}/README.md": readme_md(),
|
|
153
|
+
}
|
pixie/disks.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Block-device discovery via ``lsblk``.
|
|
2
|
+
|
|
3
|
+
Pure-data module: returns plain dicts so the result can be JSON-serialised
|
|
4
|
+
or tabulated by ``pixie`` without further translation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
# Columns we ask ``lsblk`` for. NAME and PATH are both requested because
|
|
14
|
+
# loop/ram devices sometimes lack PATH.
|
|
15
|
+
_LSBLK_COLS = "NAME,PATH,SIZE,TYPE,VENDOR,MODEL,SERIAL,RM,RO,MOUNTPOINTS,TRAN"
|
|
16
|
+
|
|
17
|
+
# Top-level types we surface. Partitions are a child of "disk" and are
|
|
18
|
+
# not reported as separate entries in the default output.
|
|
19
|
+
_INTERESTING_TYPES = {"disk"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def list_disks() -> list[dict[str, Any]]:
|
|
23
|
+
"""Return interesting block devices on the local system.
|
|
24
|
+
|
|
25
|
+
Shells out to ``lsblk -J`` and filters to top-level disks (drops
|
|
26
|
+
loop, ram, rom, etc.). Each entry is a plain dict with stable keys.
|
|
27
|
+
"""
|
|
28
|
+
proc = subprocess.run(
|
|
29
|
+
["lsblk", "-J", "-o", _LSBLK_COLS],
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
check=True,
|
|
33
|
+
# Bound the call so a stuck IO subsystem (failing disk
|
|
34
|
+
# responding slowly to udev queries) can't hang ``pixie``
|
|
35
|
+
# indefinitely. 10s is generous; healthy lsblk returns
|
|
36
|
+
# in <100ms on every box I've tested.
|
|
37
|
+
timeout=10,
|
|
38
|
+
)
|
|
39
|
+
try:
|
|
40
|
+
payload = json.loads(proc.stdout)
|
|
41
|
+
except json.JSONDecodeError as exc:
|
|
42
|
+
# lsblk exited 0 but emitted non-JSON. ``check=True`` covers a
|
|
43
|
+
# non-zero exit, but a zero-exit-with-truncated/empty-stdout
|
|
44
|
+
# (seen on cut-down busybox lsblk builds) would otherwise raise
|
|
45
|
+
# an uncaught ``ValueError`` and crash disk selection. Surface
|
|
46
|
+
# it as a SubprocessError so the callers that already guard
|
|
47
|
+
# lsblk failures (the TUI disk picker, the CLI) degrade to "no
|
|
48
|
+
# disks discoverable" instead of tracing back.
|
|
49
|
+
raise subprocess.SubprocessError(f"lsblk returned unparseable JSON: {exc}") from exc
|
|
50
|
+
devices: list[dict[str, Any]] = payload.get("blockdevices", [])
|
|
51
|
+
|
|
52
|
+
out: list[dict[str, Any]] = []
|
|
53
|
+
for d in devices:
|
|
54
|
+
if d.get("type") not in _INTERESTING_TYPES:
|
|
55
|
+
continue
|
|
56
|
+
out.append(
|
|
57
|
+
{
|
|
58
|
+
"path": d.get("path") or f"/dev/{d['name']}",
|
|
59
|
+
"size": d.get("size"),
|
|
60
|
+
"type": d.get("type"),
|
|
61
|
+
"vendor": _strip_or_none(d.get("vendor")),
|
|
62
|
+
"model": _strip_or_none(d.get("model")),
|
|
63
|
+
# Some USB enclosures / vendor-firmware report
|
|
64
|
+
# serials with trailing whitespace; strip for
|
|
65
|
+
# consistency with vendor / model. ``pixie`` in
|
|
66
|
+
# auto-flash mode matches the plan's
|
|
67
|
+
# ``target_disk_serial`` against this value
|
|
68
|
+
# exactly, so the same strip on both ends keeps
|
|
69
|
+
# the gate working when the inventory side and
|
|
70
|
+
# the flash-time side agree on the canonical form.
|
|
71
|
+
"serial": _strip_or_none(d.get("serial")),
|
|
72
|
+
"tran": d.get("tran"),
|
|
73
|
+
"removable": bool(d.get("rm")),
|
|
74
|
+
"readonly": bool(d.get("ro")),
|
|
75
|
+
"mountpoints": [m for m in (d.get("mountpoints") or []) if m],
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
return out
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _strip_or_none(value: str | None) -> str | None:
|
|
82
|
+
if value is None:
|
|
83
|
+
return None
|
|
84
|
+
stripped = value.strip()
|
|
85
|
+
return stripped or None
|
pixie/events/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Append-only event log.
|
|
2
|
+
|
|
3
|
+
Every write path in pixie emits one row: fetch started, fetch failed,
|
|
4
|
+
machine bound, export registered, TFTP started. Rows live on the
|
|
5
|
+
shared ``state.db``; there is one bus for the whole process, not one
|
|
6
|
+
per module.
|
|
7
|
+
|
|
8
|
+
Consumers:
|
|
9
|
+
|
|
10
|
+
* Operator UI (``/ui/events``): scrollable timeline the operator
|
|
11
|
+
glances at to see what pixie has done recently.
|
|
12
|
+
* JSON API (``GET /events``): programmatic access + filtering by
|
|
13
|
+
subject/kind/since.
|
|
14
|
+
|
|
15
|
+
Rows are immutable; a mis-emitted event is not corrected in place, a
|
|
16
|
+
follow-up event is emitted describing the correction. Simpler audit
|
|
17
|
+
trail, no back-dated writes.
|
|
18
|
+
|
|
19
|
+
Hard-forked from bty's ``_events_log.py`` (see
|
|
20
|
+
``docs/audit.md#bty``) 2026-07-13.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from pixie.events._kinds import KNOWN_EVENT_KINDS
|
|
26
|
+
from pixie.events._log import EventsLog, UnknownEventKind
|
|
27
|
+
|
|
28
|
+
__all__ = ["KNOWN_EVENT_KINDS", "EventsLog", "UnknownEventKind"]
|
pixie/events/_kinds.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Closed registry of event kinds pixie emits.
|
|
2
|
+
|
|
3
|
+
Every mutation carries an event with a well-defined identifier from
|
|
4
|
+
this module. Callers import the constant and pass it to
|
|
5
|
+
:meth:`EventsLog.emit`; the emit call rejects any string not in
|
|
6
|
+
:data:`KNOWN_EVENT_KINDS`. A new mutation site adds a constant here
|
|
7
|
+
first (with a docstring explaining what state changed and what
|
|
8
|
+
``subject_kind`` / ``subject_id`` / ``details`` carry), then wires it
|
|
9
|
+
into the call site -- never the other way around.
|
|
10
|
+
|
|
11
|
+
Naming convention: ``<subject>.<verb>[.<outcome>]`` in lower kebab
|
|
12
|
+
form. ``<subject>`` matches the ``subject_kind`` field on the row
|
|
13
|
+
so filtering ``kind LIKE 'catalog.%'`` and ``subject_kind =
|
|
14
|
+
'entry'`` gives roughly the same slice. ``<outcome>`` distinguishes
|
|
15
|
+
async terminal states (``.started`` / ``.done`` / ``.failed``);
|
|
16
|
+
synchronous verbs skip it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
# ---------- catalog + fetch pipeline ---------------------------------
|
|
22
|
+
#
|
|
23
|
+
# Every catalog-store mutation emits one of these. ``subject_kind`` is
|
|
24
|
+
# always ``"entry"``; ``subject_id`` is the catalog entry name.
|
|
25
|
+
|
|
26
|
+
CATALOG_ENTRY_ADDED = "catalog.entry.added"
|
|
27
|
+
"""A new row landed via POST /catalog/entries, POST /ui/catalog/add,
|
|
28
|
+
or one of the entries in POST /ui/catalog/import. ``details.src``
|
|
29
|
+
carries the source URL so an operator can grep the log by URL."""
|
|
30
|
+
|
|
31
|
+
CATALOG_ENTRY_DELETED = "catalog.entry.deleted"
|
|
32
|
+
"""Row deleted via POST /ui/catalog/delete or DELETE /catalog/entries.
|
|
33
|
+
On-disk bytes are NOT touched here (see ``catalog.blob.deleted``)."""
|
|
34
|
+
|
|
35
|
+
CATALOG_ENTRY_UPDATED = "catalog.entry.updated"
|
|
36
|
+
"""A catalog entry's metadata was overwritten (Import overwrote an
|
|
37
|
+
existing name; a future edit-entry form lands here too). Fetch is
|
|
38
|
+
tracked separately via the ``catalog.fetch.*`` triple."""
|
|
39
|
+
|
|
40
|
+
CATALOG_FETCH_STARTED = "catalog.fetch.started"
|
|
41
|
+
"""Fetch pipeline kicked off. ``details`` carries the source URL +
|
|
42
|
+
whether this is an initial Fetch (``update = False``) or an Update
|
|
43
|
+
on an already-fetched entry."""
|
|
44
|
+
|
|
45
|
+
CATALOG_FETCH_DONE = "catalog.fetch.done"
|
|
46
|
+
"""Fetch pipeline finished successfully. ``details.content_sha256``
|
|
47
|
+
+ ``details.size_bytes`` name the new bytes."""
|
|
48
|
+
|
|
49
|
+
CATALOG_FETCH_FAILED = "catalog.fetch.failed"
|
|
50
|
+
"""Fetch pipeline errored. ``details.error`` carries the message."""
|
|
51
|
+
|
|
52
|
+
CATALOG_FETCH_UNCHANGED = "catalog.fetch.unchanged"
|
|
53
|
+
"""Update click on an already-fetched entry whose blob is still
|
|
54
|
+
present -- nothing to download. The operator's request is
|
|
55
|
+
acknowledged (an event fires + the UI shows a short-lived "already
|
|
56
|
+
at latest" pill) rather than silently flickering the fetching
|
|
57
|
+
badge for a millisecond. ``details.content_sha256`` names the
|
|
58
|
+
still-current bytes."""
|
|
59
|
+
|
|
60
|
+
CATALOG_BLOB_DELETED = "catalog.blob.deleted"
|
|
61
|
+
"""On-disk bytes for an entry dropped via POST /ui/catalog/delete-blob.
|
|
62
|
+
Row stays with content_sha256 cleared so a subsequent Fetch re-runs
|
|
63
|
+
the pipeline. ``details.forced`` is True when the operator confirmed
|
|
64
|
+
through the in-use warning banner."""
|
|
65
|
+
|
|
66
|
+
CATALOG_IMPORT_OK = "catalog.import.ok"
|
|
67
|
+
"""POST /ui/catalog/import fetched a catalog.toml and upserted every
|
|
68
|
+
entry. ``details.url`` + ``details.count`` + ``details.new``
|
|
69
|
+
summarise the result; individual ``catalog.entry.added`` /
|
|
70
|
+
``.updated`` events fire per entry."""
|
|
71
|
+
|
|
72
|
+
CATALOG_IMPORT_FAILED = "catalog.import.failed"
|
|
73
|
+
"""POST /ui/catalog/import could not reach the URL or parse the TOML.
|
|
74
|
+
``details.error`` carries the exception message."""
|
|
75
|
+
|
|
76
|
+
# ---------- exports + NBD supervisor ---------------------------------
|
|
77
|
+
#
|
|
78
|
+
# ``subject_kind`` is always ``"export"``; ``subject_id`` is the export
|
|
79
|
+
# name (``pixie-<sha12>.img`` shape).
|
|
80
|
+
|
|
81
|
+
EXPORT_REGISTERED = "export.registered"
|
|
82
|
+
"""New export row via POST /exports; carries ``details.content_sha256``
|
|
83
|
+
+ ``details.nbd_port``."""
|
|
84
|
+
|
|
85
|
+
EXPORT_DELETED = "export.deleted"
|
|
86
|
+
"""Export row + running nbdkit torn down via DELETE /exports/<name>
|
|
87
|
+
or POST /ui/exports/delete."""
|
|
88
|
+
|
|
89
|
+
EXPORT_NBDKIT_SPAWNED = "export.nbdkit.spawned"
|
|
90
|
+
"""Supervisor spawned an nbdkit subprocess (fresh spawn or the
|
|
91
|
+
startup-time respawn walk). ``details.pid`` + ``details.nbd_port``."""
|
|
92
|
+
|
|
93
|
+
EXPORT_NBDKIT_EXITED = "export.nbdkit.exited"
|
|
94
|
+
"""Supervisor observed an nbdkit subprocess died out of band
|
|
95
|
+
(``_refresh_row`` noticed no live proc for a ``running`` row).
|
|
96
|
+
``details.previous_port`` + ``details.error`` explain."""
|
|
97
|
+
|
|
98
|
+
# ---------- machines + PXE ------------------------------------------
|
|
99
|
+
#
|
|
100
|
+
# ``subject_kind`` is always ``"machine"``; ``subject_id`` is the
|
|
101
|
+
# canonical MAC address.
|
|
102
|
+
|
|
103
|
+
MACHINE_DISCOVERED = "machine.discovered"
|
|
104
|
+
"""First-ever GET /pxe/<mac> for a MAC pixie has not seen before.
|
|
105
|
+
Emitted from ``machines_store.touch_seen`` when a new row is
|
|
106
|
+
created; subsequent hits do NOT emit (they update ``last_seen_at``
|
|
107
|
+
via the ``pxe.plan.rendered`` event instead)."""
|
|
108
|
+
|
|
109
|
+
MACHINE_BOUND = "machine.bound"
|
|
110
|
+
"""First bind for a MAC: PUT /machines/<mac> or POST
|
|
111
|
+
/ui/machines/bind. ``details.boot_mode`` +
|
|
112
|
+
``details.image_content_sha256`` capture the target state."""
|
|
113
|
+
|
|
114
|
+
MACHINE_BINDING_CHANGED = "machine.binding.changed"
|
|
115
|
+
"""Existing machine's ``boot_mode`` or ``image_content_sha256``
|
|
116
|
+
changed via the same route. Distinct from ``.bound`` so an
|
|
117
|
+
operator can filter for "any change" vs "first bind ever"."""
|
|
118
|
+
|
|
119
|
+
MACHINE_DELETED = "machine.deleted"
|
|
120
|
+
"""Row deleted via DELETE /machines/<mac> or POST
|
|
121
|
+
/ui/machines/delete."""
|
|
122
|
+
|
|
123
|
+
MACHINE_INVENTORY_UPDATED = "machine.inventory.updated"
|
|
124
|
+
"""POST /pxe/<mac>/inventory landed a new hardware inventory blob
|
|
125
|
+
from the live-env pixie CLI."""
|
|
126
|
+
|
|
127
|
+
# ---------- PXE plan render + status --------------------------------
|
|
128
|
+
#
|
|
129
|
+
# The renderer fires exactly one of ``pxe.plan.rendered`` /
|
|
130
|
+
# ``pxe.plan.unavailable`` per GET /pxe/<mac>. Status POSTs from the
|
|
131
|
+
# target's live env / nbdboot initrd flow through ``pxe.status.received``.
|
|
132
|
+
|
|
133
|
+
PXE_PLAN_RENDERED = "pxe.plan.rendered"
|
|
134
|
+
"""GET /pxe/<mac> emitted a bootable iPXE plan for a known
|
|
135
|
+
boot_mode. ``details.boot_mode`` names which mode; ``subject_kind``
|
|
136
|
+
= ``machine``, ``subject_id`` = mac."""
|
|
137
|
+
|
|
138
|
+
PXE_PLAN_UNAVAILABLE = "pxe.plan.unavailable"
|
|
139
|
+
"""GET /pxe/<mac> fell back to unavailable.j2 (missing binding,
|
|
140
|
+
missing bundle, missing live-env media, unknown mode).
|
|
141
|
+
``details.reason`` carries the human-readable explanation the
|
|
142
|
+
plan comment carries."""
|
|
143
|
+
|
|
144
|
+
PXE_STATUS_RECEIVED = "pxe.status.received"
|
|
145
|
+
"""POST /pxe/<mac>/status from the target initrd or live env.
|
|
146
|
+
``details.status`` is the raw status token
|
|
147
|
+
(``ramboot.up``, ``ramboot.nbd_connect_failed``, ``ramboot.die``, ...)."""
|
|
148
|
+
|
|
149
|
+
# ---------- TFTP subprocess supervisor ------------------------------
|
|
150
|
+
#
|
|
151
|
+
# No subject (``subject_kind`` = "") -- the TFTP process is per-pixie,
|
|
152
|
+
# not per-resource. Useful for the operator to prove udp/69 came up.
|
|
153
|
+
|
|
154
|
+
TFTP_STARTED = "tftp.started"
|
|
155
|
+
"""``pixie.tftp.TftpServer.start`` succeeded and ``in.tftpd`` is
|
|
156
|
+
listening on the resolved bind:port."""
|
|
157
|
+
|
|
158
|
+
TFTP_STOPPED = "tftp.stopped"
|
|
159
|
+
"""The tftp supervisor tore its subprocess down on shutdown."""
|
|
160
|
+
|
|
161
|
+
# ---------- auth ----------------------------------------------------
|
|
162
|
+
#
|
|
163
|
+
# ``subject_kind`` = "" (single-tenant; no per-operator identity).
|
|
164
|
+
|
|
165
|
+
AUTH_LOGIN_SUCCEEDED = "auth.login.succeeded"
|
|
166
|
+
"""POST /ui/login with the correct admin password."""
|
|
167
|
+
|
|
168
|
+
AUTH_LOGIN_FAILED = "auth.login.failed"
|
|
169
|
+
"""POST /ui/login with a wrong admin password."""
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# The canonical closed set. Every kind above is registered here; the
|
|
173
|
+
# ``EventsLog.emit`` call rejects anything not in this frozenset.
|
|
174
|
+
KNOWN_EVENT_KINDS: frozenset[str] = frozenset(
|
|
175
|
+
{
|
|
176
|
+
CATALOG_ENTRY_ADDED,
|
|
177
|
+
CATALOG_ENTRY_DELETED,
|
|
178
|
+
CATALOG_ENTRY_UPDATED,
|
|
179
|
+
CATALOG_FETCH_STARTED,
|
|
180
|
+
CATALOG_FETCH_DONE,
|
|
181
|
+
CATALOG_FETCH_FAILED,
|
|
182
|
+
CATALOG_FETCH_UNCHANGED,
|
|
183
|
+
CATALOG_BLOB_DELETED,
|
|
184
|
+
CATALOG_IMPORT_OK,
|
|
185
|
+
CATALOG_IMPORT_FAILED,
|
|
186
|
+
EXPORT_REGISTERED,
|
|
187
|
+
EXPORT_DELETED,
|
|
188
|
+
EXPORT_NBDKIT_SPAWNED,
|
|
189
|
+
EXPORT_NBDKIT_EXITED,
|
|
190
|
+
MACHINE_DISCOVERED,
|
|
191
|
+
MACHINE_BOUND,
|
|
192
|
+
MACHINE_BINDING_CHANGED,
|
|
193
|
+
MACHINE_DELETED,
|
|
194
|
+
MACHINE_INVENTORY_UPDATED,
|
|
195
|
+
PXE_PLAN_RENDERED,
|
|
196
|
+
PXE_PLAN_UNAVAILABLE,
|
|
197
|
+
PXE_STATUS_RECEIVED,
|
|
198
|
+
TFTP_STARTED,
|
|
199
|
+
TFTP_STOPPED,
|
|
200
|
+
AUTH_LOGIN_SUCCEEDED,
|
|
201
|
+
AUTH_LOGIN_FAILED,
|
|
202
|
+
}
|
|
203
|
+
)
|
pixie/events/_log.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Events table + append/list operations.
|
|
2
|
+
|
|
3
|
+
Events are (kind, subject_kind, subject_id, summary, details, ts)
|
|
4
|
+
tuples. ``kind`` is a dotted string (e.g. ``catalog.fetch.started``)
|
|
5
|
+
that operators grep on. ``subject_kind`` + ``subject_id`` scope the
|
|
6
|
+
event to a specific resource: ``machine``/<mac>, ``export``/<name>,
|
|
7
|
+
``entry``/<catalog-name>.
|
|
8
|
+
|
|
9
|
+
All time in ISO-8601 UTC (``now_iso`` from the shared util).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import json
|
|
16
|
+
import sqlite3
|
|
17
|
+
import threading
|
|
18
|
+
from collections.abc import Generator
|
|
19
|
+
from dataclasses import dataclass, field
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
from pixie._util import now_iso
|
|
24
|
+
from pixie.events._kinds import KNOWN_EVENT_KINDS
|
|
25
|
+
|
|
26
|
+
_DB_WRITE_LOCK = threading.Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class UnknownEventKind(ValueError):
|
|
30
|
+
"""Raised by :meth:`EventsLog.emit` when the caller passed a
|
|
31
|
+
``kind`` string not in :data:`pixie.events.KNOWN_EVENT_KINDS`.
|
|
32
|
+
|
|
33
|
+
Every action pixie takes must land in the event log with a
|
|
34
|
+
well-defined identifier from ``pixie.events._kinds``; the closed
|
|
35
|
+
set is enforced (not advisory) so a new mutation site cannot slip
|
|
36
|
+
through without an operator seeing it. Add the constant to
|
|
37
|
+
``_kinds.py`` first, then wire the call site.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
_SCHEMA = """
|
|
42
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
43
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
|
+
ts TEXT NOT NULL,
|
|
45
|
+
kind TEXT NOT NULL,
|
|
46
|
+
subject_kind TEXT NOT NULL DEFAULT '',
|
|
47
|
+
subject_id TEXT NOT NULL DEFAULT '',
|
|
48
|
+
summary TEXT NOT NULL DEFAULT '',
|
|
49
|
+
details_json TEXT NOT NULL DEFAULT '{}'
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
|
|
53
|
+
CREATE INDEX IF NOT EXISTS idx_events_subject
|
|
54
|
+
ON events(subject_kind, subject_id);
|
|
55
|
+
CREATE INDEX IF NOT EXISTS idx_events_ts ON events(ts);
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Event:
|
|
61
|
+
ts: str
|
|
62
|
+
kind: str
|
|
63
|
+
subject_kind: str = ""
|
|
64
|
+
subject_id: str = ""
|
|
65
|
+
summary: str = ""
|
|
66
|
+
details: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
id: int | None = None
|
|
68
|
+
|
|
69
|
+
def to_dict(self) -> dict[str, Any]:
|
|
70
|
+
out: dict[str, Any] = {
|
|
71
|
+
"id": self.id,
|
|
72
|
+
"ts": self.ts,
|
|
73
|
+
"kind": self.kind,
|
|
74
|
+
"summary": self.summary,
|
|
75
|
+
}
|
|
76
|
+
if self.subject_kind:
|
|
77
|
+
out["subject_kind"] = self.subject_kind
|
|
78
|
+
if self.subject_id:
|
|
79
|
+
out["subject_id"] = self.subject_id
|
|
80
|
+
if self.details:
|
|
81
|
+
out["details"] = self.details
|
|
82
|
+
return out
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class EventsLog:
|
|
86
|
+
"""Repository over the ``events`` table. Append-only + query."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, db_path: Path) -> None:
|
|
89
|
+
self.db_path = Path(db_path)
|
|
90
|
+
self._ensure_schema()
|
|
91
|
+
|
|
92
|
+
def _ensure_schema(self) -> None:
|
|
93
|
+
with self._conn() as conn:
|
|
94
|
+
conn.executescript(_SCHEMA)
|
|
95
|
+
|
|
96
|
+
@contextlib.contextmanager
|
|
97
|
+
def _conn(self) -> Generator[sqlite3.Connection]:
|
|
98
|
+
conn = sqlite3.connect(str(self.db_path))
|
|
99
|
+
conn.row_factory = sqlite3.Row
|
|
100
|
+
try:
|
|
101
|
+
yield conn
|
|
102
|
+
conn.commit()
|
|
103
|
+
finally:
|
|
104
|
+
conn.close()
|
|
105
|
+
|
|
106
|
+
def emit(
|
|
107
|
+
self,
|
|
108
|
+
kind: str,
|
|
109
|
+
*,
|
|
110
|
+
subject_kind: str = "",
|
|
111
|
+
subject_id: str = "",
|
|
112
|
+
summary: str = "",
|
|
113
|
+
details: dict[str, Any] | None = None,
|
|
114
|
+
) -> Event:
|
|
115
|
+
"""Append one row.
|
|
116
|
+
|
|
117
|
+
``kind`` MUST be one of the constants declared in
|
|
118
|
+
:mod:`pixie.events._kinds`; anything else raises
|
|
119
|
+
:class:`UnknownEventKind`. Every pixie mutation carries an
|
|
120
|
+
event log entry with a well-defined identifier, and the
|
|
121
|
+
closed set is enforced so a new mutation site cannot slip in
|
|
122
|
+
without an operator noticing. Register the constant in
|
|
123
|
+
``_kinds.py`` first, then wire the call site.
|
|
124
|
+
"""
|
|
125
|
+
if kind not in KNOWN_EVENT_KINDS:
|
|
126
|
+
raise UnknownEventKind(
|
|
127
|
+
f"event kind {kind!r} is not registered in "
|
|
128
|
+
f"pixie.events._kinds.KNOWN_EVENT_KINDS. Add a constant "
|
|
129
|
+
f"there first (with a docstring) before emitting from a "
|
|
130
|
+
f"new site."
|
|
131
|
+
)
|
|
132
|
+
row = Event(
|
|
133
|
+
ts=now_iso(),
|
|
134
|
+
kind=kind,
|
|
135
|
+
subject_kind=subject_kind,
|
|
136
|
+
subject_id=subject_id,
|
|
137
|
+
summary=summary,
|
|
138
|
+
details=details or {},
|
|
139
|
+
)
|
|
140
|
+
with _DB_WRITE_LOCK, self._conn() as conn:
|
|
141
|
+
cur = conn.execute(
|
|
142
|
+
"INSERT INTO events (ts, kind, subject_kind, subject_id, summary, details_json) "
|
|
143
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
144
|
+
(
|
|
145
|
+
row.ts,
|
|
146
|
+
row.kind,
|
|
147
|
+
row.subject_kind,
|
|
148
|
+
row.subject_id,
|
|
149
|
+
row.summary,
|
|
150
|
+
json.dumps(row.details),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
row.id = int(cur.lastrowid) if cur.lastrowid is not None else None
|
|
154
|
+
return row
|
|
155
|
+
|
|
156
|
+
def list(
|
|
157
|
+
self,
|
|
158
|
+
*,
|
|
159
|
+
kind: str = "",
|
|
160
|
+
subject_kind: str = "",
|
|
161
|
+
subject_id: str = "",
|
|
162
|
+
since_id: int = 0,
|
|
163
|
+
limit: int = 100,
|
|
164
|
+
) -> list[Event]:
|
|
165
|
+
"""Reverse-chronological (newest first) with optional filters.
|
|
166
|
+
|
|
167
|
+
``since_id`` is exclusive (>) so operators can build a poll
|
|
168
|
+
loop off ``max(id)`` without dedup. Filters are ANDed; empty
|
|
169
|
+
strings match anything.
|
|
170
|
+
"""
|
|
171
|
+
clauses: list[str] = []
|
|
172
|
+
params: list[Any] = []
|
|
173
|
+
if kind:
|
|
174
|
+
clauses.append("kind = ?")
|
|
175
|
+
params.append(kind)
|
|
176
|
+
if subject_kind:
|
|
177
|
+
clauses.append("subject_kind = ?")
|
|
178
|
+
params.append(subject_kind)
|
|
179
|
+
if subject_id:
|
|
180
|
+
clauses.append("subject_id = ?")
|
|
181
|
+
params.append(subject_id)
|
|
182
|
+
if since_id > 0:
|
|
183
|
+
clauses.append("id > ?")
|
|
184
|
+
params.append(since_id)
|
|
185
|
+
sql = "SELECT * FROM events"
|
|
186
|
+
if clauses:
|
|
187
|
+
sql += " WHERE " + " AND ".join(clauses)
|
|
188
|
+
sql += " ORDER BY id DESC LIMIT ?"
|
|
189
|
+
params.append(max(1, min(limit, 1000)))
|
|
190
|
+
with self._conn() as conn:
|
|
191
|
+
rows = conn.execute(sql, params).fetchall()
|
|
192
|
+
return [_row(r) for r in rows]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _row(r: sqlite3.Row) -> Event:
|
|
196
|
+
try:
|
|
197
|
+
details = json.loads(r["details_json"] or "{}")
|
|
198
|
+
if not isinstance(details, dict):
|
|
199
|
+
details = {}
|
|
200
|
+
except json.JSONDecodeError:
|
|
201
|
+
details = {}
|
|
202
|
+
return Event(
|
|
203
|
+
id=r["id"],
|
|
204
|
+
ts=r["ts"],
|
|
205
|
+
kind=r["kind"],
|
|
206
|
+
subject_kind=r["subject_kind"],
|
|
207
|
+
subject_id=r["subject_id"],
|
|
208
|
+
summary=r["summary"],
|
|
209
|
+
details=details,
|
|
210
|
+
)
|