withcache 0.7.1__tar.gz → 0.7.2__tar.gz

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 (26) hide show
  1. {withcache-0.7.1 → withcache-0.7.2}/PKG-INFO +1 -1
  2. {withcache-0.7.1 → withcache-0.7.2}/shim/build.zig.zon +1 -1
  3. {withcache-0.7.1 → withcache-0.7.2}/src/withcache/__init__.py +1 -1
  4. {withcache-0.7.1 → withcache-0.7.2}/src/withcache/server.py +379 -57
  5. {withcache-0.7.1 → withcache-0.7.2}/.gitignore +0 -0
  6. {withcache-0.7.1 → withcache-0.7.2}/LICENSE +0 -0
  7. {withcache-0.7.1 → withcache-0.7.2}/README.md +0 -0
  8. {withcache-0.7.1 → withcache-0.7.2}/deploy/Containerfile +0 -0
  9. {withcache-0.7.1 → withcache-0.7.2}/deploy/compose.yml +0 -0
  10. {withcache-0.7.1 → withcache-0.7.2}/hatch_build.py +0 -0
  11. {withcache-0.7.1 → withcache-0.7.2}/pyproject.toml +0 -0
  12. {withcache-0.7.1 → withcache-0.7.2}/shim/build.zig +0 -0
  13. {withcache-0.7.1 → withcache-0.7.2}/shim/shim.zig +0 -0
  14. {withcache-0.7.1 → withcache-0.7.2}/src/withcache/_shim.py +0 -0
  15. {withcache-0.7.1 → withcache-0.7.2}/src/withcache/client.py +0 -0
  16. {withcache-0.7.1 → withcache-0.7.2}/src/withcache/curlwithcache.py +0 -0
  17. {withcache-0.7.1 → withcache-0.7.2}/src/withcache/oras.py +0 -0
  18. {withcache-0.7.1 → withcache-0.7.2}/src/withcache/static/bootstrap-icons.min.css +0 -0
  19. {withcache-0.7.1 → withcache-0.7.2}/src/withcache/static/bootstrap.min.css +0 -0
  20. {withcache-0.7.1 → withcache-0.7.2}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  21. {withcache-0.7.1 → withcache-0.7.2}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  22. {withcache-0.7.1 → withcache-0.7.2}/src/withcache/static/htmx.min.js +0 -0
  23. {withcache-0.7.1 → withcache-0.7.2}/src/withcache/wgetwithcache.py +0 -0
  24. {withcache-0.7.1 → withcache-0.7.2}/tests/test_differential.py +0 -0
  25. {withcache-0.7.1 → withcache-0.7.2}/tests/test_oras.py +0 -0
  26. {withcache-0.7.1 → withcache-0.7.2}/tests/test_withcache.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.7.1
3
+ Version: 0.7.2
4
4
  Summary: Operator-curated, URL-keyed artifact cache for a small lab (CUDA/ROCm/DOCA/firmware)
5
5
  Project-URL: Homepage, https://github.com/safl/withcache
6
6
  Author-email: "Simon A. F. Lund" <safl@safl.dk>
@@ -2,7 +2,7 @@
2
2
  .name = .withcache_shim,
3
3
  // Zig requires a literal here; keep it in lockstep with the project's
4
4
  // single source (src/withcache/__init__.py) via `make bump` / `make version-check`.
5
- .version = "0.7.1",
5
+ .version = "0.7.2",
6
6
  .fingerprint = 0xd7d96c5ed212ccaa,
7
7
  .minimum_zig_version = "0.16.0",
