withcache 0.9.0__tar.gz → 0.9.1__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 (46) hide show
  1. {withcache-0.9.0 → withcache-0.9.1}/PKG-INFO +1 -1
  2. {withcache-0.9.0 → withcache-0.9.1}/shim/build.zig.zon +1 -1
  3. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/__init__.py +1 -1
  4. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_api.py +159 -11
  5. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_app.py +35 -34
  6. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_settings_store.py +4 -4
  7. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_templates/ui/cached.html +2 -2
  8. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_templates/ui/misses.html +2 -2
  9. withcache-0.9.1/src/withcache/client.py +237 -0
  10. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/server.py +27 -16
  11. {withcache-0.9.0 → withcache-0.9.1}/tests/test_fastapi_admin_forms.py +2 -2
  12. {withcache-0.9.0 → withcache-0.9.1}/tests/test_fastapi_blob.py +2 -2
  13. withcache-0.9.1/tests/test_fastapi_catalog_api.py +249 -0
  14. {withcache-0.9.0 → withcache-0.9.1}/tests/test_fastapi_scaffold.py +2 -6
  15. {withcache-0.9.0 → withcache-0.9.1}/tests/test_fastapi_settings_persistence.py +2 -2
  16. {withcache-0.9.0 → withcache-0.9.1}/tests/test_fastapi_ui_pages.py +2 -2
  17. {withcache-0.9.0 → withcache-0.9.1}/tests/test_withcache.py +9 -26
  18. withcache-0.9.0/src/withcache/client.py +0 -89
  19. {withcache-0.9.0 → withcache-0.9.1}/.gitignore +0 -0
  20. {withcache-0.9.0 → withcache-0.9.1}/LICENSE +0 -0
  21. {withcache-0.9.0 → withcache-0.9.1}/README.md +0 -0
  22. {withcache-0.9.0 → withcache-0.9.1}/deploy/Containerfile +0 -0
  23. {withcache-0.9.0 → withcache-0.9.1}/deploy/compose.local-build.yml +0 -0
  24. {withcache-0.9.0 → withcache-0.9.1}/deploy/compose.yml +0 -0
  25. {withcache-0.9.0 → withcache-0.9.1}/deploy/envvars.example +0 -0
  26. {withcache-0.9.0 → withcache-0.9.1}/hatch_build.py +0 -0
  27. {withcache-0.9.0 → withcache-0.9.1}/pyproject.toml +0 -0
  28. {withcache-0.9.0 → withcache-0.9.1}/shim/build.zig +0 -0
  29. {withcache-0.9.0 → withcache-0.9.1}/shim/shim.zig +0 -0
  30. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_shim.py +0 -0
  31. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_templates/ui/_layout.html +0 -0
  32. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_templates/ui/catalog.html +0 -0
  33. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_templates/ui/downloads.html +0 -0
  34. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_templates/ui/login.html +0 -0
  35. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_templates/ui/settings.html +0 -0
  36. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/curlwithcache.py +0 -0
  37. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/oras.py +0 -0
  38. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/static/bootstrap-icons.min.css +0 -0
  39. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/static/bootstrap.min.css +0 -0
  40. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  41. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  42. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/static/htmx.min.js +0 -0
  43. {withcache-0.9.0 → withcache-0.9.1}/src/withcache/wgetwithcache.py +0 -0
  44. {withcache-0.9.0 → withcache-0.9.1}/tests/test_differential.py +0 -0
  45. {withcache-0.9.0 → withcache-0.9.1}/tests/test_fastapi_uvicorn_smoke.py +0 -0
  46. {withcache-0.9.0 → withcache-0.9.1}/tests/test_oras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.9.0
3
+ Version: 0.9.1
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.9.0",
5
+ .version = "0.9.1",
6
6
  .fingerprint = 0xd7d96c5ed212ccaa,
7
7
  .minimum_zig_version = "0.16.0",
