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,234 @@
1
+ """Catalog store: SQLite-backed metadata + content-addressed blobs
2
+ and artifacts on the filesystem.
3
+
4
+ One ``state.db`` under ``<state_dir>`` holds every catalog entry;
5
+ blobs land at ``<state_dir>/blobs/<content_sha256>/blob`` and the
6
+ tar.gz-unpacked netboot artifacts at
7
+ ``<state_dir>/artifacts/<content_sha256>/{vmlinuz,initrd,manifest.json}``.
8
+
9
+ Both paths are content-addressed: the same bytes served under
10
+ different catalog names share on-disk storage, and an entry's blob
11
+ URL (``/b/<sha>/<name>``) never changes across renames as long as the
12
+ content is stable. This is a deliberate departure from withcache's
13
+ URL-addressed store; pixie is an operator-curated library, not a
14
+ URL->bytes cache.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import contextlib
20
+ import sqlite3
21
+ import threading
22
+ from collections.abc import Generator
23
+ from pathlib import Path
24
+
25
+ from pixie._util import now_iso
26
+ from pixie.catalog._schema import CatalogEntry
27
+
28
+ _DB_WRITE_LOCK = threading.Lock()
29
+
30
+ # SQLite schema. Migrations for pixie land as ``CREATE TABLE IF NOT
31
+ # EXISTS`` at ``open_store`` time, per bty's pattern. Pre-1.0: if a
32
+ # schema change is required and the migration would be complex, we
33
+ # rotate ``state.db`` -> ``state.db.<oldver>.<ts>.bak`` and start
34
+ # clean rather than write migration SQL. (Documented ergonomics
35
+ # tradeoff -- Cf. bty v0.25.0.)
36
+ _SCHEMA = """
37
+ CREATE TABLE IF NOT EXISTS catalog_entries (
38
+ name TEXT PRIMARY KEY,
39
+ src TEXT NOT NULL,
40
+ format TEXT NOT NULL,
41
+ arch TEXT NOT NULL DEFAULT '',
42
+ description TEXT NOT NULL DEFAULT '',
43
+ netboot_src TEXT NOT NULL DEFAULT '',
44
+ content_sha256 TEXT NOT NULL DEFAULT '',
45
+ size_bytes INTEGER NOT NULL DEFAULT 0,
46
+ fetched_at TEXT NOT NULL DEFAULT '',
47
+ added_at TEXT NOT NULL,
48
+ extra_json TEXT NOT NULL DEFAULT '{}'
49
+ );
50
+
51
+ CREATE INDEX IF NOT EXISTS idx_catalog_entries_src ON catalog_entries(src);
52
+ CREATE INDEX IF NOT EXISTS idx_catalog_entries_content_sha
53
+ ON catalog_entries(content_sha256);
54
+ """
55
+
56
+
57
+ class CatalogStore:
58
+ """Thin repository over ``state.db`` for catalog rows.
59
+
60
+ Blob + artifact storage on the filesystem is siblings of the DB
61
+ (``<state_dir>/{blobs,artifacts}/``); the store owns their
62
+ directory-shape but does NOT own the fetch pipeline that fills
63
+ them -- that lives in :mod:`pixie.catalog._fetcher`.
64
+ """
65
+
66
+ def __init__(self, state_dir: Path) -> None:
67
+ self.state_dir = Path(state_dir).resolve()
68
+ self.state_dir.mkdir(parents=True, exist_ok=True)
69
+ self.db_path = self.state_dir / "state.db"
70
+ self.blobs_dir = self.state_dir / "blobs"
71
+ self.artifacts_dir = self.state_dir / "artifacts"
72
+ self.blobs_dir.mkdir(exist_ok=True)
73
+ self.artifacts_dir.mkdir(exist_ok=True)
74
+ self._ensure_schema()
75
+
76
+ # ---------- schema init ---------------------------------------
77
+
78
+ def _ensure_schema(self) -> None:
79
+ with self._conn() as conn:
80
+ conn.executescript(_SCHEMA)
81
+
82
+ @contextlib.contextmanager
83
+ def _conn(self) -> Generator[sqlite3.Connection]:
84
+ conn = sqlite3.connect(str(self.db_path))
85
+ conn.row_factory = sqlite3.Row
86
+ try:
87
+ yield conn
88
+ conn.commit()
89
+ finally:
90
+ conn.close()
91
+
92
+ # ---------- entry CRUD ----------------------------------------
93
+
94
+ def list_entries(self) -> list[CatalogEntry]:
95
+ with self._conn() as conn:
96
+ rows = conn.execute("SELECT * FROM catalog_entries ORDER BY LOWER(name)").fetchall()
97
+ return [_row_to_entry(r) for r in rows]
98
+
99
+ def get_entry(self, name: str) -> CatalogEntry | None:
100
+ with self._conn() as conn:
101
+ row = conn.execute("SELECT * FROM catalog_entries WHERE name = ?", (name,)).fetchone()
102
+ return _row_to_entry(row) if row else None
103
+
104
+ def get_entry_by_src(self, src: str) -> CatalogEntry | None:
105
+ """Look up an entry by its ``src`` URL. This is the pairing key
106
+ for ``netboot_src`` cross-reference: pixie resolves a
107
+ disk-image entry's netboot bundle by matching src, never by
108
+ name."""
109
+ with self._conn() as conn:
110
+ row = conn.execute("SELECT * FROM catalog_entries WHERE src = ?", (src,)).fetchone()
111
+ return _row_to_entry(row) if row else None
112
+
113
+ def upsert(self, entry: CatalogEntry) -> None:
114
+ """Insert or update an entry by name. Uses INSERT OR REPLACE so
115
+ editing an entry doesn't leave orphan rows; a full row write
116
+ is cheap at catalog sizes."""
117
+ import json as _json
118
+
119
+ with _DB_WRITE_LOCK, self._conn() as conn:
120
+ conn.execute(
121
+ """
122
+ INSERT OR REPLACE INTO catalog_entries (
123
+ name, src, format, arch, description, netboot_src,
124
+ content_sha256, size_bytes, fetched_at, added_at,
125
+ extra_json
126
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
127
+ """,
128
+ (
129
+ entry.name,
130
+ entry.src,
131
+ entry.format,
132
+ entry.arch,
133
+ entry.description,
134
+ entry.netboot_src,
135
+ entry.content_sha256,
136
+ entry.size_bytes,
137
+ entry.fetched_at,
138
+ entry.added_at,
139
+ _json.dumps(entry.extra),
140
+ ),
141
+ )
142
+
143
+ def delete(self, name: str) -> bool:
144
+ """Remove an entry by name. Returns True iff a row was removed.
145
+ Blob + artifact bytes are NOT deleted here; they may be shared
146
+ by other entries with the same content_sha256. GC (refcount
147
+ walk) lives on a caller in the routes layer."""
148
+ with _DB_WRITE_LOCK, self._conn() as conn:
149
+ cur = conn.execute("DELETE FROM catalog_entries WHERE name = ?", (name,))
150
+ return cur.rowcount > 0
151
+
152
+ def mark_unfetched(self, name: str) -> None:
153
+ """Reverse of :meth:`mark_fetched`: clear the row's fetched
154
+ fields (content sha, size, timestamp) without deleting the
155
+ row itself. Used by the "delete blob but keep the entry"
156
+ path so a subsequent Fetch re-runs the pipeline instead of
157
+ needing the operator to re-Import the URL."""
158
+ with _DB_WRITE_LOCK, self._conn() as conn:
159
+ conn.execute(
160
+ """
161
+ UPDATE catalog_entries
162
+ SET content_sha256 = '', size_bytes = 0, fetched_at = ''
163
+ WHERE name = ?
164
+ """,
165
+ (name,),
166
+ )
167
+
168
+ def mark_fetched(
169
+ self,
170
+ name: str,
171
+ *,
172
+ content_sha256: str,
173
+ size_bytes: int,
174
+ ) -> None:
175
+ """Post-fetch update: record content sha + size + timestamp.
176
+ Failure (no matching row) is silent because the fetch pipeline
177
+ may race with a concurrent delete; the fetched blob is
178
+ content-addressed so it's not orphaned in a bad way."""
179
+ with _DB_WRITE_LOCK, self._conn() as conn:
180
+ conn.execute(
181
+ """
182
+ UPDATE catalog_entries
183
+ SET content_sha256 = ?, size_bytes = ?, fetched_at = ?
184
+ WHERE name = ?
185
+ """,
186
+ (content_sha256, size_bytes, now_iso(), name),
187
+ )
188
+
189
+ # ---------- content-addressed storage paths -------------------
190
+
191
+ def blob_path(self, content_sha256: str) -> Path:
192
+ """Where a fetched disk-image blob lives on disk."""
193
+ return self.blobs_dir / content_sha256 / "blob"
194
+
195
+ def artifact_dir(self, content_sha256: str) -> Path:
196
+ """Where an unpacked netboot bundle's files live on disk."""
197
+ return self.artifacts_dir / content_sha256
198
+
199
+ def artifact_path(self, content_sha256: str, filename: str) -> Path:
200
+ return self.artifact_dir(content_sha256) / filename
201
+
202
+ def content_shas_in_use(self) -> set[str]:
203
+ """The union of content_sha256 values referenced by any
204
+ catalog row. Used to compute GC candidates."""
205
+ with self._conn() as conn:
206
+ rows = conn.execute(
207
+ "SELECT DISTINCT content_sha256 FROM catalog_entries WHERE content_sha256 != ''"
208
+ ).fetchall()
209
+ return {str(r["content_sha256"]) for r in rows}
210
+
211
+
212
+ def _row_to_entry(row: sqlite3.Row) -> CatalogEntry:
213
+ import json as _json
214
+
215
+ try:
216
+ extra = _json.loads(row["extra_json"] or "{}")
217
+ if not isinstance(extra, dict):
218
+ extra = {}
219
+ except _json.JSONDecodeError:
220
+ extra = {}
221
+
222
+ return CatalogEntry(
223
+ name=row["name"],
224
+ src=row["src"],
225
+ format=row["format"],
226
+ arch=row["arch"],
227
+ description=row["description"],
228
+ netboot_src=row["netboot_src"],
229
+ content_sha256=row["content_sha256"],
230
+ size_bytes=row["size_bytes"] or 0,
231
+ fetched_at=row["fetched_at"],
232
+ added_at=row["added_at"],
233
+ extra=extra,
234
+ )
@@ -0,0 +1,26 @@
1
+ """pixie-lab: deploy generator + operator convenience CLI.
2
+
3
+ ``pixie-lab init [dest]`` emits a ready-to-run compose deployment for
4
+ pixie: a ``compose.yml`` with one service on ``--network=host``, an
5
+ ``envvars.example`` with the settings an operator MUST fill (admin
6
+ password, host address for LAN visibility), a ``data/`` scaffold and
7
+ a README pointing at ``PLAN.md``.
8
+
9
+ ``pixie-lab deploy [dest]`` builds on init: auto-fills envvars with a
10
+ detected LAN IP + generated password, then runs ``podman compose
11
+ up -d`` and waits for ``/healthz`` to answer 200.
12
+
13
+ ``pixie-lab purge [dest]`` tears the stack down (``podman compose
14
+ down -v``) so a fresh ``deploy`` on the same dir starts clean.
15
+
16
+ Kept deliberately shallower than bty-lab: pixie is one container, so
17
+ the whole file is measured in hundreds of LOC, not thousands. The
18
+ extra knobs (Quadlet emission, upgrade paths, backup / restore) land
19
+ in a follow-up if operators ask.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from pixie.deploy._main import main
25
+
26
+ __all__ = ["main"]
pixie/deploy/_main.py ADDED
@@ -0,0 +1,321 @@
1
+ """pixie-lab CLI entry.
2
+
3
+ Subcommands mirror bty-lab's shape to keep operator muscle memory:
4
+
5
+ pixie-lab init [dest] write compose.yml + envvars + README
6
+ pixie-lab deploy [dest] init + auto-fill envvars + podman compose up + wait /healthz
7
+ pixie-lab purge [dest] podman compose down -v; optionally --wipe-data
8
+
9
+ Every subcommand takes an optional ``dest`` argument (defaults to the
10
+ current directory). The ``deploy`` and ``purge`` subcommands shell out
11
+ to ``podman compose`` (or ``docker compose``, or ``podman-compose``,
12
+ whichever is on PATH) so pixie-lab does not carry its own container
13
+ runtime.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import http.client
20
+ import os
21
+ import secrets
22
+ import shutil
23
+ import socket
24
+ import subprocess
25
+ import sys
26
+ import time
27
+ import urllib.error
28
+ import urllib.request
29
+ from pathlib import Path
30
+
31
+ import pixie
32
+ from pixie.deploy._templates import (
33
+ DEFAULT_ADMIN_PASSWORD,
34
+ DEFAULT_IMAGE_REPO,
35
+ compose_yaml,
36
+ envvars_example,
37
+ readme_md,
38
+ )
39
+
40
+ DEFAULT_DEST = Path.cwd()
41
+ _HEALTHZ_TIMEOUT = 60.0
42
+
43
+
44
+ # ---------- filesystem writes ------------------------------------------
45
+
46
+
47
+ def _write(path: Path, body: str, *, force: bool) -> Path:
48
+ """Write ``body`` to ``path``, refusing to overwrite unless
49
+ ``force`` is set. Creates parent dirs on the way. Returns the
50
+ absolute path so the caller can print a friendly summary."""
51
+ if path.exists() and not force:
52
+ raise FileExistsError(f"{path} already exists; pass --force to overwrite")
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ path.write_text(body, encoding="utf-8")
55
+ return path
56
+
57
+
58
+ def _emit_files(dest: Path, *, image: str, admin_password: str, force: bool) -> list[Path]:
59
+ """Lay down the three canonical files + create the ``data/``
60
+ scaffold. Returns the list of written paths."""
61
+ compose = _write(
62
+ dest / "compose.yml", compose_yaml(image=image, admin_password=admin_password), force=force
63
+ )
64
+ envvars = _write(
65
+ dest / "envvars.example", envvars_example(admin_password=admin_password), force=force
66
+ )
67
+ readme = _write(dest / "README.md", readme_md(), force=force)
68
+ (dest / "data").mkdir(parents=True, exist_ok=True)
69
+ return [compose, envvars, readme]
70
+
71
+
72
+ # ---------- host address + password detection --------------------------
73
+
74
+
75
+ def detect_host_addr() -> str:
76
+ """LAN-facing IP by UDP-connect probe (no packet is sent; the
77
+ kernel just chooses an outbound interface). Falls back to
78
+ 127.0.0.1 when no route is available. Same trick bty uses."""
79
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
80
+ s.settimeout(2)
81
+ try:
82
+ s.connect(("198.51.100.1", 80))
83
+ addr: str = s.getsockname()[0]
84
+ return addr
85
+ except OSError:
86
+ return "127.0.0.1"
87
+ finally:
88
+ s.close()
89
+
90
+
91
+ def gen_admin_password(nbytes: int = 24) -> str:
92
+ """URL-safe random token to hand the operator on first deploy."""
93
+ return secrets.token_urlsafe(nbytes)
94
+
95
+
96
+ # ---------- compose runner detection -----------------------------------
97
+
98
+
99
+ def _compose_cmd() -> list[str] | None:
100
+ """Return the compose invocation to shell out to, or ``None`` when
101
+ no supported runner is on PATH."""
102
+ if shutil.which("podman-compose"):
103
+ return ["podman-compose"]
104
+ if shutil.which("podman") and shutil.which("docker-compose") is None:
105
+ # ``podman compose`` shells out to a provider; keep it simple.
106
+ return ["podman", "compose"]
107
+ if shutil.which("docker"):
108
+ return ["docker", "compose"]
109
+ return None
110
+
111
+
112
+ def _compose_up(dest: Path, envvars: Path) -> None:
113
+ cmd = _compose_cmd()
114
+ if cmd is None:
115
+ raise RuntimeError(
116
+ "no compose runner on PATH (need one of: podman-compose, podman, docker)"
117
+ )
118
+ try:
119
+ subprocess.run(
120
+ [*cmd, "--env-file", str(envvars), "up", "-d"],
121
+ cwd=str(dest),
122
+ check=True,
123
+ capture_output=True,
124
+ text=True,
125
+ )
126
+ except subprocess.CalledProcessError as exc:
127
+ raise RuntimeError(
128
+ f"compose up failed (rc={exc.returncode}):\n"
129
+ f"--- stdout ---\n{exc.stdout}\n--- stderr ---\n{exc.stderr}"
130
+ ) from exc
131
+
132
+
133
+ def _compose_down(dest: Path, envvars: Path, *, wipe_data: bool) -> None:
134
+ cmd = _compose_cmd()
135
+ if cmd is None:
136
+ raise RuntimeError(
137
+ "no compose runner on PATH (need one of: podman-compose, podman, docker)"
138
+ )
139
+ args = [*cmd, "--env-file", str(envvars), "down"]
140
+ if wipe_data:
141
+ args.append("-v")
142
+ subprocess.run(args, cwd=str(dest), check=False, capture_output=True)
143
+
144
+
145
+ def _wait_healthz(host: str, port: int, deadline: float) -> None:
146
+ url = f"http://{host}:{port}/healthz"
147
+ last_err = ""
148
+ while time.monotonic() < deadline:
149
+ try:
150
+ with urllib.request.urlopen(url, timeout=2.0) as resp:
151
+ if resp.status == 200:
152
+ return
153
+ last_err = f"HTTP {resp.status}"
154
+ except (urllib.error.URLError, http.client.HTTPException, OSError) as exc:
155
+ last_err = str(exc)
156
+ time.sleep(1.0)
157
+ raise RuntimeError(f"healthz timeout on {url}: {last_err}")
158
+
159
+
160
+ # ---------- subcommands ------------------------------------------------
161
+
162
+
163
+ def _cmd_init(args: argparse.Namespace) -> int:
164
+ dest = Path(args.dest).resolve()
165
+ image = args.image or f"{DEFAULT_IMAGE_REPO}:{pixie.__version__}"
166
+ admin_pw = args.admin_password or DEFAULT_ADMIN_PASSWORD
167
+ try:
168
+ written = _emit_files(dest, image=image, admin_password=admin_pw, force=args.force)
169
+ except FileExistsError as exc:
170
+ print(f"pixie-lab init: {exc}", file=sys.stderr)
171
+ return 1
172
+ for p in written:
173
+ print(f" {p.relative_to(dest.parent) if dest.parent != Path() else p}", file=sys.stderr)
174
+ print(
175
+ f"\nNext:\n"
176
+ f" cd {dest}\n"
177
+ f' cp envvars.example envvars && "${{EDITOR:-vi}}" envvars\n'
178
+ f" COMPOSE_ENV_FILES=envvars podman compose up -d\n",
179
+ file=sys.stderr,
180
+ )
181
+ return 0
182
+
183
+
184
+ def _cmd_deploy(args: argparse.Namespace) -> int:
185
+ dest = Path(args.dest).resolve()
186
+ image = args.image or f"{DEFAULT_IMAGE_REPO}:{pixie.__version__}"
187
+ admin_pw = args.admin_password or gen_admin_password()
188
+ host_addr = args.host_addr or detect_host_addr()
189
+
190
+ try:
191
+ _emit_files(dest, image=image, admin_password=admin_pw, force=args.force)
192
+ except FileExistsError as exc:
193
+ print(f"pixie-lab deploy: {exc}", file=sys.stderr)
194
+ return 1
195
+
196
+ # Realise envvars from envvars.example + operator-detected
197
+ # host_addr + admin password.
198
+ envvars = dest / "envvars"
199
+ envvars.write_text(
200
+ f"PIXIE_HOST_ADDR={host_addr}\nPIXIE_ADMIN_PASSWORD={admin_pw}\n",
201
+ encoding="utf-8",
202
+ )
203
+
204
+ print(
205
+ f"pixie-lab deploy:\n"
206
+ f" dest : {dest}\n"
207
+ f" image : {image}\n"
208
+ f" host_addr : {host_addr}\n"
209
+ f" admin_pass : {admin_pw}\n",
210
+ file=sys.stderr,
211
+ )
212
+
213
+ try:
214
+ _compose_up(dest, envvars)
215
+ _wait_healthz("127.0.0.1", 8080, time.monotonic() + _HEALTHZ_TIMEOUT)
216
+ except RuntimeError as exc:
217
+ print(f"pixie-lab deploy: {exc}", file=sys.stderr)
218
+ return 1
219
+ print(f"pixie is up at http://{host_addr}:8080/ui/", file=sys.stderr)
220
+ return 0
221
+
222
+
223
+ def _cmd_purge(args: argparse.Namespace) -> int:
224
+ dest = Path(args.dest).resolve()
225
+ envvars = dest / "envvars"
226
+ if not envvars.exists():
227
+ # No envvars means no compose to pull down. Still let the
228
+ # operator wipe data if they asked for it.
229
+ if args.wipe_data:
230
+ data_dir = dest / "data"
231
+ if data_dir.exists():
232
+ shutil.rmtree(data_dir)
233
+ print(f"pixie-lab purge: removed {data_dir}", file=sys.stderr)
234
+ return 0
235
+ try:
236
+ _compose_down(dest, envvars, wipe_data=args.wipe_data)
237
+ except RuntimeError as exc:
238
+ print(f"pixie-lab purge: {exc}", file=sys.stderr)
239
+ return 1
240
+ return 0
241
+
242
+
243
+ # ---------- CLI wiring -------------------------------------------------
244
+
245
+
246
+ def _build_parser() -> argparse.ArgumentParser:
247
+ p = argparse.ArgumentParser(
248
+ prog="pixie-lab",
249
+ description=(
250
+ "pixie deploy generator + convenience CLI. See PLAN.md at "
251
+ "github.com/safl/pixie for the design rationale."
252
+ ),
253
+ )
254
+ sub = p.add_subparsers(dest="cmd", required=True)
255
+
256
+ def _add_common(sp: argparse.ArgumentParser) -> None:
257
+ sp.add_argument(
258
+ "dest",
259
+ nargs="?",
260
+ default=str(DEFAULT_DEST),
261
+ help="deploy directory (default: cwd)",
262
+ )
263
+ sp.add_argument(
264
+ "--image",
265
+ default=None,
266
+ help=f"container image (default: {DEFAULT_IMAGE_REPO}:<pixie-version>)",
267
+ )
268
+ sp.add_argument(
269
+ "--admin-password",
270
+ default=None,
271
+ help="operator UI + write-route password (init: default pixie; deploy: random)",
272
+ )
273
+ sp.add_argument(
274
+ "--force",
275
+ action="store_true",
276
+ help="overwrite existing files under dest",
277
+ )
278
+
279
+ p_init = sub.add_parser("init", help="write compose.yml + envvars.example + README + data/")
280
+ _add_common(p_init)
281
+ p_init.set_defaults(func=_cmd_init)
282
+
283
+ p_deploy = sub.add_parser(
284
+ "deploy",
285
+ help="init + fill envvars + podman compose up -d + wait /healthz",
286
+ )
287
+ _add_common(p_deploy)
288
+ p_deploy.add_argument(
289
+ "--host-addr",
290
+ default=None,
291
+ help="LAN address pixie advertises (default: auto-detected)",
292
+ )
293
+ p_deploy.set_defaults(func=_cmd_deploy)
294
+
295
+ p_purge = sub.add_parser("purge", help="podman compose down [--wipe-data drops the volume]")
296
+ p_purge.add_argument(
297
+ "dest",
298
+ nargs="?",
299
+ default=str(DEFAULT_DEST),
300
+ help="deploy directory (default: cwd)",
301
+ )
302
+ p_purge.add_argument(
303
+ "--wipe-data",
304
+ action="store_true",
305
+ help="also delete the ``data/`` subdirectory",
306
+ )
307
+ p_purge.set_defaults(func=_cmd_purge)
308
+ return p
309
+
310
+
311
+ def main(argv: list[str] | None = None) -> int:
312
+ parser = _build_parser()
313
+ args = parser.parse_args(argv if argv is not None else sys.argv[1:])
314
+ return int(args.func(args))
315
+
316
+
317
+ if __name__ == "__main__": # pragma: no cover -- exercised via console-script
318
+ sys.exit(main())
319
+
320
+
321
+ _ = os.environ # silence unused-import warnings in case future paths grow env reads