pixie-lab 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. pixie/__init__.py +20 -0
  2. pixie/_util.py +38 -0
  3. pixie/catalog/__init__.py +25 -0
  4. pixie/catalog/_fetcher.py +513 -0
  5. pixie/catalog/_routes.py +392 -0
  6. pixie/catalog/_schema.py +263 -0
  7. pixie/catalog/_store.py +234 -0
  8. pixie/deploy/__init__.py +26 -0
  9. pixie/deploy/_main.py +321 -0
  10. pixie/deploy/_templates.py +153 -0
  11. pixie/disks.py +85 -0
  12. pixie/events/__init__.py +28 -0
  13. pixie/events/_kinds.py +203 -0
  14. pixie/events/_log.py +210 -0
  15. pixie/events/_routes.py +42 -0
  16. pixie/exports/__init__.py +17 -0
  17. pixie/exports/_routes.py +211 -0
  18. pixie/exports/_store.py +156 -0
  19. pixie/exports/_supervisor.py +240 -0
  20. pixie/flash.py +1971 -0
  21. pixie/images.py +473 -0
  22. pixie/machines/__init__.py +16 -0
  23. pixie/machines/_routes.py +190 -0
  24. pixie/machines/_store.py +623 -0
  25. pixie/oras.py +547 -0
  26. pixie/pivot/__init__.py +180 -0
  27. pixie/pivot/nbdboot +348 -0
  28. pixie/pxe/__init__.py +19 -0
  29. pixie/pxe/_renderer.py +244 -0
  30. pixie/pxe/_routes.py +386 -0
  31. pixie/tftp/__init__.py +21 -0
  32. pixie/tftp/_supervisor.py +129 -0
  33. pixie/tui/__init__.py +185 -0
  34. pixie/tui/_app.py +2219 -0
  35. pixie/tui_catalog.py +657 -0
  36. pixie/web/__init__.py +6 -0
  37. pixie/web/_auth.py +70 -0
  38. pixie/web/_settings_store.py +211 -0
  39. pixie/web/_static/.gitkeep +0 -0
  40. pixie/web/_static/bootstrap-icons.min.css +5 -0
  41. pixie/web/_static/bootstrap.min.css +12 -0
  42. pixie/web/_static/fonts/bootstrap-icons.woff +0 -0
  43. pixie/web/_static/fonts/bootstrap-icons.woff2 +0 -0
  44. pixie/web/_static/htmx.min.js +1 -0
  45. pixie/web/_static/pixie-favicon.png +0 -0
  46. pixie/web/_static/sse.js +290 -0
  47. pixie/web/_table_state.py +261 -0
  48. pixie/web/_templates/_partials/page_description.html +22 -0
  49. pixie/web/_templates/_partials/recent_events.html +47 -0
  50. pixie/web/_templates/_partials/table_helpers.html +189 -0
  51. pixie/web/_templates/catalog.html +364 -0
  52. pixie/web/_templates/catalog_detail.html +386 -0
  53. pixie/web/_templates/dashboard.html +256 -0
  54. pixie/web/_templates/events.html +125 -0
  55. pixie/web/_templates/ipxe/bootstrap.j2 +12 -0
  56. pixie/web/_templates/ipxe/exit.j2 +10 -0
  57. pixie/web/_templates/ipxe/nbdboot.j2 +57 -0
  58. pixie/web/_templates/ipxe/pixie-live-env.j2 +55 -0
  59. pixie/web/_templates/ipxe/unavailable.j2 +14 -0
  60. pixie/web/_templates/layout.html +345 -0
  61. pixie/web/_templates/login.html +40 -0
  62. pixie/web/_templates/machine_detail.html +786 -0
  63. pixie/web/_templates/machines.html +186 -0
  64. pixie/web/_templates/settings.html +265 -0
  65. pixie/web/main.py +1910 -0
  66. pixie_lab-0.1.0.dist-info/METADATA +107 -0
  67. pixie_lab-0.1.0.dist-info/RECORD +70 -0
  68. pixie_lab-0.1.0.dist-info/WHEEL +4 -0
  69. pixie_lab-0.1.0.dist-info/entry_points.txt +3 -0
  70. pixie_lab-0.1.0.dist-info/licenses/LICENSE +674 -0
@@ -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
@@ -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
+ )