8
8
  .paths = .{
@@ -21,6 +21,6 @@ inherit the framework floor.
21
21
  from . import oras
22
22
  from .client import blob_url, cache_base, is_cached, serve_url
23
23
 
24
- __version__ = "0.9.0"
24
+ __version__ = "0.9.1"
25
25
 
26
26
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -22,11 +22,9 @@ stay byte-identical:
22
22
  the worker with the same credential.
23
23
 
24
24
  Streaming shape: FastAPI ``StreamingResponse`` with a generator
25
- that reads chunks from the on-disk blob. The pre-port stdlib
26
- handler chunked via ``self.wfile.write`` in a loop; the port
27
- preserves the same 64 KiB read size + the
28
- :class:`StreamRegistry` progress ticks so the operator dashboard
29
- shows the same numbers.
25
+ that reads chunks from the on-disk blob at 64 KiB reads;
26
+ :class:`StreamRegistry` ticks progress per chunk so the operator
27
+ Downloads page shows in-flight transfers as they run.
30
28
  """
31
29
 
32
30
  from __future__ import annotations
@@ -34,11 +32,12 @@ from __future__ import annotations
34
32
  import base64
35
33
  import urllib.parse
36
34
  from collections.abc import Iterator
35
+ from typing import Any
37
36
 
38
- from fastapi import FastAPI, Request
39
- from fastapi.responses import PlainTextResponse, Response, StreamingResponse
37
+ from fastapi import Depends, FastAPI, HTTPException, Request
38
+ from fastapi.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse
40
39
 
41
- from .server import CHUNK, StreamRegistry, _oras_tag_moved
40
+ from .server import CHUNK, CatalogState, StreamRegistry, _oras_tag_moved, _serialise_catalog
42
41
 
43
42
 
44
43
  def _decode_blob_origin(path: str, query: str) -> str:
@@ -68,8 +67,7 @@ def _serve_blob(
68
67
 
69
68
  Returns 400 when the origin URL couldn't be decoded, 404 (with
70
69
  miss recording + optional fetch enqueue) on cache miss, or 200
71
- + a StreamingResponse on hit. Same status codes and body shapes
72
- the pre-port stdlib handler emitted.
70
+ + a StreamingResponse on hit.
73
71
  """
74
72
  if not url:
75
73
  return PlainTextResponse("missing url\n", status_code=400)
@@ -83,7 +81,6 @@ def _serve_blob(
83
81
  if row is not None and _oras_tag_moved(url, row["sha256"]):
84
82
  # Tag re-pushed since we cached it: drop the stale bytes so
85
83
  # the miss branch below re-fetches the current content.
86
- # Same shape as the pre-port handler.
87
84
  store.delete_blob(row["key"])
88
85
  row = None
89
86
 
@@ -180,3 +177,154 @@ def register_api_routes(app: FastAPI) -> None:
180
177
  del name
181
178
  url = _decode_blob_origin(f"/b/{token}/x", "")
182
179
  return _serve_blob(request, url, head_only=True)
180
+
181
+ # ---------- Catalog JSON API -----------------------------------------
182
+ #
183
+ # Bty consumes the catalog through these endpoints: withcache is the
184
+ # single source of truth for what images exist. Read is open (mirrors
185
+ # /blob's LAN-only trust model); writes gate on the same Bearer
186
+ # pattern nbdmux uses -- an ``Authorization: Bearer <pw>`` header
187
+ # whose value matches ``$WITHCACHE_ADMIN_PASSWORD``, or a session
188
+ # cookie for browser-based operators.
189
+
190
+ def _bearer_or_session_authed(request: Request) -> None:
191
+ """Auth dependency for catalog writes.
192
+
193
+ - No password configured -> open (single-tenant LAN deploy).
194
+ - Session cookie present -> OK (browser path).
195
+ - Matching Bearer token -> OK (service-to-service path;
196
+ bty reads ``$WITHCACHE_ADMIN_PASSWORD`` and posts it).
197
+ - Otherwise -> 401.
198
+ """
199
+ auth = request.app.state.auth
200
+ if not auth.enabled:
201
+ return
202
+ # Session cookie check -- Starlette's SessionMiddleware
203
+ # stores the flag under ``withcache_authed`` in
204
+ # ``request.session``. Mirrors the flag ``_app.py`` sets.
205
+ if request.session.get("withcache_authed"):
206
+ return
207
+ header = request.headers.get("Authorization") or ""
208
+ if header.startswith("Bearer ") and auth.check_bearer(header[len("Bearer ") :]):
209
+ return
210
+ raise HTTPException(status_code=401, detail="auth required")
211
+
212
+ @app.get("/catalog")
213
+ def list_catalog(request: Request) -> JSONResponse:
214
+ """Return the current catalog as JSON.
215
+
216
+ Open route: bty polls this from a sibling container without a
217
+ session (mirrors the ``/blob`` byte-serving surface's trust
218
+ model -- LAN-only, no auth on reads). Returns a snapshot of
219
+ the in-process ``CatalogState.entries`` plus provenance
220
+ metadata (URL, env pin, fetch timestamps) so clients can
221
+ detect staleness. No pagination -- the catalog is small
222
+ (dozens of entries at most).
223
+ """
224
+ cs: CatalogState = request.app.state.catalog
225
+ return JSONResponse(
226
+ {
227
+ "url": cs.url,
228
+ "env_url": cs.env_url,
229
+ "fetched_at": cs.fetched_at,
230
+ "last_error": cs.last_error,
231
+ "entries": [dict(e) for e in cs.entries],
232
+ }
233
+ )
234
+
235
+ @app.post("/catalog/entries", status_code=201)
236
+ def add_catalog_entry(
237
+ body: dict[str, Any],
238
+ request: Request,
239
+ _auth: None = Depends(_bearer_or_session_authed),
240
+ ) -> JSONResponse:
241
+ """Insert one catalog entry.
242
+
243
+ Body: ``{"name": "...", "src": "...", "format?", "arch?",
244
+ "sha256?", "size_bytes?", "resolved_src?", "description?"}``.
245
+ Only ``src`` is required; every other field is optional and
246
+ gets persisted through the ``catalog.toml`` round-trip.
247
+
248
+ 409 when an entry with the same ``name`` already exists;
249
+ rejecting on duplicate name (not src) because the display
250
+ surface keys on name and two rows with the same name would
251
+ collide on the /ui/catalog table.
252
+
253
+ Rejects any unknown top-level key -- the emitter allowlists
254
+ keys anyway, but failing loud at the API boundary saves the
255
+ operator from wondering why their ``notes`` field vanished.
256
+ """
257
+ allowed = {
258
+ "name",
259
+ "src",
260
+ "resolved_src",
261
+ "format",
262
+ "arch",
263
+ "sha256",
264
+ "size_bytes",
265
+ "description",
266
+ }
267
+ if not isinstance(body, dict):
268
+ raise HTTPException(status_code=400, detail="body must be a JSON object")
269
+ unknown = set(body.keys()) - allowed
270
+ if unknown:
271
+ raise HTTPException(
272
+ status_code=400,
273
+ detail=f"unknown key(s) {sorted(unknown)}; allowed: {sorted(allowed)}",
274
+ )
275
+ name = body.get("name")
276
+ src = body.get("src")
277
+ if not isinstance(name, str) or not name.strip():
278
+ raise HTTPException(status_code=400, detail="name: non-empty string required")
279
+ if not isinstance(src, str) or not src.strip():
280
+ raise HTTPException(status_code=400, detail="src: non-empty string required")
281
+
282
+ cs: CatalogState = request.app.state.catalog
283
+ if any(e.get("name") == name for e in cs.entries):
284
+ raise HTTPException(
285
+ status_code=409,
286
+ detail=f"catalog entry with name={name!r} already exists",
287
+ )
288
+ entry = {k: v for k, v in body.items() if v is not None and v != ""}
289
+ cs.entries.append(entry)
290
+ _persist_catalog(cs)
291
+ return JSONResponse(entry, status_code=201)
292
+
293
+ @app.delete("/catalog/entries", status_code=204)
294
+ def delete_catalog_entry(
295
+ request: Request,
296
+ name: str | None = None,
297
+ _auth: None = Depends(_bearer_or_session_authed),
298
+ ) -> Response:
299
+ """Delete via ``?name=<name>`` query param (URL-safe;
300
+ avoids path-encoding TOML entry names).
301
+
302
+ 204 on success. 404 when no entry with that name exists.
303
+ """
304
+ if not name or not name.strip():
305
+ raise HTTPException(status_code=400, detail="name: query param required")
306
+ cs: CatalogState = request.app.state.catalog
307
+ original = list(cs.entries)
308
+ cs.entries = [e for e in cs.entries if e.get("name") != name]
309
+ if len(cs.entries) == len(original):
310
+ raise HTTPException(
311
+ status_code=404,
312
+ detail=f"no catalog entry with name={name!r}",
313
+ )
314
+ _persist_catalog(cs)
315
+ return Response(status_code=204)
316
+
317
+
318
+ def _persist_catalog(cs: CatalogState) -> None:
319
+ """Write the current in-memory catalog back to
320
+ ``<data_dir>/catalog.toml`` (when ``persist_path`` is set).
321
+
322
+ Silent no-op when ``persist_path`` is ``None`` -- tests inject
323
+ a stub CatalogState without disk backing and still exercise
324
+ the in-memory mutation."""
325
+ if cs.persist_path is None:
326
+ return
327
+ from pathlib import Path
328
+
329
+ Path(cs.persist_path).parent.mkdir(parents=True, exist_ok=True)
330
+ Path(cs.persist_path).write_bytes(_serialise_catalog(cs.entries))
@@ -1,18 +1,16 @@
1
- """FastAPI app factory for withcache (v0.9.0 port).
1
+ """FastAPI app factory for withcache.
2
2
 
3
- Replaces the stdlib ``http.server``-based ``server.py`` request
4
- handler with a FastAPI application. Mirrors ``nbdmux._app`` in
5
- shape so the trio's three consoles share one testing + auth +
6
- chrome pattern; the eventual ``trio-common`` extraction rolls
7
- these into one library.
3
+ Mirrors :mod:`nbdmux._app` in shape so the trio's three consoles
4
+ share one testing + auth + chrome pattern; the eventual
5
+ ``trio-common`` extraction rolls these into one library.
8
6
 
9
7
  Hosts the operator UI (Cached / Downloads / Misses / Catalog /
10
8
  Settings), the byte-serving routes registered via
11
9
  :func:`._api.register_api_routes`, the admin form endpoints the
12
10
  UI action buttons post to, and the persistent-override Settings
13
- form for the Warming card's log level. The stdlib ``server.py``
14
- still holds the daemon entrypoint during the port; ``main()``
15
- there launches uvicorn against this factory.
11
+ form for the Warming card's log level. :func:`withcache.server.main`
12
+ constructs the runtime objects and launches uvicorn against this
13
+ factory.
16
14
  """
17
15
 
18
16
  from __future__ import annotations
@@ -94,9 +92,8 @@ def create_app(
94
92
 
95
93
  ``store`` / ``mgr`` / ``streams`` / ``auto_fetch`` let tests
96
94
  inject stubs / capture doubles without spawning the real
97
- :class:`DownloadManager` worker thread. The daemon path
98
- (``server.main`` post-cut-over) passes real instances via a
99
- lifespan hook.
95
+ :class:`DownloadManager` worker thread. :func:`server.main`
96
+ passes real instances at daemon start.
100
97
  """
101
98
  data_dir_str = str(data_dir)
102
99
  Path(data_dir_str).mkdir(parents=True, exist_ok=True)
@@ -118,9 +115,9 @@ def create_app(
118
115
  """
119
116
  if run_lifecycle:
120
117
  # Kick the startup catalog fetch when the on-disk
121
- # persisted catalog is empty. Same shape the pre-port
122
- # ``server.main`` had; daemon=True so a slow / broken
123
- # upstream doesn't block ``uvicorn.run``'s serve loop.
118
+ # persisted catalog is empty. daemon=True so a slow /
119
+ # broken upstream doesn't block ``uvicorn.run``'s serve
120
+ # loop.
124
121
  if not _app.state.catalog.entries:
125
122
  threading.Thread(
126
123
  target=_app.state.catalog.fetch_now,
@@ -146,8 +143,8 @@ def create_app(
146
143
  )
147
144
 
148
145
  # SessionMiddleware signs a cookie with the same shape (name +
149
- # HttpOnly + SameSite=Lax + Max-Age) the pre-port stdlib server
150
- # emitted, so a rolling deploy doesn't invalidate existing
146
+ # HttpOnly + SameSite=Lax + Max-Age) as bty + nbdmux so a
147
+ # rolling deploy across the trio doesn't invalidate existing
151
148
  # browser sessions.
152
149
  app.add_middleware(
153
150
  SessionMiddleware,
@@ -173,11 +170,17 @@ def create_app(
173
170
  app.state.mgr = mgr if mgr is not None else DownloadManager(app.state.store)
174
171
  app.state.streams = streams if streams is not None else StreamRegistry()
175
172
  app.state.auto_fetch = auto_fetch
176
- # CatalogState mirrors the pre-port ``server.main`` setup: env pin
177
- # wins over on-disk override, on-disk override wins over the shipping
178
- # default (nosi's rolling catalog manifest). ``load_persisted`` seeds
179
- # entries from the last successful fetch so a restart doesn't wipe
180
- # the cache. Tests pass a stub via ``catalog=`` to skip disk IO.
173
+ # Auth object exposed on app.state so :func:`register_api_routes`
174
+ # (which owns the JSON catalog write endpoints) can gate them on
175
+ # ``Authorization: Bearer <pw>``. The UI form + login flow read
176
+ # ``auth`` from this closure directly, but the register-routes
177
+ # pattern doesn't have the closure, so app.state is the bridge.
178
+ app.state.auth = auth
179
+ # CatalogState resolution: env pin wins over on-disk override,
180
+ # on-disk override wins over the shipping default (nosi's rolling
181
+ # catalog manifest). ``load_persisted`` seeds entries from the
182
+ # last successful fetch so a restart doesn't wipe the cache.
183
+ # Tests pass a stub via ``catalog=`` to skip disk IO.
181
184
  if catalog is not None:
182
185
  app.state.catalog = catalog
183
186
  else:
@@ -257,7 +260,7 @@ def create_app(
257
260
  request.session.clear()
258
261
  return RedirectResponse(url="/ui/login", status_code=status.HTTP_303_SEE_OTHER)
259
262
 
260
- # ---------- Root redirect + placeholder pages -----------------------
263
+ # ---------- Root redirect + operator UI pages -----------------------
261
264
 
262
265
  @app.get("/")
263
266
  def _root() -> RedirectResponse:
@@ -299,9 +302,8 @@ def create_app(
299
302
  @app.get("/ui/catalog", response_class=HTMLResponse)
300
303
  def ui_catalog(request: Request, _auth_check: None = Depends(require_ui_auth)) -> HTMLResponse:
301
304
  """Catalog view: current URL + env pin + on-disk override
302
- provenance, plus the entries table. Read-only for this pass;
303
- Refresh + Set URL + Add oras + Delete land in the admin-forms
304
- follow-up PR."""
305
+ provenance, plus the entries table. Refresh + Set URL +
306
+ Add oras + Delete are on the subnav / row forms."""
305
307
  cs: CatalogState = app.state.catalog
306
308
  return render(
307
309
  "ui/catalog.html",
@@ -399,12 +401,11 @@ def create_app(
399
401
 
400
402
  # ---------- Admin form endpoints ------------------------------------
401
403
  #
402
- # Form-encoded siblings of the operator actions the pre-port
403
- # ``server.Handler`` dispatched on ``self.ADMIN_POST``. Each 303s
404
- # to a sensible /ui/* target so the browser flips to GET and the
405
- # dashboard reflects the mutation. Auth-gated -- the JSON /blob
406
- # + /b/ byte-serving routes stay open (bty polls from a sibling
407
- # container) but writes gate on the session cookie.
404
+ # Form-encoded operator actions the UI action buttons post to.
405
+ # Each 303s to a sensible /ui/* target so the browser flips to
406
+ # GET and the dashboard reflects the mutation. Auth-gated -- the
407
+ # JSON /blob + /b/ byte-serving routes stay open (bty polls from
408
+ # a sibling container) but writes gate on the session cookie.
408
409
 
409
410
  @app.post("/admin/fetch")
410
411
  def ui_admin_fetch(
@@ -413,8 +414,8 @@ def create_app(
413
414
  _auth_check: None = Depends(require_ui_auth),
414
415
  ) -> RedirectResponse:
415
416
  """Enqueue a background fetch. Optional ``header`` field
416
- carries a curated authorization payload -- format matches
417
- the pre-port ``parse_headers`` helper."""
417
+ carries a curated authorization payload, parsed by
418
+ :func:`withcache.server.parse_headers`."""
418
419
  from .server import parse_headers
419
420
 
420
421
  u = (url or "").strip()
@@ -9,10 +9,10 @@ to change without redeploying:
9
9
  - :data:`KEY_LOG_LEVEL` -- uvicorn / logging level.
10
10
  Env: :data:`ENV_LOG_LEVEL`. Default: ``info``.
11
11
  - :data:`KEY_CATALOG_URL_OVERRIDE` is intentionally NOT here --
12
- :class:`CatalogState.set_url_override` already persists it to a
13
- dedicated ``<data-dir>/catalog_url`` file so it round-trips
14
- with the ``env_url`` precedence the pre-port code implemented.
15
- Route Settings-page edits through that same helper.
12
+ :class:`CatalogState.set_url_override` persists it to a dedicated
13
+ ``<data-dir>/catalog_url`` file the daemon reads at startup, so
14
+ the Catalog page's Set-URL form remains the single source of
15
+ truth for that knob.
16
16
 
17
17
  Resolution order is always **override -> env -> default**, so
18
18
  operators can drop an env / systemd-unit config without hunting
@@ -21,8 +21,8 @@
21
21
  <div class="alert alert-secondary small mb-3">
22
22
  Blobs bty flashes through withcache land here. Each row is a
23
23
  cached artifact keyed by the sha256 of its canonical source URL.
24
- Delete + refresh actions land in a follow-up PR; today the page
25
- is read-only.
24
+ Use the trash button to drop a blob; the next request for that
25
+ URL will refetch from origin.
26
26
  </div>
27
27
  {% endblock %}
28
28
 
@@ -5,8 +5,8 @@
5
5
  <div class="alert alert-secondary small mb-3">
6
6
  Cache misses recorded on ``/blob`` / ``/b/<b64>/<name>`` requests.
7
7
  In auto-fetch mode withcache enqueues a fetch in the background;
8
- in curate mode an operator triggers the pull manually. Dismiss +
9
- Fetch actions land in the admin-forms follow-up PR.
8
+ in curate mode an operator triggers the pull manually. Per-row
9
+ Fetch enqueues the pull; Dismiss removes the recorded miss.
10
10
  </div>
11
11
  {% endblock %}
12
12
 
@@ -0,0 +1,237 @@
1
+ """A tiny client for consuming a withcache cache-host from other tools.
2
+
3
+ Lets a consumer (e.g. bty) point downloads at withcache without re-implementing
4
+ the ``/b/`` URL scheme, and also lets bty consume withcache's catalog as the
5
+ single source of truth for what images are flashable. Stdlib only, so
6
+ importing it pulls in no third-party dependencies.
7
+
8
+ from withcache import client
9
+
10
+ # Byte-serving: "use the cache when it's warm, the origin otherwise"
11
+ url = client.serve_url("http://cache:8081", origin) or origin
12
+
13
+ # Catalog: list what's flashable, add a new entry
14
+ entries = client.list_catalog("http://cache:8081")
15
+ client.add_catalog_entry(
16
+ "http://cache:8081",
17
+ {"name": "debian", "src": "https://.../debian.img.gz"},
18
+ )
19
+
20
+ The ``/b/<urlsafe-b64(origin)>/<basename>`` encoding is shared with the shims
21
+ and the server (one definition in :mod:`withcache._shim`), so consumers stay in
22
+ lockstep with the cache-host automatically. Catalog reads are open (LAN-only
23
+ trust model); writes gate on ``$WITHCACHE_ADMIN_PASSWORD`` -- the client picks
24
+ it up from the env var by default so cross-container callers work out of the
25
+ box on the bty deploy (which sets the password on both ends).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import urllib.error
33
+ import urllib.parse
34
+ import urllib.request
35
+ from typing import Any
36
+
37
+ from . import _shim
38
+
39
+ __all__ = [
40
+ "CATALOG_TIMEOUT",
41
+ "PROBE_TIMEOUT",
42
+ "WithcacheError",
43
+ "add_catalog_entry",
44
+ "blob_url",
45
+ "cache_base",
46
+ "delete_catalog_entry",
47
+ "is_cached",
48
+ "list_catalog",
49
+ "serve_url",
50
+ ]
51
+
52
+ PROBE_TIMEOUT = 3.0 # seconds; never block the caller on a slow/unreachable cache
53
+ CATALOG_TIMEOUT = 5.0 # seconds; catalog reads/writes are small, keep short
54
+
55
+
56
+ class WithcacheError(Exception):
57
+ """Raised on a catalog-plane failure: network, HTTP error, parse error.
58
+
59
+ Inherits from :class:`Exception` (not :class:`OSError`) so callers can
60
+ opt-in to surfacing withcache failures distinctly from generic network
61
+ errors."""
62
+
63
+
64
+ #: Normalize a server value: accepts 'host', 'host:8081', or 'http://host:8081'.
65
+ cache_base = _shim.cache_base
66
+
67
+
68
+ def blob_url(server: str, origin: str) -> str:
69
+ """The cache-host serve URL for ``origin``:
70
+ ``<server>/b/<urlsafe-b64(origin), unpadded>/<basename>``. The trailing
71
+ basename is cosmetic (so any downloader names the saved file after the
72
+ artifact); the cache keys on the decoded origin URL."""
73
+ return _shim.blob_url(_shim.cache_base(server), origin)
74
+
75
+
76
+ def is_cached(
77
+ server: str,
78
+ origin: str,
79
+ timeout: float = PROBE_TIMEOUT,
80
+ headers: dict[str, str] | None = None,
81
+ ) -> bool:
82
+ """True if the cache-host already holds ``origin`` (a ``HEAD`` on ``/b/``
83
+ returns 200). A miss (404), an unreachable host, a timeout, or any error
84
+ returns False, so a caller can safely fall back to the origin. The HEAD
85
+ also *warms* an auto-fetch cache-host: the miss is recorded and the
86
+ background fill enqueued, so a later probe flips to cached.
87
+
88
+ ``headers`` (optional) attaches request headers to the HEAD. The
89
+ cache-host forwards a client-supplied ``Authorization`` into its
90
+ background-fetch worker, so a consumer that has just minted an OCI
91
+ bearer (the typical use case: bty resolving an ``oras://`` catalog
92
+ entry to a ``ghcr.io`` blob URL at import time) can warm the cache
93
+ against that token-gated origin in one probe. Other entries in
94
+ ``headers`` round-trip the same way; only ``Authorization`` is
95
+ forwarded into the fetch on the server side.
96
+ """
97
+ req = urllib.request.Request(blob_url(server, origin), method="HEAD")
98
+ if headers:
99
+ for k, v in headers.items():
100
+ req.add_header(k, v)
101
+ try:
102
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
103
+ return bool(resp.status == 200)
104
+ except urllib.error.HTTPError:
105
+ return False # 404 miss (now recorded + enqueued by the cache-host)
106
+ except (urllib.error.URLError, OSError):
107
+ return False # unreachable / timeout -> caller serves the origin itself
108
+
109
+
110
+ def serve_url(
111
+ server: str,
112
+ origin: str,
113
+ timeout: float = PROBE_TIMEOUT,
114
+ headers: dict[str, str] | None = None,
115
+ ) -> str | None:
116
+ """The cache-host serve URL for ``origin`` if the cache holds it, else
117
+ ``None`` -- the convenience form of "use the cache when warm":
118
+
119
+ url = client.serve_url(cache, origin) or origin
120
+
121
+ ``headers`` is passed through to :func:`is_cached` for the HEAD probe;
122
+ the returned serve URL never carries auth (cached bytes are served
123
+ without revisiting the origin).
124
+ """
125
+ return blob_url(server, origin) if is_cached(server, origin, timeout, headers=headers) else None
126
+
127
+
128
+ def _resolve_password(password: str | None) -> str | None:
129
+ """``password`` if given, else ``$WITHCACHE_ADMIN_PASSWORD``, else ``None``.
130
+ Empty / whitespace values are treated as unset."""
131
+ if password:
132
+ return password
133
+ env = (os.environ.get("WITHCACHE_ADMIN_PASSWORD") or "").strip()
134
+ return env or None
135
+
136
+
137
+ def _catalog_request(
138
+ method: str,
139
+ server: str,
140
+ path: str,
141
+ body: dict[str, Any] | None = None,
142
+ timeout: float = CATALOG_TIMEOUT,
143
+ password: str | None = None,
144
+ ) -> Any:
145
+ """Shared request wiring for the catalog JSON endpoints.
146
+
147
+ ``password`` -> ``Authorization: Bearer <pw>`` on writes. Reads
148
+ are open on the server side so ``password`` is a no-op there;
149
+ always sending it is fine (server ignores the header on reads).
150
+ """
151
+ url = f"{cache_base(server)}{path}"
152
+ data = json.dumps(body).encode("utf-8") if body is not None else None
153
+ req = urllib.request.Request(url, data=data, method=method)
154
+ if body is not None:
155
+ req.add_header("Content-Type", "application/json")
156
+ resolved_pw = _resolve_password(password)
157
+ if resolved_pw is not None:
158
+ req.add_header("Authorization", f"Bearer {resolved_pw}")
159
+ try:
160
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
161
+ if resp.status == 204:
162
+ return None
163
+ raw = resp.read()
164
+ if not raw:
165
+ return None
166
+ return json.loads(raw)
167
+ except urllib.error.HTTPError as exc:
168
+ try:
169
+ payload = json.loads(exc.read() or b"{}")
170
+ detail = payload.get("detail") if isinstance(payload, dict) else None
171
+ except (json.JSONDecodeError, ValueError):
172
+ detail = None
173
+ raise WithcacheError(f"{method} {path} -> HTTP {exc.code}: {detail or exc.reason}") from exc
174
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
175
+ raise WithcacheError(f"{method} {path} -> {exc}") from exc
176
+ except json.JSONDecodeError as exc:
177
+ raise WithcacheError(f"{method} {path} -> invalid JSON: {exc}") from exc
178
+
179
+
180
+ def list_catalog(
181
+ server: str,
182
+ timeout: float = CATALOG_TIMEOUT,
183
+ ) -> dict[str, Any]:
184
+ """Return the catalog snapshot as ``{url, env_url, fetched_at,
185
+ last_error, entries: [...]}``. Open route: no password needed.
186
+
187
+ Raises :class:`WithcacheError` on any failure (network, HTTP,
188
+ parse). Callers typically want the ``entries`` field only:
189
+
190
+ entries = client.list_catalog(server)["entries"]
191
+ """
192
+ return _catalog_request("GET", server, "/catalog", timeout=timeout)
193
+
194
+
195
+ def add_catalog_entry(
196
+ server: str,
197
+ entry: dict[str, Any],
198
+ *,
199
+ timeout: float = CATALOG_TIMEOUT,
200
+ password: str | None = None,
201
+ ) -> dict[str, Any]:
202
+ """POST one entry into the catalog.
203
+
204
+ ``entry`` must carry at least ``name`` + ``src``. Optional
205
+ fields: ``format``, ``arch``, ``sha256``, ``size_bytes``,
206
+ ``resolved_src``, ``description``.
207
+
208
+ Raises :class:`WithcacheError` on any HTTP failure (409 if the
209
+ entry name already exists; 401 if auth is enabled and no valid
210
+ password reached the server).
211
+ """
212
+ return _catalog_request(
213
+ "POST", server, "/catalog/entries", body=entry, timeout=timeout, password=password
214
+ )
215
+
216
+
217
+ def delete_catalog_entry(
218
+ server: str,
219
+ name: str,
220
+ *,
221
+ timeout: float = CATALOG_TIMEOUT,
222
+ password: str | None = None,
223
+ ) -> None:
224
+ """DELETE the catalog entry with ``name=<name>``. 404 (no such
225
+ entry) is treated as success so the call is idempotent for the
226
+ operator's "make sure this is gone" intent, mirroring
227
+ :func:`nbdmux.client.remove_export`.
228
+
229
+ Raises :class:`WithcacheError` on transport failure but NOT on
230
+ 404."""
231
+ path = f"/catalog/entries?name={urllib.parse.quote(name, safe='')}"
232
+ try:
233
+ _catalog_request("DELETE", server, path, timeout=timeout, password=password)
234
+ except WithcacheError as exc:
235
+ if "HTTP 404" in str(exc):
236
+ return
237
+ raise
@@ -19,8 +19,8 @@ the parts of the daemon that stay stdlib:
19
19
  bty flashes against, persisted to
20
20
  ``<data-dir>/catalog.toml`` between restarts.
21
21
  - Helpers: ``resolve_secret``, ``parse_size``, ``parse_headers``,
22
- ``now_iso``, ``_age_human``, ``human_size``, ``_b64e`` /
23
- ``_b64d``, ``_serialise_catalog``, ``_oras_tag_moved``.
22
+ ``now_iso``, ``human_size``, ``_b64e`` / ``_b64d``,
23
+ ``_serialise_catalog``, ``_oras_tag_moved``.
24
24
  - ``main()``: CLI entrypoint that constructs Store + DownloadManager
25
25
  and hands them to :func:`withcache._app.create_app`, then boots
26
26
  uvicorn. SIGTERM / SIGINT handling is uvicorn's.
@@ -74,18 +74,6 @@ def now_iso() -> str:
74
74
  return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
75
75
 
76
76
 
77
- def _age_human(started_at: float, *, now: float | None = None) -> str:
78
- """Render seconds-since as a compact ``Ns`` / ``Nm`` / ``Nh`` string for
79
- the streams table. ``now`` is injectable so tests don't need
80
- monkeypatching ``time.time`` to assert formatting."""
81
- elapsed = int(max(0.0, (now if now is not None else time.time()) - started_at))
82
- if elapsed < 60:
83
- return f"{elapsed}s"
84
- if elapsed < 3600:
85
- return f"{elapsed // 60}m{elapsed % 60:02d}s"
86
- return f"{elapsed // 3600}h{(elapsed % 3600) // 60:02d}m"
87
-
88
-
89
77
  def human_size(n: int) -> str:
90
78
  f = float(n)
91
79
  for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
@@ -157,11 +145,21 @@ def _serialise_catalog(entries: list[dict[str, Any]]) -> bytes:
157
145
  ``tomli_w`` dep: the schema is flat so a hand-rolled emitter is
158
146
  safer than pulling in a write library. Only known scalar keys are
159
147
  emitted; unknown keys are dropped (silently) so an operator-added
160
- row can't smuggle arbitrary TOML through."""
148
+ row can't smuggle arbitrary TOML through.
149
+
150
+ Scalar keys emitted: ``name``, ``src``, ``resolved_src``,
151
+ ``format``, ``arch``, ``sha256``, ``description``. Integer:
152
+ ``size_bytes``. ``resolved_src`` is the canonical fetch URL
153
+ (for ``oras://`` refs; equal to ``src`` for plain HTTPS);
154
+ bty needs it to render iPXE flash chains against the byte
155
+ URL directly. ``description`` is operator-supplied prose so
156
+ the /ui/images page has something better than the URL to
157
+ display.
158
+ """
161
159
  out: list[str] = ["version = 1", ""]
162
160
  for e in entries:
163
161
  out.append("[[images]]")
164
- for key in ("name", "src", "format", "arch", "sha256"):
162
+ for key in ("name", "src", "resolved_src", "format", "arch", "sha256", "description"):
165
163
  val = e.get(key)
166
164
  if val is None or val == "":
167
165
  continue
@@ -432,6 +430,19 @@ class Auth:
432
430
  return False
433
431
  return hmac.compare_digest(pw.encode("utf-8"), self.password.encode("utf-8"))
434
432
 
433
+ def check_bearer(self, token: str) -> bool:
434
+ """Constant-time compare a raw Bearer token against the
435
+ configured admin password. Used by the JSON control-plane's
436
+ service-to-service path (bty reads
437
+ ``$WITHCACHE_ADMIN_PASSWORD`` and sends it as
438
+ ``Authorization: Bearer <pw>``). Equivalent trust surface
439
+ to ``check_password`` -- both are password comparisons --
440
+ so callers can pick whichever channel their transport
441
+ supports."""
442
+ if not self.password:
443
+ return False
444
+ return hmac.compare_digest(token.encode("utf-8"), self.password.encode("utf-8"))
445
+
435
446
 
436
447
  # --------------------------------------------------------------------------
437
448
  # Storage
@@ -20,8 +20,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
20
20
 
21
21
  try:
22
22
  from fastapi.testclient import TestClient # noqa: E402
23
- except (ImportError, RuntimeError): # pragma: no cover
24
- raise unittest.SkipTest("fastapi + httpx not available (port scaffolding deps)") from None
23
+ except ImportError: # pragma: no cover
24
+ raise unittest.SkipTest("fastapi not installed") from None
25
25
 
26
26
  from withcache._app import create_app # noqa: E402
27
27
  from withcache.server import CatalogState # noqa: E402
@@ -23,8 +23,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
23
23
 
24
24
  try:
25
25
  from fastapi.testclient import TestClient # noqa: E402
26
- except (ImportError, RuntimeError): # pragma: no cover
27
- raise unittest.SkipTest("fastapi + httpx not available (port scaffolding deps)") from None
26
+ except ImportError: # pragma: no cover
27
+ raise unittest.SkipTest("fastapi not installed") from None
28
28
 
29
29
  from withcache._app import create_app # noqa: E402
30
30
 
@@ -0,0 +1,249 @@
1
+ """TestClient tests for the JSON catalog API.
2
+
3
+ Pins the ``GET /catalog`` + ``POST /catalog/entries`` +
4
+ ``DELETE /catalog/entries`` surface that bty consumes as the
5
+ single source of truth for what images are flashable. Mirrors the
6
+ Bearer / session dual-auth pattern nbdmux uses on its write
7
+ routes.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import shutil
14
+ import sys
15
+ import tempfile
16
+ import unittest
17
+
18
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
19
+
20
+ try:
21
+ from fastapi.testclient import TestClient # noqa: E402
22
+ except ImportError: # pragma: no cover
23
+ raise unittest.SkipTest("fastapi not installed") from None
24
+
25
+ from withcache._app import create_app # noqa: E402
26
+ from withcache.server import CatalogState # noqa: E402
27
+
28
+ TEST_PASSWORD = "test-admin-pw"
29
+ TEST_SECRET = b"test-secret-not-for-prod-use-32b_"
30
+
31
+
32
+ class _CatalogApiBase(unittest.TestCase):
33
+ ENABLE_AUTH = False
34
+
35
+ def setUp(self) -> None:
36
+ self._tmpdir = tempfile.mkdtemp()
37
+ self._saved_pw = os.environ.get("WITHCACHE_ADMIN_PASSWORD")
38
+ self._saved_cat = os.environ.get("WITHCACHE_CATALOG_URL")
39
+ if self.ENABLE_AUTH:
40
+ os.environ["WITHCACHE_ADMIN_PASSWORD"] = TEST_PASSWORD
41
+ else:
42
+ os.environ.pop("WITHCACHE_ADMIN_PASSWORD", None)
43
+ os.environ.pop("WITHCACHE_CATALOG_URL", None)
44
+
45
+ self.catalog = CatalogState(
46
+ url="https://example.invalid/catalog.toml",
47
+ persist_path=os.path.join(self._tmpdir, "catalog.toml"),
48
+ env_url="",
49
+ url_override_path=os.path.join(self._tmpdir, "catalog_url"),
50
+ )
51
+
52
+ def _fake_fetch_now(**_kw: object) -> None:
53
+ pass
54
+
55
+ self.catalog.fetch_now = _fake_fetch_now # type: ignore[assignment]
56
+
57
+ self.app = create_app(
58
+ data_dir=self._tmpdir,
59
+ secret_key=TEST_SECRET,
60
+ catalog=self.catalog,
61
+ )
62
+ self.client = TestClient(self.app, follow_redirects=False)
63
+
64
+ def tearDown(self) -> None:
65
+ self.client.close()
66
+ for key, saved in (
67
+ ("WITHCACHE_ADMIN_PASSWORD", self._saved_pw),
68
+ ("WITHCACHE_CATALOG_URL", self._saved_cat),
69
+ ):
70
+ if saved is None:
71
+ os.environ.pop(key, None)
72
+ else:
73
+ os.environ[key] = saved
74
+ shutil.rmtree(self._tmpdir, ignore_errors=True)
75
+
76
+ def _login(self) -> None:
77
+ r = self.client.post("/ui/login", data={"password": TEST_PASSWORD}, follow_redirects=False)
78
+ self.assertEqual(r.status_code, 303, r.text)
79
+
80
+
81
+ class ListCatalogTests(_CatalogApiBase):
82
+ def test_list_empty_returns_metadata_with_empty_entries(self) -> None:
83
+ r = self.client.get("/catalog")
84
+ self.assertEqual(r.status_code, 200)
85
+ body = r.json()
86
+ self.assertEqual(body["entries"], [])
87
+ self.assertEqual(body["url"], "https://example.invalid/catalog.toml")
88
+ self.assertIn("fetched_at", body)
89
+ self.assertIn("last_error", body)
90
+
91
+ def test_list_returns_seeded_entries(self) -> None:
92
+ self.catalog.entries = [
93
+ {
94
+ "name": "demo",
95
+ "src": "https://example/demo.img.gz",
96
+ "format": "img.gz",
97
+ "sha256": "a" * 64,
98
+ "size_bytes": 1024,
99
+ "description": "demo image",
100
+ "resolved_src": "https://example/demo.img.gz",
101
+ }
102
+ ]
103
+ r = self.client.get("/catalog")
104
+ self.assertEqual(r.status_code, 200)
105
+ entries = r.json()["entries"]
106
+ self.assertEqual(len(entries), 1)
107
+ self.assertEqual(entries[0]["name"], "demo")
108
+ self.assertEqual(entries[0]["sha256"], "a" * 64)
109
+ self.assertEqual(entries[0]["description"], "demo image")
110
+
111
+
112
+ class AddCatalogEntryTests(_CatalogApiBase):
113
+ def test_add_minimum_entry(self) -> None:
114
+ """Only ``name`` + ``src`` are required."""
115
+ r = self.client.post(
116
+ "/catalog/entries",
117
+ json={"name": "demo", "src": "https://example/demo.img.gz"},
118
+ )
119
+ self.assertEqual(r.status_code, 201, r.text)
120
+ body = r.json()
121
+ self.assertEqual(body["name"], "demo")
122
+ self.assertEqual(body["src"], "https://example/demo.img.gz")
123
+ # And it landed in the catalog state.
124
+ self.assertEqual(len(self.catalog.entries), 1)
125
+
126
+ def test_add_full_entry_round_trips_all_fields(self) -> None:
127
+ r = self.client.post(
128
+ "/catalog/entries",
129
+ json={
130
+ "name": "debian",
131
+ "src": "https://example/debian.img.gz",
132
+ "resolved_src": "https://example/debian.img.gz",
133
+ "format": "img.gz",
134
+ "arch": "x86_64",
135
+ "sha256": "b" * 64,
136
+ "size_bytes": 2048,
137
+ "description": "Debian sysdev",
138
+ },
139
+ )
140
+ self.assertEqual(r.status_code, 201, r.text)
141
+ stored = self.catalog.entries[0]
142
+ self.assertEqual(stored["description"], "Debian sysdev")
143
+ self.assertEqual(stored["resolved_src"], "https://example/debian.img.gz")
144
+
145
+ def test_add_persists_to_disk(self) -> None:
146
+ self.client.post(
147
+ "/catalog/entries", json={"name": "demo", "src": "https://example/demo.img.gz"}
148
+ )
149
+ toml_path = os.path.join(self._tmpdir, "catalog.toml")
150
+ self.assertTrue(os.path.exists(toml_path))
151
+ content = open(toml_path, encoding="utf-8").read()
152
+ self.assertIn('name = "demo"', content)
153
+
154
+ def test_add_rejects_missing_name(self) -> None:
155
+ r = self.client.post("/catalog/entries", json={"src": "https://example/demo.img.gz"})
156
+ self.assertEqual(r.status_code, 400)
157
+
158
+ def test_add_rejects_missing_src(self) -> None:
159
+ r = self.client.post("/catalog/entries", json={"name": "demo"})
160
+ self.assertEqual(r.status_code, 400)
161
+
162
+ def test_add_rejects_unknown_field(self) -> None:
163
+ """Unknown fields are refused at the API boundary so operators
164
+ catch typos loud (``notes`` → ``description``) instead of
165
+ watching them silently drop through the emitter."""
166
+ r = self.client.post(
167
+ "/catalog/entries",
168
+ json={"name": "demo", "src": "https://example/x", "notes": "typo"},
169
+ )
170
+ self.assertEqual(r.status_code, 400)
171
+ self.assertIn("notes", r.json()["detail"])
172
+
173
+ def test_add_duplicate_name_409s(self) -> None:
174
+ first = {"name": "demo", "src": "https://example/one.img.gz"}
175
+ self.client.post("/catalog/entries", json=first)
176
+ r = self.client.post(
177
+ "/catalog/entries",
178
+ json={"name": "demo", "src": "https://example/two.img.gz"},
179
+ )
180
+ self.assertEqual(r.status_code, 409)
181
+
182
+
183
+ class DeleteCatalogEntryTests(_CatalogApiBase):
184
+ def test_delete_removes_entry(self) -> None:
185
+ self.catalog.entries = [{"name": "demo", "src": "https://example/demo.img.gz"}]
186
+ r = self.client.delete("/catalog/entries?name=demo")
187
+ self.assertEqual(r.status_code, 204)
188
+ self.assertEqual(self.catalog.entries, [])
189
+
190
+ def test_delete_unknown_returns_404(self) -> None:
191
+ r = self.client.delete("/catalog/entries?name=missing")
192
+ self.assertEqual(r.status_code, 404)
193
+
194
+ def test_delete_requires_name(self) -> None:
195
+ r = self.client.delete("/catalog/entries")
196
+ self.assertEqual(r.status_code, 400)
197
+
198
+
199
+ class CatalogApiAuthTests(_CatalogApiBase):
200
+ ENABLE_AUTH = True
201
+
202
+ def test_get_catalog_open_even_with_auth(self) -> None:
203
+ """Bty polls the catalog from a sibling container without a
204
+ session; read stays open regardless of auth."""
205
+ r = self.client.get("/catalog")
206
+ self.assertEqual(r.status_code, 200)
207
+
208
+ def test_add_entry_401_without_auth(self) -> None:
209
+ r = self.client.post("/catalog/entries", json={"name": "demo", "src": "https://example/x"})
210
+ self.assertEqual(r.status_code, 401)
211
+
212
+ def test_add_entry_200_with_session(self) -> None:
213
+ self._login()
214
+ r = self.client.post("/catalog/entries", json={"name": "demo", "src": "https://example/x"})
215
+ self.assertEqual(r.status_code, 201)
216
+
217
+ def test_add_entry_200_with_bearer(self) -> None:
218
+ """Service-to-service path: bty reads WITHCACHE_ADMIN_PASSWORD
219
+ from env and sends it as ``Authorization: Bearer <pw>``."""
220
+ r = self.client.post(
221
+ "/catalog/entries",
222
+ json={"name": "demo", "src": "https://example/x"},
223
+ headers={"Authorization": f"Bearer {TEST_PASSWORD}"},
224
+ )
225
+ self.assertEqual(r.status_code, 201)
226
+
227
+ def test_add_entry_401_with_bearer_mismatch(self) -> None:
228
+ r = self.client.post(
229
+ "/catalog/entries",
230
+ json={"name": "demo", "src": "https://example/x"},
231
+ headers={"Authorization": "Bearer wrong-pw"},
232
+ )
233
+ self.assertEqual(r.status_code, 401)
234
+
235
+ def test_delete_entry_401_without_auth(self) -> None:
236
+ r = self.client.delete("/catalog/entries?name=demo")
237
+ self.assertEqual(r.status_code, 401)
238
+
239
+ def test_delete_entry_204_with_bearer(self) -> None:
240
+ self.catalog.entries = [{"name": "demo", "src": "https://example/x"}]
241
+ r = self.client.delete(
242
+ "/catalog/entries?name=demo",
243
+ headers={"Authorization": f"Bearer {TEST_PASSWORD}"},
244
+ )
245
+ self.assertEqual(r.status_code, 204)
246
+
247
+
248
+ if __name__ == "__main__": # pragma: no cover
249
+ unittest.main()
@@ -33,12 +33,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
33
33
 
34
34
  try:
35
35
  from fastapi.testclient import TestClient # noqa: E402
36
- except (ImportError, RuntimeError): # pragma: no cover
37
- # ImportError -> fastapi absent; RuntimeError -> starlette's
38
- # ``testclient`` raises when httpx isn't installed. Either way,
39
- # skip so an unprepped CI runner reports a green skip rather
40
- # than a red error.
41
- raise unittest.SkipTest("fastapi + httpx not available (port scaffolding deps)") from None
36
+ except ImportError: # pragma: no cover
37
+ raise unittest.SkipTest("fastapi not installed") from None
42
38
 
43
39
  from withcache._app import create_app # noqa: E402
44
40
 
@@ -26,8 +26,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
26
26
 
27
27
  try:
28
28
  from fastapi.testclient import TestClient # noqa: E402
29
- except (ImportError, RuntimeError): # pragma: no cover
30
- raise unittest.SkipTest("fastapi + httpx not available (port scaffolding deps)") from None
29
+ except ImportError: # pragma: no cover
30
+ raise unittest.SkipTest("fastapi not installed") from None
31
31
 
32
32
  from withcache import _settings_store # noqa: E402
33
33
  from withcache._app import create_app # noqa: E402
@@ -20,8 +20,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
20
20
 
21
21
  try:
22
22
  from fastapi.testclient import TestClient # noqa: E402
23
- except (ImportError, RuntimeError): # pragma: no cover
24
- raise unittest.SkipTest("fastapi + httpx not available (port scaffolding deps)") from None
23
+ except ImportError: # pragma: no cover
24
+ raise unittest.SkipTest("fastapi not installed") from None
25
25
 
26
26
  from withcache._app import create_app # noqa: E402
27
27
  from withcache.server import CatalogState # noqa: E402
@@ -20,8 +20,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
20
20
 
21
21
  import base64
22
22
  import urllib.error
23
- import urllib.parse
24
- import urllib.request
25
23
 
26
24
  from withcache import _shim, server
27
25
 
@@ -115,6 +113,15 @@ class TestAuth(unittest.TestCase):
115
113
  a = server.Auth(b"k", None)
116
114
  self.assertFalse(a.enabled)
117
115
  self.assertFalse(a.check_password("anything"))
116
+ self.assertFalse(a.check_bearer("anything"))
117
+
118
+ def test_bearer_check(self):
119
+ """Bearer path uses constant-time compare against the same
120
+ admin password ``check_password`` gates the UI login on."""
121
+ a = server.Auth(b"k", "hunter2")
122
+ self.assertTrue(a.check_bearer("hunter2"))
123
+ self.assertFalse(a.check_bearer("nope"))
124
+ self.assertFalse(a.check_bearer(""))
118
125
 
119
126
 
120
127
  # --------------------------------------------------------------------------
@@ -508,30 +515,6 @@ class TestStreamRegistry(unittest.TestCase):
508
515
  self.assertEqual(reg.snapshot(), [])
509
516
 
510
517
 
511
- class TestAgeHuman(unittest.TestCase):
512
- """``_age_human`` renders elapsed seconds into the compact form the
513
- Streams table cell shows. Inject ``now`` so the test doesn't have
514
- to monkeypatch ``time.time``."""
515
-
516
- def test_seconds_only(self):
517
- self.assertEqual(server._age_human(100.0, now=100.0), "0s")
518
- self.assertEqual(server._age_human(100.0, now=159.0), "59s")
519
-
520
- def test_minutes_pad_seconds(self):
521
- self.assertEqual(server._age_human(100.0, now=160.0), "1m00s")
522
- self.assertEqual(server._age_human(100.0, now=222.0), "2m02s")
523
-
524
- def test_hours_pad_minutes(self):
525
- self.assertEqual(server._age_human(0.0, now=3600.0), "1h00m")
526
- self.assertEqual(server._age_human(0.0, now=3661.0), "1h01m")
527
- self.assertEqual(server._age_human(0.0, now=7320.0), "2h02m")
528
-
529
- def test_negative_clamps_to_zero(self):
530
- # Started-at in the future (clock skew, replayed snapshot) renders
531
- # as 0s rather than a confusing negative.
532
- self.assertEqual(server._age_human(200.0, now=100.0), "0s")
533
-
534
-
535
518
  # --------------------------------------------------------------------------
536
519
  # _shim: URL detection, rewrite, real-tool resolution, env, path-encoding
537
520
  # --------------------------------------------------------------------------
@@ -1,89 +0,0 @@
1
- """A tiny client for consuming a withcache cache-host from other tools.
2
-
3
- Lets a consumer (e.g. bty) point downloads at withcache without re-implementing
4
- the ``/b/`` URL scheme. Stdlib only, so importing it pulls in no third-party
5
- dependencies.
6
-
7
- from withcache import client
8
-
9
- # "use the cache when it's warm, the origin otherwise"
10
- url = client.serve_url("http://cache:8081", origin) or origin
11
-
12
- The ``/b/<urlsafe-b64(origin)>/<basename>`` encoding is shared with the shims
13
- and the server (one definition in :mod:`withcache._shim`), so consumers stay in
14
- lockstep with the cache-host automatically.
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- import urllib.error
20
- import urllib.request
21
-
22
- from . import _shim
23
-
24
- __all__ = ["PROBE_TIMEOUT", "blob_url", "cache_base", "is_cached", "serve_url"]
25
-
26
- PROBE_TIMEOUT = 3.0 # seconds; never block the caller on a slow/unreachable cache
27
-
28
- #: Normalize a server value: accepts 'host', 'host:8081', or 'http://host:8081'.
29
- cache_base = _shim.cache_base
30
-
31
-
32
- def blob_url(server: str, origin: str) -> str:
33
- """The cache-host serve URL for ``origin``:
34
- ``<server>/b/<urlsafe-b64(origin), unpadded>/<basename>``. The trailing
35
- basename is cosmetic (so any downloader names the saved file after the
36
- artifact); the cache keys on the decoded origin URL."""
37
- return _shim.blob_url(_shim.cache_base(server), origin)
38
-
39
-
40
- def is_cached(
41
- server: str,
42
- origin: str,
43
- timeout: float = PROBE_TIMEOUT,
44
- headers: dict[str, str] | None = None,
45
- ) -> bool:
46
- """True if the cache-host already holds ``origin`` (a ``HEAD`` on ``/b/``
47
- returns 200). A miss (404), an unreachable host, a timeout, or any error
48
- returns False, so a caller can safely fall back to the origin. The HEAD
49
- also *warms* an auto-fetch cache-host: the miss is recorded and the
50
- background fill enqueued, so a later probe flips to cached.
51
-
52
- ``headers`` (optional) attaches request headers to the HEAD. The
53
- cache-host forwards a client-supplied ``Authorization`` into its
54
- background-fetch worker, so a consumer that has just minted an OCI
55
- bearer (the typical use case: bty resolving an ``oras://`` catalog
56
- entry to a ``ghcr.io`` blob URL at import time) can warm the cache
57
- against that token-gated origin in one probe. Other entries in
58
- ``headers`` round-trip the same way; only ``Authorization`` is
59
- forwarded into the fetch on the server side.
60
- """
61
- req = urllib.request.Request(blob_url(server, origin), method="HEAD")
62
- if headers:
63
- for k, v in headers.items():
64
- req.add_header(k, v)
65
- try:
66
- with urllib.request.urlopen(req, timeout=timeout) as resp:
67
- return bool(resp.status == 200)
68
- except urllib.error.HTTPError:
69
- return False # 404 miss (now recorded + enqueued by the cache-host)
70
- except (urllib.error.URLError, OSError):
71
- return False # unreachable / timeout -> caller serves the origin itself
72
-
73
-
74
- def serve_url(
75
- server: str,
76
- origin: str,
77
- timeout: float = PROBE_TIMEOUT,
78
- headers: dict[str, str] | None = None,
79
- ) -> str | None:
80
- """The cache-host serve URL for ``origin`` if the cache holds it, else
81
- ``None`` -- the convenience form of "use the cache when warm":
82
-
83
- url = client.serve_url(cache, origin) or origin
84
-
85
- ``headers`` is passed through to :func:`is_cached` for the HEAD probe;
86
- the returned serve URL never carries auth (cached bytes are served
87
- without revisiting the origin).
88
- """
89
- return blob_url(server, origin) if is_cached(server, origin, timeout, headers=headers) else None
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