8
8
  .paths = .{
@@ -17,6 +17,6 @@ All modules are stdlib-only and self-contained.
17
17
  from . import oras
18
18
  from .client import blob_url, cache_base, is_cached, serve_url
19
19
 
20
- __version__ = "0.7.1"
20
+ __version__ = "0.7.2"
21
21
 
22
22
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -152,6 +152,30 @@ def resolve_secret(data_dir: str) -> bytes:
152
152
  DEFAULT_CATALOG_URL = "https://github.com/safl/nosi/releases/latest/download/catalog.toml"
153
153
 
154
154
 
155
+ def _serialise_catalog(entries: list[dict[str, Any]]) -> bytes:
156
+ """Serialise a list of catalog entries back to TOML bytes matching the
157
+ nosi ``version = 1`` + ``[[images]]`` schema. Stdlib-only, no
158
+ ``tomli_w`` dep: the schema is flat so a hand-rolled emitter is
159
+ safer than pulling in a write library. Only known scalar keys are
160
+ emitted; unknown keys are dropped (silently) so an operator-added
161
+ row can't smuggle arbitrary TOML through."""
162
+ out: list[str] = ["version = 1", ""]
163
+ for e in entries:
164
+ out.append("[[images]]")
165
+ for key in ("name", "src", "format", "arch", "sha256"):
166
+ val = e.get(key)
167
+ if val is None or val == "":
168
+ continue
169
+ # Escape backslashes + quotes then wrap in double quotes.
170
+ escaped = str(val).replace("\\", "\\\\").replace('"', '\\"')
171
+ out.append(f'{key} = "{escaped}"')
172
+ size = e.get("size_bytes")
173
+ if isinstance(size, int):
174
+ out.append(f"size_bytes = {size}")
175
+ out.append("")
176
+ return "\n".join(out).encode("utf-8")
177
+
178
+
155
179
  @dataclass
156
180
  class CatalogState:
157
181
  """Live state of the fetched image catalog.
@@ -167,20 +191,40 @@ class CatalogState:
167
191
  so a restart doesn't wipe the last known good catalog and the
168
192
  same file can be re-served verbatim to consumers on a future
169
193
  ``GET /catalog.toml`` route.
194
+
195
+ ``env_url`` records the value pinned via ``$WITHCACHE_CATALOG_URL``
196
+ (empty if unset). The operator can override the effective URL at
197
+ runtime via ``POST /admin/catalog_set_url``; the override is
198
+ persisted to ``<data_dir>/catalog_url`` and wins over the built-in
199
+ default, but the env var still wins over the operator override so
200
+ an operator can't silently unblock a locked-down deploy.
170
201
  """
171
202
 
172
203
  url: str
173
204
  persist_path: str
205
+ env_url: str = ""
206
+ url_override_path: str = ""
174
207
  entries: list[dict[str, Any]] = field(default_factory=list)
175
208
  fetched_at: str = ""
176
209
  last_error: str = ""
210
+ last_info: str = ""
177
211
  _lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
178
212
 
179
213
  def load_persisted(self) -> None:
180
214
  """Best-effort: seed ``entries`` + ``fetched_at`` from the on-disk
181
- ``catalog.toml`` if it exists. Never raises; a corrupt or missing
182
- file leaves the state empty so the operator sees the "not
183
- fetched yet" hint in the dashboard."""
215
+ ``catalog.toml`` if it exists. Also loads the operator URL
216
+ override from ``<data_dir>/catalog_url`` when the env var is
217
+ not set. Never raises; a corrupt or missing file leaves the
218
+ state empty so the operator sees the "not fetched yet" hint
219
+ in the dashboard."""
220
+ if self.url_override_path and os.path.isfile(self.url_override_path) and not self.env_url:
221
+ try:
222
+ with open(self.url_override_path, encoding="utf-8") as f:
223
+ override = f.read().strip()
224
+ if override:
225
+ self.url = override
226
+ except OSError:
227
+ pass
184
228
  if not os.path.isfile(self.persist_path):
185
229
  return
186
230
  try:
@@ -210,13 +254,11 @@ class CatalogState:
210
254
  entries = list(parsed.get("images") or [])
211
255
  # Atomic write: tempfile + rename so a crash mid-write
212
256
  # never leaves a half-written catalog.toml.
213
- tmp = self.persist_path + ".tmp"
214
- with open(tmp, "wb") as f:
215
- f.write(raw)
216
- os.replace(tmp, self.persist_path)
257
+ self._persist_bytes(raw)
217
258
  self.entries = entries
218
259
  self.fetched_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
219
260
  self.last_error = ""
261
+ self.last_info = f"fetched {len(entries)} entries from {self.url}"
220
262
  except (
221
263
  urllib.error.URLError,
222
264
  OSError,
@@ -226,6 +268,109 @@ class CatalogState:
226
268
  ) as e:
227
269
  self.last_error = str(e)
228
270
 
271
+ def set_url_override(self, new_url: str) -> tuple[bool, str]:
272
+ """Persist an operator-set URL override to
273
+ ``<data_dir>/catalog_url``. Env-var pin wins: if
274
+ ``$WITHCACHE_CATALOG_URL`` is set, the override is rejected
275
+ so a locked-down deploy stays locked down. Returns
276
+ ``(ok, message)`` for the UI to surface."""
277
+ if self.env_url:
278
+ return False, "env WITHCACHE_CATALOG_URL is pinned; unset it to allow overrides"
279
+ candidate = new_url.strip()
280
+ if not candidate:
281
+ return False, "url is empty"
282
+ if not candidate.startswith(("http://", "https://")):
283
+ return False, "url must start with http:// or https://"
284
+ with self._lock:
285
+ try:
286
+ if self.url_override_path:
287
+ tmp = self.url_override_path + ".tmp"
288
+ with open(tmp, "w", encoding="utf-8") as f:
289
+ f.write(candidate + "\n")
290
+ os.replace(tmp, self.url_override_path)
291
+ self.url = candidate
292
+ except OSError as e:
293
+ return False, str(e)
294
+ return True, f"catalog url set to {candidate}"
295
+
296
+ def replace_from_bytes(self, raw: bytes, *, source_label: str) -> tuple[bool, str]:
297
+ """Validate + persist an operator-supplied ``catalog.toml``
298
+ payload (from an upload). On success the in-memory ``entries``
299
+ are refreshed and ``last_info`` records the source. On
300
+ validation failure nothing on disk is touched."""
301
+ try:
302
+ parsed = tomllib.loads(raw.decode("utf-8"))
303
+ except (UnicodeDecodeError, tomllib.TOMLDecodeError) as e:
304
+ return False, f"not a valid TOML file: {e}"
305
+ entries = list(parsed.get("images") or [])
306
+ with self._lock:
307
+ try:
308
+ self._persist_bytes(raw)
309
+ except OSError as e:
310
+ return False, str(e)
311
+ self.entries = entries
312
+ self.fetched_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
313
+ self.last_error = ""
314
+ self.last_info = f"loaded {len(entries)} entries from {source_label}"
315
+ return True, self.last_info
316
+
317
+ def add_entry(self, entry: dict[str, Any]) -> tuple[bool, str]:
318
+ """Append a single ``[[images]]`` row to the persisted catalog
319
+ and refresh the in-memory list. ``entry`` must have at least
320
+ ``name`` and ``src``. Duplicate ``name`` replaces the existing
321
+ row (matches how ``bty-web``'s catalog-import handled dupes)."""
322
+ name = str(entry.get("name") or "").strip()
323
+ src = str(entry.get("src") or "").strip()
324
+ if not name:
325
+ return False, "name is required"
326
+ if not src:
327
+ return False, "src is required"
328
+ clean: dict[str, Any] = {"name": name, "src": src}
329
+ for key in ("format", "arch", "sha256"):
330
+ val = entry.get(key)
331
+ if isinstance(val, str) and val.strip():
332
+ clean[key] = val.strip()
333
+ with self._lock:
334
+ new_entries = [e for e in self.entries if str(e.get("name") or "") != name]
335
+ new_entries.append(clean)
336
+ raw = _serialise_catalog(new_entries)
337
+ try:
338
+ self._persist_bytes(raw)
339
+ except OSError as e:
340
+ return False, str(e)
341
+ self.entries = new_entries
342
+ self.last_error = ""
343
+ self.last_info = f"added entry: {name}"
344
+ return True, self.last_info
345
+
346
+ def delete_entry(self, name: str) -> tuple[bool, str]:
347
+ """Remove an entry by ``name``. No-op if absent."""
348
+ target = name.strip()
349
+ if not target:
350
+ return False, "name is required"
351
+ with self._lock:
352
+ new_entries = [e for e in self.entries if str(e.get("name") or "") != target]
353
+ if len(new_entries) == len(self.entries):
354
+ return False, f"no entry named {target!r}"
355
+ raw = _serialise_catalog(new_entries)
356
+ try:
357
+ self._persist_bytes(raw)
358
+ except OSError as e:
359
+ return False, str(e)
360
+ self.entries = new_entries
361
+ self.last_error = ""
362
+ self.last_info = f"deleted entry: {target}"
363
+ return True, self.last_info
364
+
365
+ def _persist_bytes(self, raw: bytes) -> None:
366
+ """Atomic-replace ``self.persist_path`` with ``raw`` via
367
+ tempfile + rename so a crash mid-write can never leave a
368
+ half-written catalog.toml. Callers hold ``self._lock``."""
369
+ tmp = self.persist_path + ".tmp"
370
+ with open(tmp, "wb") as f:
371
+ f.write(raw)
372
+ os.replace(tmp, self.persist_path)
373
+
229
374
 
230
375
  class Auth:
231
376
  COOKIE = "withcache-token"
@@ -878,10 +1023,24 @@ class Handler(http.server.BaseHTTPRequestHandler):
878
1023
  "/admin/cancel",
879
1024
  "/admin/clear",
880
1025
  "/admin/catalog_refresh",
1026
+ "/admin/catalog_set_url",
1027
+ "/admin/catalog_upload",
1028
+ "/admin/catalog_add_entry",
1029
+ "/admin/catalog_delete_entry",
881
1030
  )
882
1031
 
883
1032
  def do_POST(self):
884
1033
  parsed = urllib.parse.urlsplit(self.path)
1034
+ # /admin/catalog_upload reads its own body (multipart); everything
1035
+ # else uses the form parser. Don't try to url-decode multipart
1036
+ # bytes.
1037
+ if parsed.path == "/admin/catalog_upload":
1038
+ if not self.is_authed():
1039
+ self.send_text(401, "login required\n")
1040
+ return
1041
+ self._handle_catalog_upload()
1042
+ self.respond_admin()
1043
+ return
885
1044
  form = self.read_form()
886
1045
  if parsed.path == "/ui/login":
887
1046
  self.handle_login_submit(form)
@@ -907,6 +1066,29 @@ class Handler(http.server.BaseHTTPRequestHandler):
907
1066
  self.mgr.clear_finished()
908
1067
  elif parsed.path == "/admin/catalog_refresh":
909
1068
  self.catalog.fetch_now()
1069
+ elif parsed.path == "/admin/catalog_set_url":
1070
+ ok, msg = self.catalog.set_url_override(form.get("url", ""))
1071
+ if ok:
1072
+ # Trigger an immediate fetch so the operator sees
1073
+ # the new source's entries without a second click.
1074
+ self.catalog.fetch_now()
1075
+ else:
1076
+ self.catalog.last_error = msg
1077
+ elif parsed.path == "/admin/catalog_add_entry":
1078
+ entry = {
1079
+ "name": form.get("name", ""),
1080
+ "src": form.get("src", ""),
1081
+ "format": form.get("format", ""),
1082
+ "arch": form.get("arch", ""),
1083
+ "sha256": form.get("sha256", ""),
1084
+ }
1085
+ ok, msg = self.catalog.add_entry(entry)
1086
+ if not ok:
1087
+ self.catalog.last_error = msg
1088
+ elif parsed.path == "/admin/catalog_delete_entry":
1089
+ ok, msg = self.catalog.delete_entry(form.get("name", ""))
1090
+ if not ok:
1091
+ self.catalog.last_error = msg
910
1092
  self.respond_admin()
911
1093
  else:
912
1094
  self.send_text(404, "not found\n")
@@ -1089,6 +1271,55 @@ class Handler(http.server.BaseHTTPRequestHandler):
1089
1271
  body = self.rfile.read(length).decode("utf-8") if length else ""
1090
1272
  return {k: v[0] for k, v in urllib.parse.parse_qs(body).items()}
1091
1273
 
1274
+ def _handle_catalog_upload(self) -> None:
1275
+ """Parse a multipart/form-data body with a single ``catalog``
1276
+ file part and forward the raw bytes to
1277
+ ``CatalogState.replace_from_bytes``. Stdlib-only: uses
1278
+ ``email.parser.BytesParser`` to walk the parts (avoids the
1279
+ ``cgi`` module which is slated for removal). On success or
1280
+ failure the outcome is recorded in ``last_info`` /
1281
+ ``last_error`` so ``respond_admin`` renders it inline."""
1282
+ content_type = self.headers.get("Content-Type", "") or ""
1283
+ if not content_type.startswith("multipart/"):
1284
+ self.catalog.last_error = "expected multipart/form-data"
1285
+ return
1286
+ length = int(self.headers.get("Content-Length", 0) or 0)
1287
+ if length <= 0:
1288
+ self.catalog.last_error = "empty upload"
1289
+ return
1290
+ body = self.rfile.read(length)
1291
+ # BytesParser needs a full "MIME message" header line so it
1292
+ # knows the Content-Type; prepend it verbatim from the request.
1293
+ prelude = f"MIME-Version: 1.0\r\nContent-Type: {content_type}\r\n\r\n".encode()
1294
+ try:
1295
+ import email.parser
1296
+ import email.policy
1297
+
1298
+ msg = email.parser.BytesParser(policy=email.policy.default).parsebytes(prelude + body)
1299
+ except Exception as e: # noqa: BLE001 -- any parse error is a bad upload
1300
+ self.catalog.last_error = f"multipart parse failed: {e}"
1301
+ return
1302
+ if not msg.is_multipart():
1303
+ self.catalog.last_error = "no multipart parts found"
1304
+ return
1305
+ payload_bytes: bytes | None = None
1306
+ filename = "upload"
1307
+ for part in msg.iter_parts():
1308
+ disp = part.get("Content-Disposition", "") or ""
1309
+ if 'name="catalog"' not in disp:
1310
+ continue
1311
+ payload_bytes = part.get_payload(decode=True)
1312
+ candidate_filename = part.get_filename()
1313
+ if candidate_filename:
1314
+ filename = candidate_filename
1315
+ break
1316
+ if not payload_bytes:
1317
+ self.catalog.last_error = "upload missing 'catalog' file part"
1318
+ return
1319
+ ok, msg_text = self.catalog.replace_from_bytes(payload_bytes, source_label=filename)
1320
+ if not ok:
1321
+ self.catalog.last_error = msg_text
1322
+
1092
1323
  def send_text(self, code: int, text: str):
1093
1324
  data = text.encode("utf-8")
1094
1325
  self.send_response(code)
@@ -1469,41 +1700,41 @@ class Handler(http.server.BaseHTTPRequestHandler):
1469
1700
  </div>
1470
1701
  </nav>
1471
1702
 
1472
- <!-- Sub-nav strip. Carries the five tab pills below the main
1473
- navbar; visually attached (same sticky container, #495057
1474
- background) but rendered STATIC here so tab labels don't
1703
+ <!-- Sub-nav strip. Left side carries the five tab pills below the
1704
+ main navbar; right side carries the page-level "Add from URI"
1705
+ action (bty convention: page-level actions live in the subnav's
1706
+ right-hand subnav-actions slot, not as a full-width card in
1707
+ main). The tab pills are rendered STATIC here so labels don't
1475
1708
  flicker on the 1 Hz htmx dashboard swap. Per-tab counts live
1476
1709
  in the summary line inside the dash content and update on
1477
1710
  every swap. -->
1478
1711
  <div class="subnav-strip">
1479
1712
  <div class="container">
1480
- <ul class="nav nav-pills flex-nowrap flex-md-wrap">
1713
+ <ul class="nav nav-pills flex-nowrap flex-md-wrap m-0">
1481
1714
  <li class="nav-item"><a class="nav-link" href="#tab-cached">Cached</a></li>
1482
1715
  <li class="nav-item"><a class="nav-link" href="#tab-streams">Streams</a></li>
1483
1716
  <li class="nav-item"><a class="nav-link" href="#tab-downloads">Downloads</a></li>
1484
1717
  <li class="nav-item"><a class="nav-link" href="#tab-misses">Misses</a></li>
1485
1718
  <li class="nav-item"><a class="nav-link" href="#tab-catalog">Catalog</a></li>
1486
- <span class="ms-auto"><progress id="spin" class="htmx-indicator"></progress></span>
1487
1719
  </ul>
1720
+ <div class="subnav-actions ms-auto d-flex align-items-center gap-1">
1721
+ <form hx-post="/admin/fetch" hx-target="#dash" hx-swap="innerHTML"
1722
+ hx-indicator="#spin" hx-on::after-request="this.reset()"
1723
+ class="m-0 d-flex align-items-center gap-1">
1724
+ <input class="form-control form-control-sm" type="url" name="url"
1725
+ style="width: 22rem;"
1726
+ placeholder="https://origin/path/artifact.tar.gz" required>
1727
+ <button class="btn btn-sm btn-primary" type="submit" title="Add from URI"
1728
+ >Fetch</button>
1729
+ </form>
1730
+ <progress id="spin" class="htmx-indicator ms-1"
1731
+ style="width:4rem;height:.4rem;"></progress>
1732
+ </div>
1488
1733
  </div>
1489
1734
  </div>
1490
1735
  </div><!-- /.sticky-header -->
1491
1736
 
1492
1737
  <main class="container py-4">
1493
- <div class="card mb-4">
1494
- <div class="card-header"><i class="bi bi-cloud-download text-primary"></i> Add from URI</div>
1495
- <div class="card-body">
1496
- <form hx-post="/admin/fetch" hx-target="#dash" hx-swap="innerHTML"
1497
- hx-indicator="#spin" hx-on::after-request="this.reset()">
1498
- <div class="input-group">
1499
- <input class="form-control" type="url" name="url"
1500
- placeholder="https://origin/path/artifact.tar.gz" required>
1501
- <button class="btn btn-primary" type="submit" style="white-space: nowrap;"
1502
- >Fetch &amp; store</button>
1503
- </div>
1504
- </form>
1505
- </div>
1506
- </div>
1507
1738
 
1508
1739
  <!-- The hx-trigger gates polling on the user NOT having an active
1509
1740
  text selection, so highlight-and-copy a URL out of a table cell
@@ -1750,29 +1981,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
1750
1981
  </section>
1751
1982
 
1752
1983
  <section id="tab-catalog" class="tab{_active("tab-catalog")}">
1753
- <div class="d-flex align-items-center justify-content-between mb-2 flex-wrap gap-2">
1754
- <div>
1755
- <div><small class="text-muted">source</small>
1756
- &nbsp;<code>{html.escape(self.catalog.url)}</code></div>
1757
- <div><small class="text-muted">fetched</small>
1758
- &nbsp;<code>{html.escape(self.catalog.fetched_at) or "never"}</code>
1759
- {
1760
- (
1761
- f'<span class="badge bg-danger-subtle text-danger ms-1">'
1762
- f"{html.escape(self.catalog.last_error)}</span>"
1763
- )
1764
- if self.catalog.last_error
1765
- else ""
1766
- }</div>
1767
- </div>
1768
- <form hx-post="/admin/catalog_refresh" hx-target="#dash" hx-swap="innerHTML"
1769
- hx-indicator="#spin" class="m-0">
1770
- <button class="btn btn-sm btn-primary" type="submit">Refresh</button>
1771
- </form>
1772
- </div>
1984
+ {self._catalog_controls_html()}
1773
1985
  <div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0">
1774
1986
  <thead class="table-light"><tr>
1775
- <th>Name</th><th>Format</th><th>Arch</th><th>Size</th><th>Source</th>
1987
+ <th>Name</th><th>Format</th><th>Arch</th><th>Size</th><th>Source</th><th></th>
1776
1988
  </tr></thead>
1777
1989
  <tbody>{catalog_rows}</tbody>
1778
1990
  </table></div>
@@ -1780,15 +1992,20 @@ class Handler(http.server.BaseHTTPRequestHandler):
1780
1992
 
1781
1993
  def _catalog_rows(self) -> str:
1782
1994
  """One <tr> per catalog image. Cells: name, format, arch,
1783
- human-readable size, and a Source cell whose URL is the
1784
- withcache /b/ token (so a click through pulls via cache).
1785
- Empty catalog rows to a "not fetched yet" placeholder + a
1786
- hint about the Refresh button."""
1995
+ human-readable size, a Source cell whose URL is the
1996
+ withcache /b/ token (so a click through pulls via cache),
1997
+ and a Delete button that removes the entry via
1998
+ ``/admin/catalog_delete_entry``. Empty catalog rows to a
1999
+ "not fetched yet" placeholder + a hint about the Refresh
2000
+ or Fetch controls above."""
1787
2001
  entries = self.catalog.entries
1788
2002
  if not entries:
1789
- hint = self.catalog.last_error or "click Refresh above to fetch the catalog manifest"
2003
+ hint = (
2004
+ self.catalog.last_error
2005
+ or "click Fetch / Refresh above, or upload a catalog.toml, to populate."
2006
+ )
1790
2007
  return (
1791
- '<tr><td colspan="5" class="text-center text-muted">'
2008
+ '<tr><td colspan="6" class="text-center text-muted">'
1792
2009
  f"<em>{html.escape(hint)}</em></td></tr>"
1793
2010
  )
1794
2011
  rows: list[str] = []
@@ -1810,6 +2027,13 @@ class Handler(http.server.BaseHTTPRequestHandler):
1810
2027
  src_cell = f'<a href="{link}" title="{html.escape(src)}">{html.escape(src)}</a>'
1811
2028
  else:
1812
2029
  src_cell = html.escape(src)
2030
+ delete_cell = (
2031
+ '<form hx-post="/admin/catalog_delete_entry" hx-target="#dash" '
2032
+ 'hx-swap="innerHTML" hx-confirm="Delete this catalog entry?" class="m-0">'
2033
+ f'<input type="hidden" name="name" value="{html.escape(name, quote=True)}">'
2034
+ '<button class="btn btn-sm btn-outline-danger" type="submit">Delete</button>'
2035
+ "</form>"
2036
+ )
1813
2037
  rows.append(
1814
2038
  f"<tr>"
1815
2039
  f"<td>{html.escape(name)}</td>"
@@ -1817,10 +2041,103 @@ class Handler(http.server.BaseHTTPRequestHandler):
1817
2041
  f'<td class="mono"><small>{html.escape(arch)}</small></td>'
1818
2042
  f"<td>{html.escape(size)}</td>"
1819
2043
  f'<td class="url">{src_cell}</td>'
2044
+ f"<td>{delete_cell}</td>"
1820
2045
  "</tr>"
1821
2046
  )
1822
2047
  return "".join(rows)
1823
2048
 
2049
+ def _catalog_controls_html(self) -> str:
2050
+ """The top of the Catalog tab: source + fetched-at metadata,
2051
+ the operator control forms (set URL / upload catalog.toml /
2052
+ add single entry), and the outcome banner (last_info or
2053
+ last_error). Split out so ``render_dash`` stays readable."""
2054
+ catalog = self.catalog
2055
+ env_pinned = bool(catalog.env_url)
2056
+ # Outcome banner: preferred order is error (red) > info (green).
2057
+ # Both are transient (overwritten on the next admin action).
2058
+ banner = ""
2059
+ if catalog.last_error:
2060
+ banner = (
2061
+ '<div class="alert alert-danger py-1 px-2 mb-2 small">'
2062
+ f'<i class="bi bi-exclamation-triangle-fill me-1"></i>'
2063
+ f"{html.escape(catalog.last_error)}</div>"
2064
+ )
2065
+ elif catalog.last_info:
2066
+ banner = (
2067
+ '<div class="alert alert-success py-1 px-2 mb-2 small">'
2068
+ f'<i class="bi bi-check-circle-fill me-1"></i>'
2069
+ f"{html.escape(catalog.last_info)}</div>"
2070
+ )
2071
+ url_input_attrs = (
2072
+ 'disabled title="pinned by env WITHCACHE_CATALOG_URL"' if env_pinned else ""
2073
+ )
2074
+ env_hint = (
2075
+ '<span class="badge bg-secondary bg-opacity-10 text-secondary ms-1">env-pinned</span>'
2076
+ if env_pinned
2077
+ else ""
2078
+ )
2079
+ return f"""
2080
+ <div class="row g-2 mb-2">
2081
+ <div class="col-md-6">
2082
+ <div class="d-flex flex-column">
2083
+ <label class="form-label mb-1 small text-muted">Catalog URL {env_hint}</label>
2084
+ <form hx-post="/admin/catalog_set_url" hx-target="#dash" hx-swap="innerHTML"
2085
+ hx-indicator="#spin" class="m-0 d-flex align-items-center gap-1">
2086
+ <input class="form-control form-control-sm" type="url" name="url"
2087
+ value="{html.escape(catalog.url, quote=True)}" required {url_input_attrs}>
2088
+ <button class="btn btn-sm btn-primary" type="submit"
2089
+ {url_input_attrs}>Set &amp; fetch</button>
2090
+ <button class="btn btn-sm btn-outline-secondary" type="button"
2091
+ hx-post="/admin/catalog_refresh" hx-target="#dash" hx-swap="innerHTML"
2092
+ hx-indicator="#spin" title="Refetch from current URL">Refresh</button>
2093
+ </form>
2094
+ </div>
2095
+ </div>
2096
+ <div class="col-md-6">
2097
+ <label class="form-label mb-1 small text-muted">Upload catalog.toml</label>
2098
+ <form hx-post="/admin/catalog_upload" hx-target="#dash" hx-swap="innerHTML"
2099
+ hx-encoding="multipart/form-data" hx-indicator="#spin"
2100
+ class="m-0 d-flex align-items-center gap-1">
2101
+ <input class="form-control form-control-sm" type="file" name="catalog"
2102
+ accept=".toml,text/plain" required>
2103
+ <button class="btn btn-sm btn-primary" type="submit">Upload</button>
2104
+ </form>
2105
+ </div>
2106
+ </div>
2107
+ <div class="d-flex flex-wrap align-items-end gap-2 mb-2">
2108
+ <div>
2109
+ <label class="form-label mb-0 small text-muted">Last fetched</label>
2110
+ <div><code>{html.escape(catalog.fetched_at) or "never"}</code>
2111
+ &middot; {len(catalog.entries)} entries</div>
2112
+ </div>
2113
+ <form hx-post="/admin/catalog_add_entry" hx-target="#dash" hx-swap="innerHTML"
2114
+ hx-indicator="#spin" hx-on::after-request="this.reset()"
2115
+ class="m-0 ms-auto d-flex align-items-end flex-wrap gap-1">
2116
+ <div>
2117
+ <label class="form-label mb-0 small text-muted">name</label>
2118
+ <input class="form-control form-control-sm" name="name"
2119
+ style="width: 10rem;" placeholder="debian-13" required>
2120
+ </div>
2121
+ <div>
2122
+ <label class="form-label mb-0 small text-muted">src</label>
2123
+ <input class="form-control form-control-sm" name="src"
2124
+ style="width: 16rem;" placeholder="https://.../image.img.zst" required>
2125
+ </div>
2126
+ <div>
2127
+ <label class="form-label mb-0 small text-muted">format</label>
2128
+ <input class="form-control form-control-sm" name="format"
2129
+ style="width: 7rem;" placeholder="img.zst">
2130
+ </div>
2131
+ <div>
2132
+ <label class="form-label mb-0 small text-muted">arch</label>
2133
+ <input class="form-control form-control-sm" name="arch"
2134
+ style="width: 6rem;" placeholder="x86_64">
2135
+ </div>
2136
+ <button class="btn btn-sm btn-primary" type="submit">Add entry</button>
2137
+ </form>
2138
+ </div>
2139
+ {banner}"""
2140
+
1824
2141
  def _stream_progress_cell(self, s: Stream) -> str:
1825
2142
  """One progress cell for an active stream: a <progress> bar when the
1826
2143
  total is known (always for a cached blob, since the size came off
@@ -1913,13 +2230,18 @@ def main():
1913
2230
  httpd.mgr = mgr # type: ignore[attr-defined]
1914
2231
  httpd.auto_fetch = not args.curate # type: ignore[attr-defined]
1915
2232
  httpd.streams = StreamRegistry() # type: ignore[attr-defined]
1916
- # Catalog state: env override, else the shipping default (nosi's
1917
- # rolling catalog manifest). Seeded from the last persisted
1918
- # catalog.toml on disk so a restart doesn't wipe the cache.
1919
- catalog_url = (os.environ.get("WITHCACHE_CATALOG_URL") or DEFAULT_CATALOG_URL).strip()
2233
+ # Catalog state: env pin wins over everything, then the operator
2234
+ # override on disk (set via /admin/catalog_set_url), then the
2235
+ # shipping default (nosi's rolling catalog manifest). Seeded from
2236
+ # the last persisted catalog.toml on disk so a restart doesn't
2237
+ # wipe the cache.
2238
+ env_catalog_url = (os.environ.get("WITHCACHE_CATALOG_URL") or "").strip()
2239
+ catalog_url = env_catalog_url or DEFAULT_CATALOG_URL
1920
2240
  catalog = CatalogState(
1921
2241
  url=catalog_url,
1922
2242
  persist_path=os.path.join(store.data_dir, "catalog.toml"),
2243
+ env_url=env_catalog_url,
2244
+ url_override_path=os.path.join(store.data_dir, "catalog_url"),
1923
2245
  )
1924
2246
  catalog.load_persisted()
1925
2247
  httpd.catalog = catalog # type: ignore[attr-defined]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes