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/machines/_store.py
ADDED
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
"""Machines table + typed row.
|
|
2
|
+
|
|
3
|
+
One row per MAC. The two operator-writable fields are ``boot_mode``
|
|
4
|
+
and ``image_content_sha256``; the rest are discovery + telemetry.
|
|
5
|
+
Discovery is a side-effect of ``GET /pxe/<mac>`` -- the routes
|
|
6
|
+
module upserts a row (or bumps ``last_seen_at`` on an existing one)
|
|
7
|
+
before rendering the plan.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import contextlib
|
|
13
|
+
import re
|
|
14
|
+
import sqlite3
|
|
15
|
+
import threading
|
|
16
|
+
from collections.abc import Generator, Sequence
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from pixie._util import now_iso
|
|
22
|
+
|
|
23
|
+
_DB_WRITE_LOCK = threading.Lock()
|
|
24
|
+
|
|
25
|
+
# Boot modes pixie renders a plan for. The set is closed on purpose:
|
|
26
|
+
# an unknown mode on a row would silently fall through to the default
|
|
27
|
+
# and confuse an operator staring at ``GET /machines/<mac>``. Mirrors
|
|
28
|
+
# bty's tuple minus ``bty-tui`` (whose live-env driver has not been
|
|
29
|
+
# ported yet). ``pixie-flash-*`` + ``pixie-inventory`` chain into
|
|
30
|
+
# pixie's own live env; the renderer currently emits an
|
|
31
|
+
# ``unavailable`` plan for them so a bound target boots into a
|
|
32
|
+
# readable "live env not yet baked" screen rather than kernel-panicking
|
|
33
|
+
# on a bty-media initrd.
|
|
34
|
+
LIVE_ENV_MODES: frozenset[str] = frozenset(
|
|
35
|
+
{
|
|
36
|
+
"pixie-flash-once",
|
|
37
|
+
"pixie-flash-always",
|
|
38
|
+
"pixie-inventory",
|
|
39
|
+
"pixie-tui",
|
|
40
|
+
}
|
|
41
|
+
)
|
|
42
|
+
"""Boot modes that chain the target into pixie's own netboot-pc live
|
|
43
|
+
env. Extracted as its own set so ``pxe/_renderer.py`` and the store
|
|
44
|
+
share one source of truth: if a mode is added on one side only, the
|
|
45
|
+
renderer silently falls through the plan-render match and the bound
|
|
46
|
+
target boots into ``unavailable.j2``. Kept close to :data:`BOOT_MODES`
|
|
47
|
+
so a reader adding a mode sees both spots at once."""
|
|
48
|
+
|
|
49
|
+
BOOT_MODES: frozenset[str] = frozenset({"ipxe-exit", "nbdboot"}) | LIVE_ENV_MODES
|
|
50
|
+
DEFAULT_BOOT_MODE = "ipxe-exit"
|
|
51
|
+
|
|
52
|
+
# Presentation metadata for the six boot modes. Ordered
|
|
53
|
+
# passthrough -> diagnostic -> interactive -> destructive so the
|
|
54
|
+
# radio-card picker on ``machine_detail.html`` reads roughly
|
|
55
|
+
# left-to-right by blast radius (ipxe-exit is a no-op; the flash
|
|
56
|
+
# modes rewrite the disk). Icons pulled from Bootstrap Icons.
|
|
57
|
+
#
|
|
58
|
+
# Kept next to :data:`BOOT_MODES` on purpose: adding a mode without
|
|
59
|
+
# a metadata row here trips the assertion below, so the picker
|
|
60
|
+
# cannot silently drop a mode.
|
|
61
|
+
BOOT_MODE_META: tuple[tuple[str, dict[str, str]], ...] = (
|
|
62
|
+
(
|
|
63
|
+
"ipxe-exit",
|
|
64
|
+
{
|
|
65
|
+
"icon": "box-arrow-right",
|
|
66
|
+
"short": "Exit to firmware",
|
|
67
|
+
"desc": "Drop through pixie's chain; the BIOS boot order picks the next device.",
|
|
68
|
+
},
|
|
69
|
+
),
|
|
70
|
+
(
|
|
71
|
+
"pixie-inventory",
|
|
72
|
+
{
|
|
73
|
+
"icon": "hdd-stack",
|
|
74
|
+
"short": "Discover inventory",
|
|
75
|
+
"desc": "Live env posts disk + NIC info to pixie, then exits to firmware.",
|
|
76
|
+
},
|
|
77
|
+
),
|
|
78
|
+
(
|
|
79
|
+
"pixie-tui",
|
|
80
|
+
{
|
|
81
|
+
"icon": "terminal",
|
|
82
|
+
"short": "Interactive TUI",
|
|
83
|
+
"desc": "Operator-driven wizard on the pixie live env.",
|
|
84
|
+
},
|
|
85
|
+
),
|
|
86
|
+
(
|
|
87
|
+
"pixie-flash-once",
|
|
88
|
+
{
|
|
89
|
+
"icon": "download",
|
|
90
|
+
"short": "Flash once",
|
|
91
|
+
"desc": "Live env writes the image to the picked disk, then exits.",
|
|
92
|
+
},
|
|
93
|
+
),
|
|
94
|
+
(
|
|
95
|
+
"pixie-flash-always",
|
|
96
|
+
{
|
|
97
|
+
"icon": "arrow-clockwise",
|
|
98
|
+
"short": "Flash every boot",
|
|
99
|
+
"desc": "Re-flash the image on every PXE. Any local changes are discarded.",
|
|
100
|
+
},
|
|
101
|
+
),
|
|
102
|
+
(
|
|
103
|
+
"nbdboot",
|
|
104
|
+
{
|
|
105
|
+
"icon": "hdd-network",
|
|
106
|
+
"short": "Netboot over NBD",
|
|
107
|
+
"desc": "Stream the image over NBD; root is overlay-on-tmpfs. Nothing persists.",
|
|
108
|
+
},
|
|
109
|
+
),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Guard against a mode landing in BOOT_MODES without a metadata row
|
|
113
|
+
# above (or vice versa). Fires at module load so a mismatched pair
|
|
114
|
+
# never reaches a running server.
|
|
115
|
+
_BOOT_MODE_META_KEYS = {m for m, _ in BOOT_MODE_META}
|
|
116
|
+
assert _BOOT_MODE_META_KEYS == BOOT_MODES, (
|
|
117
|
+
f"BOOT_MODE_META keys {_BOOT_MODE_META_KEYS} != BOOT_MODES {BOOT_MODES}"
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Normalise MAC to lower-case colon form. Rejects anything else.
|
|
121
|
+
_MAC_RE = re.compile(r"^([0-9a-f]{2}:){5}[0-9a-f]{2}$")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class BadMac(ValueError):
|
|
125
|
+
"""The provided MAC failed the canonical-form check."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def normalise_mac(mac: str) -> str:
|
|
129
|
+
"""Fold ``mac`` to ``aa:bb:cc:dd:ee:ff`` form + lowercase. Accepts
|
|
130
|
+
the same MAC in ``AA-BB-CC-DD-EE-FF`` or ``aabbccddeeff`` shapes;
|
|
131
|
+
raises :class:`BadMac` on anything unparseable.
|
|
132
|
+
"""
|
|
133
|
+
raw = (mac or "").strip().lower().replace("-", ":").replace(".", ":")
|
|
134
|
+
if ":" not in raw and len(raw) == 12:
|
|
135
|
+
raw = ":".join(raw[i : i + 2] for i in range(0, 12, 2))
|
|
136
|
+
if not _MAC_RE.match(raw):
|
|
137
|
+
raise BadMac(f"invalid mac: {mac!r}")
|
|
138
|
+
return raw
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
_SCHEMA = """
|
|
142
|
+
CREATE TABLE IF NOT EXISTS machines (
|
|
143
|
+
mac TEXT PRIMARY KEY,
|
|
144
|
+
boot_mode TEXT NOT NULL DEFAULT 'ipxe-exit',
|
|
145
|
+
image_content_sha256 TEXT NOT NULL DEFAULT '',
|
|
146
|
+
labels TEXT NOT NULL DEFAULT '',
|
|
147
|
+
target_disk_serial TEXT NOT NULL DEFAULT '',
|
|
148
|
+
extra_cmdline TEXT NOT NULL DEFAULT '',
|
|
149
|
+
inventory_json TEXT NOT NULL DEFAULT '',
|
|
150
|
+
inventory_at TEXT NOT NULL DEFAULT '',
|
|
151
|
+
discovered_at TEXT NOT NULL,
|
|
152
|
+
last_seen_at TEXT NOT NULL,
|
|
153
|
+
last_seen_ip TEXT NOT NULL DEFAULT '',
|
|
154
|
+
updated_at TEXT NOT NULL
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
CREATE INDEX IF NOT EXISTS idx_machines_image_content_sha
|
|
158
|
+
ON machines(image_content_sha256);
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
# Boot modes that write to the target disk. Binding these requires a
|
|
162
|
+
# ``target_disk_serial`` chosen from the machine's inventory, so the
|
|
163
|
+
# live env's flash pipeline has a concrete destination.
|
|
164
|
+
_FLASH_MODES: frozenset[str] = frozenset({"pixie-flash-once", "pixie-flash-always"})
|
|
165
|
+
|
|
166
|
+
# Bty's shape: alphanumeric-leading, alphanumeric + space + . _ - inside,
|
|
167
|
+
# 64 chars max per label, 16 labels max per machine. Matches the CSS-safe
|
|
168
|
+
# subset so a label can render as a ``.badge`` without escaping surprises.
|
|
169
|
+
_LABEL_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9 ._\-]{0,63}$")
|
|
170
|
+
_LABEL_LIMIT = 16
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _migrate_schema(conn: sqlite3.Connection) -> None:
|
|
174
|
+
"""Additive column adds for existing state.db files. Idempotent."""
|
|
175
|
+
cols = {row[1] for row in conn.execute("PRAGMA table_info(machines)").fetchall()}
|
|
176
|
+
if "inventory_json" not in cols:
|
|
177
|
+
conn.execute("ALTER TABLE machines ADD COLUMN inventory_json TEXT NOT NULL DEFAULT ''")
|
|
178
|
+
if "inventory_at" not in cols:
|
|
179
|
+
conn.execute("ALTER TABLE machines ADD COLUMN inventory_at TEXT NOT NULL DEFAULT ''")
|
|
180
|
+
if "labels" not in cols:
|
|
181
|
+
conn.execute("ALTER TABLE machines ADD COLUMN labels TEXT NOT NULL DEFAULT ''")
|
|
182
|
+
if "target_disk_serial" not in cols:
|
|
183
|
+
conn.execute("ALTER TABLE machines ADD COLUMN target_disk_serial TEXT NOT NULL DEFAULT ''")
|
|
184
|
+
if "extra_cmdline" not in cols:
|
|
185
|
+
conn.execute("ALTER TABLE machines ADD COLUMN extra_cmdline TEXT NOT NULL DEFAULT ''")
|
|
186
|
+
# Retired 2026-07 pre-1.0: sanboot_drive was carried on the bind
|
|
187
|
+
# form as an iPXE ``sanboot`` BIOS drive slug, but pixie never
|
|
188
|
+
# actually rendered it into any iPXE template -- the ipxe-exit
|
|
189
|
+
# plan just does ``exit`` and hands off to firmware boot order.
|
|
190
|
+
# Dropped from the schema (no back-compat shim); the column is
|
|
191
|
+
# gone from fresh state.dbs and stays as an unused column on
|
|
192
|
+
# existing ones. SQLite is forgiving of unread columns.
|
|
193
|
+
# Renamed 2026-07: ``boot_mode='ramboot'`` -> ``'nbdboot'``. The
|
|
194
|
+
# earlier name evoked "loads root into RAM" which is not what
|
|
195
|
+
# this mode does -- it's a netboot that mounts the root over
|
|
196
|
+
# NBD. Operator-facing rename only: kernel cmdline still says
|
|
197
|
+
# ``boot=ramboot`` and pixie-media's ``/scripts/ramboot`` still
|
|
198
|
+
# ships under that name so currently-published nosi netboot
|
|
199
|
+
# bundles keep working. Longer-term the mode-specific pivot
|
|
200
|
+
# script should not be baked into nosi's artifact at all --
|
|
201
|
+
# decoupling is a follow-up (see nbdboot.j2 template comment).
|
|
202
|
+
# Migrate silently -- an existing state.db from before the
|
|
203
|
+
# rename still resolves.
|
|
204
|
+
conn.execute("UPDATE machines SET boot_mode = 'nbdboot' WHERE boot_mode = 'ramboot'")
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def parse_labels(raw: str) -> list[str]:
|
|
208
|
+
"""Split a comma-separated label string into a normalised list.
|
|
209
|
+
Whitespace-only tokens are dropped; duplicates are folded to a
|
|
210
|
+
single occurrence in first-seen order. Raises :class:`ValueError`
|
|
211
|
+
on any token that fails :data:`_LABEL_RE` or when the count
|
|
212
|
+
exceeds :data:`_LABEL_LIMIT`."""
|
|
213
|
+
out: list[str] = []
|
|
214
|
+
seen: set[str] = set()
|
|
215
|
+
for tok in (raw or "").split(","):
|
|
216
|
+
s = tok.strip()
|
|
217
|
+
if not s:
|
|
218
|
+
continue
|
|
219
|
+
if not _LABEL_RE.match(s):
|
|
220
|
+
raise ValueError(
|
|
221
|
+
f"label {s!r} must be alphanumeric-leading + a-z/0-9/space/._- (max 64 chars)"
|
|
222
|
+
)
|
|
223
|
+
if s in seen:
|
|
224
|
+
continue
|
|
225
|
+
seen.add(s)
|
|
226
|
+
out.append(s)
|
|
227
|
+
if len(out) > _LABEL_LIMIT:
|
|
228
|
+
raise ValueError(f"at most {_LABEL_LIMIT} labels per machine (got {len(out)})")
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass
|
|
233
|
+
class Machine:
|
|
234
|
+
mac: str
|
|
235
|
+
boot_mode: str = DEFAULT_BOOT_MODE
|
|
236
|
+
image_content_sha256: str = ""
|
|
237
|
+
labels: list[str] = field(default_factory=list)
|
|
238
|
+
target_disk_serial: str = ""
|
|
239
|
+
extra_cmdline: str = ""
|
|
240
|
+
"""Per-machine kernel-cmdline tokens appended to the pixie-live-env
|
|
241
|
+
+ nbdboot chains AFTER the global ``PIXIE_LIVE_ENV_EXTRA_CMDLINE``
|
|
242
|
+
fallback. Blank means "no per-machine tokens, use the global";
|
|
243
|
+
non-blank fully overrides the global for THIS machine (does not
|
|
244
|
+
concatenate). Intended for the odd hardware quirk that needs a
|
|
245
|
+
workaround on ONE target without dragging the deploy-wide default
|
|
246
|
+
with it."""
|
|
247
|
+
inventory: dict[str, Any] = field(default_factory=dict)
|
|
248
|
+
inventory_at: str = ""
|
|
249
|
+
discovered_at: str = field(default_factory=now_iso)
|
|
250
|
+
last_seen_at: str = field(default_factory=now_iso)
|
|
251
|
+
last_seen_ip: str = ""
|
|
252
|
+
updated_at: str = field(default_factory=now_iso)
|
|
253
|
+
|
|
254
|
+
def to_dict(self) -> dict[str, Any]:
|
|
255
|
+
out: dict[str, Any] = {
|
|
256
|
+
"mac": self.mac,
|
|
257
|
+
"boot_mode": self.boot_mode,
|
|
258
|
+
"discovered_at": self.discovered_at,
|
|
259
|
+
"last_seen_at": self.last_seen_at,
|
|
260
|
+
"updated_at": self.updated_at,
|
|
261
|
+
}
|
|
262
|
+
if self.image_content_sha256:
|
|
263
|
+
out["image_content_sha256"] = self.image_content_sha256
|
|
264
|
+
if self.labels:
|
|
265
|
+
out["labels"] = list(self.labels)
|
|
266
|
+
if self.target_disk_serial:
|
|
267
|
+
out["target_disk_serial"] = self.target_disk_serial
|
|
268
|
+
if self.extra_cmdline:
|
|
269
|
+
out["extra_cmdline"] = self.extra_cmdline
|
|
270
|
+
if self.last_seen_ip:
|
|
271
|
+
out["last_seen_ip"] = self.last_seen_ip
|
|
272
|
+
if self.inventory:
|
|
273
|
+
out["inventory_at"] = self.inventory_at
|
|
274
|
+
out["inventory"] = self.inventory
|
|
275
|
+
return out
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class MachinesStore:
|
|
279
|
+
def __init__(self, db_path: Path) -> None:
|
|
280
|
+
self.db_path = Path(db_path)
|
|
281
|
+
self._ensure_schema()
|
|
282
|
+
|
|
283
|
+
def _ensure_schema(self) -> None:
|
|
284
|
+
with self._conn() as conn:
|
|
285
|
+
conn.executescript(_SCHEMA)
|
|
286
|
+
_migrate_schema(conn)
|
|
287
|
+
|
|
288
|
+
@contextlib.contextmanager
|
|
289
|
+
def _conn(self) -> Generator[sqlite3.Connection]:
|
|
290
|
+
conn = sqlite3.connect(str(self.db_path))
|
|
291
|
+
conn.row_factory = sqlite3.Row
|
|
292
|
+
try:
|
|
293
|
+
yield conn
|
|
294
|
+
conn.commit()
|
|
295
|
+
finally:
|
|
296
|
+
conn.close()
|
|
297
|
+
|
|
298
|
+
def list(self) -> list[Machine]:
|
|
299
|
+
with self._conn() as conn:
|
|
300
|
+
rows = conn.execute("SELECT * FROM machines ORDER BY mac").fetchall()
|
|
301
|
+
return [_row(r) for r in rows]
|
|
302
|
+
|
|
303
|
+
def get(self, mac: str) -> Machine | None:
|
|
304
|
+
canon = normalise_mac(mac)
|
|
305
|
+
with self._conn() as conn:
|
|
306
|
+
row = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
|
|
307
|
+
return _row(row) if row else None
|
|
308
|
+
|
|
309
|
+
def upsert_binding(
|
|
310
|
+
self,
|
|
311
|
+
mac: str,
|
|
312
|
+
*,
|
|
313
|
+
boot_mode: str,
|
|
314
|
+
image_content_sha256: str = "",
|
|
315
|
+
labels: Sequence[str] | None = None,
|
|
316
|
+
target_disk_serial: str = "",
|
|
317
|
+
extra_cmdline: str = "",
|
|
318
|
+
) -> Machine:
|
|
319
|
+
"""Operator-driven write: set boot mode + optional image ref.
|
|
320
|
+
Creates the row if it doesn't exist; preserves discovery
|
|
321
|
+
telemetry (``discovered_at``, ``last_seen_*``) on update.
|
|
322
|
+
|
|
323
|
+
``labels`` is a pre-parsed list (caller runs :func:`parse_labels`
|
|
324
|
+
so form + JSON paths share the validator).
|
|
325
|
+
``target_disk_serial`` is the disk serial the live env's flash
|
|
326
|
+
pipeline matches at flash time -- an operator picks it from
|
|
327
|
+
the machine's reported inventory.
|
|
328
|
+
``extra_cmdline`` is a space-separated string of tokens appended
|
|
329
|
+
to the kernel cmdline on the live-env + nbdboot chains for
|
|
330
|
+
this ONE machine, overriding the global
|
|
331
|
+
``PIXIE_LIVE_ENV_EXTRA_CMDLINE`` fallback. Newlines are
|
|
332
|
+
rejected so a stray paste can't smuggle a second cmdline into
|
|
333
|
+
the iPXE render.
|
|
334
|
+
"""
|
|
335
|
+
canon = normalise_mac(mac)
|
|
336
|
+
if boot_mode not in BOOT_MODES:
|
|
337
|
+
raise ValueError(f"unknown boot_mode {boot_mode!r}; valid: {sorted(BOOT_MODES)}")
|
|
338
|
+
if image_content_sha256 and not re.match(r"^[0-9a-f]{64}$", image_content_sha256):
|
|
339
|
+
raise ValueError("image_content_sha256 must be 64 lowercase hex chars")
|
|
340
|
+
labels_json = _labels_to_json(list(labels or []))
|
|
341
|
+
target_serial = (target_disk_serial or "").strip()
|
|
342
|
+
extra = (extra_cmdline or "").strip()
|
|
343
|
+
if "\n" in extra or "\r" in extra:
|
|
344
|
+
raise ValueError(
|
|
345
|
+
"extra_cmdline must be a single line (newlines truncate the iPXE render)"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
now = now_iso()
|
|
349
|
+
with _DB_WRITE_LOCK, self._conn() as conn:
|
|
350
|
+
existing = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
|
|
351
|
+
# Flash modes need to know which disk to overwrite. The live
|
|
352
|
+
# env matches ``target_disk_serial`` against currently-attached
|
|
353
|
+
# disks at flash time; a bind that doesn't name one would
|
|
354
|
+
# either fall through to "flash /dev/sda blindly" (dangerous)
|
|
355
|
+
# or refuse (silent no-op). Reject early so the operator sees
|
|
356
|
+
# a 422 pointing at the missing prerequisite, and require the
|
|
357
|
+
# value be one of the serials on the machine's stored
|
|
358
|
+
# inventory so a hand-typed sha doesn't sneak past. New /
|
|
359
|
+
# never-inventoried MACs are told to run pixie-inventory
|
|
360
|
+
# first.
|
|
361
|
+
if boot_mode in _FLASH_MODES:
|
|
362
|
+
inv_disks = _inventory_disk_serials(existing)
|
|
363
|
+
if not inv_disks:
|
|
364
|
+
raise ValueError(
|
|
365
|
+
f"boot_mode={boot_mode!r} requires a target_disk_serial, "
|
|
366
|
+
"but this machine has no inventory yet. "
|
|
367
|
+
"Bind boot_mode=pixie-inventory + power-cycle first."
|
|
368
|
+
)
|
|
369
|
+
if not target_serial:
|
|
370
|
+
raise ValueError(
|
|
371
|
+
f"boot_mode={boot_mode!r} requires target_disk_serial "
|
|
372
|
+
f"(one of: {sorted(inv_disks)})"
|
|
373
|
+
)
|
|
374
|
+
if target_serial not in inv_disks:
|
|
375
|
+
raise ValueError(
|
|
376
|
+
f"target_disk_serial={target_serial!r} is not in this "
|
|
377
|
+
f"machine's inventory (known: {sorted(inv_disks)})"
|
|
378
|
+
)
|
|
379
|
+
if existing is None:
|
|
380
|
+
conn.execute(
|
|
381
|
+
"""
|
|
382
|
+
INSERT INTO machines (
|
|
383
|
+
mac, boot_mode, image_content_sha256,
|
|
384
|
+
labels, target_disk_serial, extra_cmdline,
|
|
385
|
+
discovered_at, last_seen_at, last_seen_ip, updated_at
|
|
386
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, '', ?)
|
|
387
|
+
""",
|
|
388
|
+
(
|
|
389
|
+
canon,
|
|
390
|
+
boot_mode,
|
|
391
|
+
image_content_sha256,
|
|
392
|
+
labels_json,
|
|
393
|
+
target_serial,
|
|
394
|
+
extra,
|
|
395
|
+
now,
|
|
396
|
+
now,
|
|
397
|
+
now,
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
else:
|
|
401
|
+
conn.execute(
|
|
402
|
+
"""
|
|
403
|
+
UPDATE machines
|
|
404
|
+
SET boot_mode = ?, image_content_sha256 = ?,
|
|
405
|
+
labels = ?, target_disk_serial = ?,
|
|
406
|
+
extra_cmdline = ?, updated_at = ?
|
|
407
|
+
WHERE mac = ?
|
|
408
|
+
""",
|
|
409
|
+
(
|
|
410
|
+
boot_mode,
|
|
411
|
+
image_content_sha256,
|
|
412
|
+
labels_json,
|
|
413
|
+
target_serial,
|
|
414
|
+
extra,
|
|
415
|
+
now,
|
|
416
|
+
canon,
|
|
417
|
+
),
|
|
418
|
+
)
|
|
419
|
+
row = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
|
|
420
|
+
return _row(row)
|
|
421
|
+
|
|
422
|
+
def touch_seen(self, mac: str, *, ip: str = "") -> Machine:
|
|
423
|
+
"""Discovery-side write: create-or-update ``last_seen_at`` +
|
|
424
|
+
optionally ``last_seen_ip``. Does NOT touch operator fields."""
|
|
425
|
+
canon = normalise_mac(mac)
|
|
426
|
+
now = now_iso()
|
|
427
|
+
with _DB_WRITE_LOCK, self._conn() as conn:
|
|
428
|
+
existing = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
|
|
429
|
+
if existing is None:
|
|
430
|
+
conn.execute(
|
|
431
|
+
"""
|
|
432
|
+
INSERT INTO machines (
|
|
433
|
+
mac, boot_mode, image_content_sha256,
|
|
434
|
+
discovered_at, last_seen_at, last_seen_ip, updated_at
|
|
435
|
+
) VALUES (?, ?, '', ?, ?, ?, ?)
|
|
436
|
+
""",
|
|
437
|
+
(canon, DEFAULT_BOOT_MODE, now, now, ip, now),
|
|
438
|
+
)
|
|
439
|
+
else:
|
|
440
|
+
conn.execute(
|
|
441
|
+
"""
|
|
442
|
+
UPDATE machines
|
|
443
|
+
SET last_seen_at = ?, last_seen_ip = ?, updated_at = ?
|
|
444
|
+
WHERE mac = ?
|
|
445
|
+
""",
|
|
446
|
+
(now, ip or existing["last_seen_ip"], now, canon),
|
|
447
|
+
)
|
|
448
|
+
row = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
|
|
449
|
+
return _row(row)
|
|
450
|
+
|
|
451
|
+
def delete(self, mac: str) -> bool:
|
|
452
|
+
canon = normalise_mac(mac)
|
|
453
|
+
with _DB_WRITE_LOCK, self._conn() as conn:
|
|
454
|
+
cur = conn.execute("DELETE FROM machines WHERE mac = ?", (canon,))
|
|
455
|
+
return cur.rowcount > 0
|
|
456
|
+
|
|
457
|
+
def set_labels(self, mac: str, labels: Sequence[str]) -> Machine:
|
|
458
|
+
"""Store operator-supplied labels against the row. Creates the
|
|
459
|
+
row on first contact (mirrors ``touch_seen`` + ``set_inventory``)
|
|
460
|
+
so an operator tagging a machine before it has PXE'd works.
|
|
461
|
+
|
|
462
|
+
``labels`` is a pre-parsed list -- callers run
|
|
463
|
+
:func:`parse_labels` so the form + JSON paths share the
|
|
464
|
+
validator. Passing an empty list CLEARS the labels."""
|
|
465
|
+
canon = normalise_mac(mac)
|
|
466
|
+
now = now_iso()
|
|
467
|
+
labels_json = _labels_to_json(list(labels or []))
|
|
468
|
+
with _DB_WRITE_LOCK, self._conn() as conn:
|
|
469
|
+
existing = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
|
|
470
|
+
if existing is None:
|
|
471
|
+
conn.execute(
|
|
472
|
+
"""
|
|
473
|
+
INSERT INTO machines (
|
|
474
|
+
mac, boot_mode, image_content_sha256,
|
|
475
|
+
labels, discovered_at, last_seen_at, last_seen_ip, updated_at
|
|
476
|
+
) VALUES (?, ?, '', ?, ?, ?, '', ?)
|
|
477
|
+
""",
|
|
478
|
+
(canon, DEFAULT_BOOT_MODE, labels_json, now, now, now),
|
|
479
|
+
)
|
|
480
|
+
else:
|
|
481
|
+
conn.execute(
|
|
482
|
+
"UPDATE machines SET labels = ?, updated_at = ? WHERE mac = ?",
|
|
483
|
+
(labels_json, now, canon),
|
|
484
|
+
)
|
|
485
|
+
row = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
|
|
486
|
+
return _row(row)
|
|
487
|
+
|
|
488
|
+
def set_inventory(self, mac: str, inventory: dict[str, Any]) -> Machine:
|
|
489
|
+
"""Store a fresh inventory blob against the machine row. Creates
|
|
490
|
+
the row on first contact (mirrors touch_seen shape) so a bare
|
|
491
|
+
POST from a live env's first boot lands correctly."""
|
|
492
|
+
import json as _json
|
|
493
|
+
|
|
494
|
+
canon = normalise_mac(mac)
|
|
495
|
+
now = now_iso()
|
|
496
|
+
blob = _json.dumps(inventory)
|
|
497
|
+
with _DB_WRITE_LOCK, self._conn() as conn:
|
|
498
|
+
existing = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
|
|
499
|
+
if existing is None:
|
|
500
|
+
conn.execute(
|
|
501
|
+
"""
|
|
502
|
+
INSERT INTO machines (
|
|
503
|
+
mac, boot_mode, image_content_sha256,
|
|
504
|
+
inventory_json, inventory_at,
|
|
505
|
+
discovered_at, last_seen_at, last_seen_ip, updated_at
|
|
506
|
+
) VALUES (?, ?, '', ?, ?, ?, ?, '', ?)
|
|
507
|
+
""",
|
|
508
|
+
(canon, DEFAULT_BOOT_MODE, blob, now, now, now, now),
|
|
509
|
+
)
|
|
510
|
+
else:
|
|
511
|
+
conn.execute(
|
|
512
|
+
"""
|
|
513
|
+
UPDATE machines
|
|
514
|
+
SET inventory_json = ?, inventory_at = ?, updated_at = ?
|
|
515
|
+
WHERE mac = ?
|
|
516
|
+
""",
|
|
517
|
+
(blob, now, now, canon),
|
|
518
|
+
)
|
|
519
|
+
row = conn.execute("SELECT * FROM machines WHERE mac = ?", (canon,)).fetchone()
|
|
520
|
+
return _row(row)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _inventory_disk_serials(row: sqlite3.Row | None) -> set[str]:
|
|
524
|
+
"""Pull the set of disk serials off a stored inventory blob. Used
|
|
525
|
+
to check ``target_disk_serial`` against the machine's reported
|
|
526
|
+
hardware at bind time. Returns an empty set when the row is
|
|
527
|
+
missing, the inventory blob is empty / malformed, or none of the
|
|
528
|
+
disks report a serial."""
|
|
529
|
+
import json as _json
|
|
530
|
+
|
|
531
|
+
if row is None:
|
|
532
|
+
return set()
|
|
533
|
+
with contextlib.suppress(IndexError, KeyError):
|
|
534
|
+
raw = row["inventory_json"] or ""
|
|
535
|
+
if not raw:
|
|
536
|
+
return set()
|
|
537
|
+
try:
|
|
538
|
+
parsed = _json.loads(raw)
|
|
539
|
+
except ValueError:
|
|
540
|
+
return set()
|
|
541
|
+
if not isinstance(parsed, dict):
|
|
542
|
+
return set()
|
|
543
|
+
disks = parsed.get("disks") or []
|
|
544
|
+
if not isinstance(disks, list):
|
|
545
|
+
return set()
|
|
546
|
+
out: set[str] = set()
|
|
547
|
+
for d in disks:
|
|
548
|
+
if not isinstance(d, dict):
|
|
549
|
+
continue
|
|
550
|
+
serial = str(d.get("serial") or "").strip()
|
|
551
|
+
if serial:
|
|
552
|
+
out.add(serial)
|
|
553
|
+
return out
|
|
554
|
+
return set()
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _labels_to_json(labels: list[str]) -> str:
|
|
558
|
+
"""Serialise a validated label list to a JSON array string. Empty
|
|
559
|
+
list -> ``''`` so the DB stores a single trivially-checkable form."""
|
|
560
|
+
import json as _json
|
|
561
|
+
|
|
562
|
+
return _json.dumps(list(labels)) if labels else ""
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _labels_from_json(raw: str) -> list[str]:
|
|
566
|
+
"""Parse a stored labels JSON string. Malformed values fall back to
|
|
567
|
+
an empty list -- the migration path may leave a legacy row with
|
|
568
|
+
unexpected shape and pre-1.0 pixie tolerates it rather than 500'ing
|
|
569
|
+
the machines page."""
|
|
570
|
+
import json as _json
|
|
571
|
+
|
|
572
|
+
if not raw:
|
|
573
|
+
return []
|
|
574
|
+
try:
|
|
575
|
+
parsed = _json.loads(raw)
|
|
576
|
+
except ValueError:
|
|
577
|
+
return []
|
|
578
|
+
if not isinstance(parsed, list):
|
|
579
|
+
return []
|
|
580
|
+
return [str(x) for x in parsed if isinstance(x, str)]
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _row(r: sqlite3.Row) -> Machine:
|
|
584
|
+
import json as _json
|
|
585
|
+
|
|
586
|
+
inv: dict[str, Any] = {}
|
|
587
|
+
inv_at = ""
|
|
588
|
+
labels: list[str] = []
|
|
589
|
+
target_serial = ""
|
|
590
|
+
# New columns post-migration; older schema pre-v0.9 may not
|
|
591
|
+
# have them yet. sqlite3.Row raises IndexError on missing keys.
|
|
592
|
+
with contextlib.suppress(IndexError, KeyError):
|
|
593
|
+
raw = r["inventory_json"] or ""
|
|
594
|
+
if raw:
|
|
595
|
+
try:
|
|
596
|
+
parsed = _json.loads(raw)
|
|
597
|
+
except ValueError:
|
|
598
|
+
parsed = None
|
|
599
|
+
if isinstance(parsed, dict):
|
|
600
|
+
inv = parsed
|
|
601
|
+
with contextlib.suppress(IndexError, KeyError):
|
|
602
|
+
inv_at = r["inventory_at"] or ""
|
|
603
|
+
with contextlib.suppress(IndexError, KeyError):
|
|
604
|
+
labels = _labels_from_json(r["labels"] or "")
|
|
605
|
+
with contextlib.suppress(IndexError, KeyError):
|
|
606
|
+
target_serial = r["target_disk_serial"] or ""
|
|
607
|
+
extra_cmdline = ""
|
|
608
|
+
with contextlib.suppress(IndexError, KeyError):
|
|
609
|
+
extra_cmdline = r["extra_cmdline"] or ""
|
|
610
|
+
return Machine(
|
|
611
|
+
mac=r["mac"],
|
|
612
|
+
boot_mode=r["boot_mode"],
|
|
613
|
+
image_content_sha256=r["image_content_sha256"],
|
|
614
|
+
labels=labels,
|
|
615
|
+
target_disk_serial=target_serial,
|
|
616
|
+
extra_cmdline=extra_cmdline,
|
|
617
|
+
inventory=inv,
|
|
618
|
+
inventory_at=inv_at,
|
|
619
|
+
discovered_at=r["discovered_at"],
|
|
620
|
+
last_seen_at=r["last_seen_at"],
|
|
621
|
+
last_seen_ip=r["last_seen_ip"],
|
|
622
|
+
updated_at=r["updated_at"],
|
|
623
|
+
)
|