withcache 0.9.1__tar.gz → 0.10.0__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 (45) hide show
  1. {withcache-0.9.1 → withcache-0.10.0}/PKG-INFO +1 -1
  2. {withcache-0.9.1 → withcache-0.10.0}/shim/build.zig.zon +1 -1
  3. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/__init__.py +1 -1
  4. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_api.py +78 -22
  5. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_app.py +110 -6
  6. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_templates/ui/catalog.html +115 -8
  7. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/client.py +29 -0
  8. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/server.py +3 -8
  9. {withcache-0.9.1 → withcache-0.10.0}/tests/test_fastapi_blob.py +17 -42
  10. {withcache-0.9.1 → withcache-0.10.0}/tests/test_fastapi_catalog_api.py +67 -0
  11. {withcache-0.9.1 → withcache-0.10.0}/.gitignore +0 -0
  12. {withcache-0.9.1 → withcache-0.10.0}/LICENSE +0 -0
  13. {withcache-0.9.1 → withcache-0.10.0}/README.md +0 -0
  14. {withcache-0.9.1 → withcache-0.10.0}/deploy/Containerfile +0 -0
  15. {withcache-0.9.1 → withcache-0.10.0}/deploy/compose.local-build.yml +0 -0
  16. {withcache-0.9.1 → withcache-0.10.0}/deploy/compose.yml +0 -0
  17. {withcache-0.9.1 → withcache-0.10.0}/deploy/envvars.example +0 -0
  18. {withcache-0.9.1 → withcache-0.10.0}/hatch_build.py +0 -0
  19. {withcache-0.9.1 → withcache-0.10.0}/pyproject.toml +0 -0
  20. {withcache-0.9.1 → withcache-0.10.0}/shim/build.zig +0 -0
  21. {withcache-0.9.1 → withcache-0.10.0}/shim/shim.zig +0 -0
  22. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_settings_store.py +0 -0
  23. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_shim.py +0 -0
  24. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_templates/ui/_layout.html +0 -0
  25. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_templates/ui/cached.html +0 -0
  26. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_templates/ui/downloads.html +0 -0
  27. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_templates/ui/login.html +0 -0
  28. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_templates/ui/misses.html +0 -0
  29. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_templates/ui/settings.html +0 -0
  30. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/curlwithcache.py +0 -0
  31. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/oras.py +0 -0
  32. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/static/bootstrap-icons.min.css +0 -0
  33. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/static/bootstrap.min.css +0 -0
  34. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  35. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  36. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/static/htmx.min.js +0 -0
  37. {withcache-0.9.1 → withcache-0.10.0}/src/withcache/wgetwithcache.py +0 -0
  38. {withcache-0.9.1 → withcache-0.10.0}/tests/test_differential.py +0 -0
  39. {withcache-0.9.1 → withcache-0.10.0}/tests/test_fastapi_admin_forms.py +0 -0
  40. {withcache-0.9.1 → withcache-0.10.0}/tests/test_fastapi_scaffold.py +0 -0
  41. {withcache-0.9.1 → withcache-0.10.0}/tests/test_fastapi_settings_persistence.py +0 -0
  42. {withcache-0.9.1 → withcache-0.10.0}/tests/test_fastapi_ui_pages.py +0 -0
  43. {withcache-0.9.1 → withcache-0.10.0}/tests/test_fastapi_uvicorn_smoke.py +0 -0
  44. {withcache-0.9.1 → withcache-0.10.0}/tests/test_oras.py +0 -0
  45. {withcache-0.9.1 → withcache-0.10.0}/tests/test_withcache.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.9.1
3
+ Version: 0.10.0
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.1",
5
+ .version = "0.10.0",
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.1"
24
+ __version__ = "0.10.0"
25
25
 
26
26
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
@@ -65,39 +65,36 @@ def _serve_blob(
65
65
  ) -> Response:
66
66
  """Shared implementation for GET and HEAD blob requests.
67
67
 
68
- Returns 400 when the origin URL couldn't be decoded, 404 (with
69
- miss recording + optional fetch enqueue) on cache miss, or 200
70
- + a StreamingResponse on hit.
68
+ Returns 400 when the origin URL couldn't be decoded, 404 on
69
+ cache miss (with a hint pointing at ``POST /catalog/entries/
70
+ {name}/download``), or 200 + a StreamingResponse on hit.
71
+
72
+ Since v0.10.0 there is no auto-fetch on miss: the operator
73
+ picks explicitly what to download on withcache's /ui/catalog
74
+ page. A miss here is either "not downloaded yet" (operator
75
+ action needed) or "hit an unlisted URL" (bug in the caller).
71
76
  """
72
77
  if not url:
73
78
  return PlainTextResponse("missing url\n", status_code=400)
74
79
 
75
80
  store = request.app.state.store
76
- mgr = request.app.state.mgr
77
- auto_fetch: bool = request.app.state.auto_fetch
78
81
  streams: StreamRegistry = request.app.state.streams
79
82
 
80
83
  row = store.get_blob(url)
81
84
  if row is not None and _oras_tag_moved(url, row["sha256"]):
82
- # Tag re-pushed since we cached it: drop the stale bytes so
83
- # the miss branch below re-fetches the current content.
85
+ # Tag re-pushed since we cached it: drop the stale bytes.
86
+ # The miss branch below turns this into a 404 the operator
87
+ # resolves by hitting Download / Redownload on /ui/catalog.
84
88
  store.delete_blob(row["key"])
85
89
  row = None
86
90
 
87
91
  if row is None:
88
92
  store.record_miss(url)
89
- if auto_fetch and store.has_capacity():
90
- # Forward the client's Authorization header onto the
91
- # worker so a token-gated origin (fresh OCI bearer from
92
- # bty at catalog import time) can be fetched. Narrow
93
- # allowlist: Authorization only; /admin/fetch carries
94
- # its own ``headers=`` payload for the curated path.
95
- fwd_headers: dict[str, str] | None = None
96
- auth_header = request.headers.get("Authorization")
97
- if auth_header:
98
- fwd_headers = {"Authorization": auth_header}
99
- mgr.enqueue(url, headers=fwd_headers)
100
- return PlainTextResponse("cache miss (recorded)\n", status_code=404)
93
+ return PlainTextResponse(
94
+ "cache miss: this URL hasn't been downloaded yet. "
95
+ "Pick the matching catalog entry on /ui/catalog and hit Download.\n",
96
+ status_code=404,
97
+ )
101
98
 
102
99
  path = store.blob_path(row["key"])
103
100
  headers = {
@@ -218,17 +215,29 @@ def register_api_routes(app: FastAPI) -> None:
218
215
  model -- LAN-only, no auth on reads). Returns a snapshot of
219
216
  the in-process ``CatalogState.entries`` plus provenance
220
217
  metadata (URL, env pin, fetch timestamps) so clients can
221
- detect staleness. No pagination -- the catalog is small
222
- (dozens of entries at most).
218
+ detect staleness. Every entry is enriched with a
219
+ ``downloaded_at`` field (or ``None``) so nbdmux + bty can
220
+ filter to only-downloaded entries without a second call. No
221
+ pagination -- the catalog is small (dozens of entries at
222
+ most).
223
223
  """
224
224
  cs: CatalogState = request.app.state.catalog
225
+ store = request.app.state.store
226
+ entries_out: list[dict[str, Any]] = []
227
+ for e in cs.entries:
228
+ enriched = dict(e)
229
+ fetch_url = e.get("resolved_src") or e.get("src") or ""
230
+ row = store.get_blob(fetch_url) if fetch_url else None
231
+ enriched["downloaded_at"] = row["fetched_at"] if row else None
232
+ enriched["downloaded_size"] = int(row["size"]) if row else None
233
+ entries_out.append(enriched)
225
234
  return JSONResponse(
226
235
  {
227
236
  "url": cs.url,
228
237
  "env_url": cs.env_url,
229
238
  "fetched_at": cs.fetched_at,
230
239
  "last_error": cs.last_error,
231
- "entries": [dict(e) for e in cs.entries],
240
+ "entries": entries_out,
232
241
  }
233
242
  )
234
243
 
@@ -290,6 +299,53 @@ def register_api_routes(app: FastAPI) -> None:
290
299
  _persist_catalog(cs)
291
300
  return JSONResponse(entry, status_code=201)
292
301
 
302
+ @app.post("/catalog/entries/{name}/download", status_code=202)
303
+ def download_catalog_entry(
304
+ name: str,
305
+ request: Request,
306
+ _auth: None = Depends(_bearer_or_session_authed),
307
+ ) -> JSONResponse:
308
+ """Fetch the entry's bytes into the local cache.
309
+
310
+ Since v0.10.0 auto-fetch on cache miss is gone: the operator
311
+ picks explicitly what to download. Nbdmux + bty's flash
312
+ chain both refuse to hand out /b/<url> URLs for entries
313
+ that haven't been downloaded, so this is the one-place
314
+ control for "make it servable."
315
+
316
+ Idempotent: enqueuing an entry that already has a live
317
+ download returns the existing job. Force-refetch is
318
+ ``POST /catalog/entries/{name}/download?force=1``.
319
+ """
320
+ cs: CatalogState = request.app.state.catalog
321
+ mgr = request.app.state.mgr
322
+ store = request.app.state.store
323
+
324
+ entry = next((e for e in cs.entries if e.get("name") == name), None)
325
+ if entry is None:
326
+ raise HTTPException(status_code=404, detail=f"no catalog entry with name={name!r}")
327
+ fetch_url = entry.get("resolved_src") or entry.get("src") or ""
328
+ if not fetch_url:
329
+ raise HTTPException(
330
+ status_code=400,
331
+ detail=f"catalog entry {name!r} has no ``src`` / ``resolved_src`` to fetch",
332
+ )
333
+ force = request.query_params.get("force") in ("1", "true", "yes")
334
+ if force:
335
+ existing = store.get_blob(fetch_url)
336
+ if existing is not None:
337
+ store.delete_blob(existing["key"])
338
+ job = mgr.enqueue(fetch_url)
339
+ return JSONResponse(
340
+ {
341
+ "name": name,
342
+ "src": fetch_url,
343
+ "job_id": job.id,
344
+ "status": job.status,
345
+ },
346
+ status_code=202,
347
+ )
348
+
293
349
  @app.delete("/catalog/entries", status_code=204)
294
350
  def delete_catalog_entry(
295
351
  request: Request,
@@ -74,7 +74,6 @@ def create_app(
74
74
  mgr: DownloadManager | None = None,
75
75
  streams: StreamRegistry | None = None,
76
76
  catalog: CatalogState | None = None,
77
- auto_fetch: bool = True,
78
77
  keep_query: bool = False,
79
78
  max_bytes: int = 0,
80
79
  run_lifecycle: bool = False,
@@ -90,8 +89,8 @@ def create_app(
90
89
  stable bytes value so cookies stay valid across the fixture's
91
90
  lifetime without touching the disk.
92
91
 
93
- ``store`` / ``mgr`` / ``streams`` / ``auto_fetch`` let tests
94
- inject stubs / capture doubles without spawning the real
92
+ ``store`` / ``mgr`` / ``streams`` let tests inject stubs /
93
+ capture doubles without spawning the real
95
94
  :class:`DownloadManager` worker thread. :func:`server.main`
96
95
  passes real instances at daemon start.
97
96
  """
@@ -169,7 +168,6 @@ def create_app(
169
168
  )
170
169
  app.state.mgr = mgr if mgr is not None else DownloadManager(app.state.store)
171
170
  app.state.streams = streams if streams is not None else StreamRegistry()
172
- app.state.auto_fetch = auto_fetch
173
171
  # Auth object exposed on app.state so :func:`register_api_routes`
174
172
  # (which owns the JSON catalog write endpoints) can gate them on
175
173
  # ``Authorization: Bearer <pw>``. The UI form + login flow read
@@ -303,15 +301,41 @@ def create_app(
303
301
  def ui_catalog(request: Request, _auth_check: None = Depends(require_ui_auth)) -> HTMLResponse:
304
302
  """Catalog view: current URL + env pin + on-disk override
305
303
  provenance, plus the entries table. Refresh + Set URL +
306
- Add oras + Delete are on the subnav / row forms."""
304
+ Add oras + Download + Delete are on the subnav / row forms.
305
+
306
+ Every entry is enriched with ``downloaded_at`` from the
307
+ store (or ``None``) + an ``active_job_status`` flag from
308
+ the DownloadManager so the row's Downloaded pill shows
309
+ "-" / "queued" / "running" / "cached" without a separate
310
+ polling round-trip. Same shape ``GET /catalog`` returns to
311
+ the JSON clients.
312
+ """
307
313
  cs: CatalogState = app.state.catalog
314
+ store = app.state.store
315
+ mgr = app.state.mgr
316
+ # Map url -> latest job status for anything currently
317
+ # pending / running. Keeps the template cheap: it just
318
+ # renders a small pill without knowing the pipeline shape.
319
+ pending: dict[str, str] = {}
320
+ for job in mgr.list():
321
+ if job.status in ("queued", "running") and job.url not in pending:
322
+ pending[job.url] = job.status
323
+ enriched: list[dict[str, Any]] = []
324
+ for entry in cs.entries:
325
+ row = {**entry}
326
+ fetch_url = entry.get("resolved_src") or entry.get("src") or ""
327
+ blob = store.get_blob(fetch_url) if fetch_url else None
328
+ row["downloaded_at"] = blob["fetched_at"] if blob else None
329
+ row["downloaded_size"] = int(blob["size"]) if blob else None
330
+ row["active_job_status"] = pending.get(fetch_url)
331
+ enriched.append(row)
308
332
  return render(
309
333
  "ui/catalog.html",
310
334
  request,
311
335
  nav_active="catalog",
312
336
  catalog_url=cs.url,
313
337
  catalog_env_url=cs.env_url,
314
- catalog_entries=cs.entries,
338
+ catalog_entries=enriched,
315
339
  catalog_fetched_at=cs.fetched_at,
316
340
  catalog_last_error=cs.last_error,
317
341
  catalog_last_info=cs.last_info,
@@ -491,6 +515,56 @@ def create_app(
491
515
  app.state.catalog.add_oras_entry(url or "")
492
516
  return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
493
517
 
518
+ @app.post("/admin/catalog_add_entry")
519
+ def ui_admin_catalog_add_entry(
520
+ name: str = Form(""),
521
+ src: str = Form(""),
522
+ sha256: str = Form(""),
523
+ format: str = Form(""),
524
+ arch: str = Form(""),
525
+ size_bytes: str = Form(""),
526
+ description: str = Form(""),
527
+ _auth_check: None = Depends(require_ui_auth),
528
+ ) -> RedirectResponse:
529
+ """Full-shape add-entry form for the browser. Reuses the same
530
+ allowlist as the JSON ``POST /catalog/entries`` endpoint;
531
+ empty fields are dropped so the emitter doesn't persist them.
532
+ Validation errors surface as ``catalog.last_error`` and the
533
+ Catalog page renders them; happy path 303s back to
534
+ /ui/catalog with the entry visible in the table."""
535
+ cs: CatalogState = app.state.catalog
536
+ _name = name.strip()
537
+ _src = src.strip()
538
+ if not _name or not _src:
539
+ cs.last_error = "name and src are required"
540
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
541
+ if any(e.get("name") == _name for e in cs.entries):
542
+ cs.last_error = f"catalog entry with name={_name!r} already exists"
543
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
544
+ entry: dict[str, Any] = {"name": _name, "src": _src}
545
+ for key, val in (
546
+ ("sha256", sha256.strip()),
547
+ ("format", format.strip()),
548
+ ("arch", arch.strip()),
549
+ ("description", description.strip()),
550
+ ):
551
+ if val:
552
+ entry[key] = val
553
+ sb = size_bytes.strip()
554
+ if sb:
555
+ try:
556
+ entry["size_bytes"] = int(sb)
557
+ except ValueError:
558
+ cs.last_error = f"size_bytes must be an integer, got {sb!r}"
559
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
560
+ cs.entries.append(entry)
561
+ cs.last_error = None
562
+ # Persist to disk so the entry survives a restart.
563
+ from ._api import _persist_catalog
564
+
565
+ _persist_catalog(cs)
566
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
567
+
494
568
  @app.post("/admin/catalog_delete_entry")
495
569
  def ui_admin_catalog_delete_entry(
496
570
  name: str = Form(""),
@@ -501,4 +575,34 @@ def create_app(
501
575
  app.state.catalog.last_error = msg
502
576
  return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
503
577
 
578
+ @app.post("/admin/catalog_download_entry")
579
+ def ui_admin_catalog_download_entry(
580
+ name: str = Form(""),
581
+ force: str = Form(""),
582
+ _auth_check: None = Depends(require_ui_auth),
583
+ ) -> RedirectResponse:
584
+ """Form-encoded sibling of ``POST /catalog/entries/{name}/download``.
585
+
586
+ Adds an enqueue for the named entry; a truthy ``force`` field
587
+ drops any existing cached bytes first so the redownload
588
+ replaces stale content instead of hitting the dedup-on-active
589
+ branch in :class:`DownloadManager`.
590
+ """
591
+ cs: CatalogState = app.state.catalog
592
+ entry = next((e for e in cs.entries if e.get("name") == (name or "").strip()), None)
593
+ if entry is None:
594
+ cs.last_error = f"no catalog entry with name={name!r}"
595
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
596
+ fetch_url = entry.get("resolved_src") or entry.get("src") or ""
597
+ if not fetch_url:
598
+ cs.last_error = f"catalog entry {name!r} has no ``src`` / ``resolved_src`` to fetch"
599
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
600
+ if force.strip().lower() in ("1", "true", "on", "yes"):
601
+ existing = app.state.store.get_blob(fetch_url)
602
+ if existing is not None:
603
+ app.state.store.delete_blob(existing["key"])
604
+ app.state.mgr.enqueue(fetch_url)
605
+ cs.last_error = ""
606
+ return RedirectResponse(url="/ui/catalog", status_code=status.HTTP_303_SEE_OTHER)
607
+
504
608
  return app
@@ -85,25 +85,94 @@
85
85
  {% endif %}
86
86
  </div>
87
87
  </form>
88
- {# Add oras: append a fresh oras:// entry to the in-memory
89
- catalog. Useful for one-off pulls that aren't in the
90
- upstream manifest. #}
88
+ {# Add oras: append a fresh oras:// entry. The manifest walk
89
+ populates sha256/format/size from the registry so the
90
+ operator doesn't have to type them. #}
91
91
  <form method="post" action="/admin/catalog_add_oras" class="mt-2 mb-0">
92
92
  <label for="catalog-add-oras-input" class="form-label small mb-1">
93
- Add oras:// entry
93
+ Add oras:// entry (registry auto-resolves)
94
94
  </label>
95
95
  <div class="d-flex gap-2">
96
96
  <input type="text" class="form-control form-control-sm font-monospace"
97
97
  id="catalog-add-oras-input" name="url" required
98
98
  placeholder="oras://ghcr.io/owner/repo:tag">
99
99
  <button type="submit" class="btn btn-sm btn-outline-primary text-nowrap">
100
- <i class="bi bi-plus-circle me-1"></i>Add
100
+ <i class="bi bi-plus-circle me-1"></i>Add oras
101
101
  </button>
102
102
  </div>
103
103
  </form>
104
104
  </div>
105
105
  </div>
106
106
 
107
+ <div class="card mb-3">
108
+ <div class="card-header bg-light fw-semibold py-2">
109
+ <i class="bi bi-plus-square me-1"></i>Add HTTPS entry
110
+ </div>
111
+ <div class="card-body small">
112
+ <p class="text-muted mb-2">
113
+ Add one https:// catalog entry. Operators typically fill
114
+ <em>name</em>, <em>src</em>, and <em>sha256</em>; other
115
+ fields are optional. For <em>oras://</em> entries use the
116
+ form above instead (the registry walk fills sha256 /
117
+ format / size automatically).
118
+ </p>
119
+ <form method="post" action="/admin/catalog_add_entry" class="mb-0">
120
+ <div class="row g-2 mb-2">
121
+ <div class="col-md-4">
122
+ <label class="form-label small mb-1" for="ce-name">Name</label>
123
+ <input type="text" class="form-control form-control-sm"
124
+ id="ce-name" name="name" required
125
+ placeholder="my-appliance-2026.W28">
126
+ </div>
127
+ <div class="col-md-8">
128
+ <label class="form-label small mb-1" for="ce-src">Source URL</label>
129
+ <input type="url" class="form-control form-control-sm font-monospace"
130
+ id="ce-src" name="src" required
131
+ placeholder="https://example.com/appliance.img.gz">
132
+ </div>
133
+ </div>
134
+ <div class="row g-2 mb-2">
135
+ <div class="col-md-6">
136
+ <label class="form-label small mb-1" for="ce-sha256">SHA-256</label>
137
+ <input type="text" class="form-control form-control-sm font-monospace"
138
+ id="ce-sha256" name="sha256"
139
+ pattern="[0-9a-fA-F]{64}"
140
+ placeholder="64-hex, e.g. abcdef0123... (leave blank if unknown)">
141
+ </div>
142
+ <div class="col-md-3">
143
+ <label class="form-label small mb-1" for="ce-format">Format</label>
144
+ <input type="text" class="form-control form-control-sm"
145
+ id="ce-format" name="format"
146
+ placeholder="img.gz | img.zst | qcow2">
147
+ </div>
148
+ <div class="col-md-3">
149
+ <label class="form-label small mb-1" for="ce-arch">Arch</label>
150
+ <input type="text" class="form-control form-control-sm"
151
+ id="ce-arch" name="arch"
152
+ placeholder="x86_64 | aarch64">
153
+ </div>
154
+ </div>
155
+ <div class="row g-2 mb-2">
156
+ <div class="col-md-4">
157
+ <label class="form-label small mb-1" for="ce-size">Size (bytes)</label>
158
+ <input type="number" min="0" class="form-control form-control-sm"
159
+ id="ce-size" name="size_bytes"
160
+ placeholder="optional">
161
+ </div>
162
+ <div class="col-md-8">
163
+ <label class="form-label small mb-1" for="ce-desc">Description</label>
164
+ <input type="text" class="form-control form-control-sm"
165
+ id="ce-desc" name="description"
166
+ placeholder="Optional operator prose (shown on bty's /ui/images)">
167
+ </div>
168
+ </div>
169
+ <button type="submit" class="btn btn-sm btn-primary">
170
+ <i class="bi bi-plus-circle me-1"></i>Add entry
171
+ </button>
172
+ </form>
173
+ </div>
174
+ </div>
175
+
107
176
  <div class="card mb-3">
108
177
  <div class="card-header bg-light fw-semibold py-2">
109
178
  <i class="bi bi-list-ul me-1"></i>Catalog entries ({{ catalog_entries|length }})
@@ -116,7 +185,8 @@
116
185
  <th>Source</th>
117
186
  <th>Format</th>
118
187
  <th>SHA-256</th>
119
- <th class="text-end" style="width: 1%; white-space: nowrap;">Action</th>
188
+ <th title="When withcache last fetched this entry's bytes to disk. bty's flash chain + nbdmux's export creation both refuse entries with no timestamp here.">Downloaded</th>
189
+ <th class="text-end" style="width: 1%; white-space: nowrap;">Actions</th>
120
190
  </tr>
121
191
  </thead>
122
192
  <tbody>
@@ -137,8 +207,45 @@
137
207
  <td class="small font-monospace">
138
208
  {% if e.get('sha256') %}{{ e.sha256[:12] }}...{% else %}<span class="text-muted">-</span>{% endif %}
139
209
  </td>
210
+ <td class="small">
211
+ {% if e.get('active_job_status') %}
212
+ <span class="badge bg-info text-dark" title="Download in flight">
213
+ <i class="bi bi-arrow-down-circle me-1"></i>{{ e.active_job_status }}
214
+ </span>
215
+ {% elif e.get('downloaded_at') %}
216
+ <span class="badge bg-success" title="{{ e.downloaded_at }}">
217
+ <i class="bi bi-check-circle me-1"></i>cached
218
+ </span>
219
+ {% else %}
220
+ <span class="text-muted">-</span>
221
+ {% endif %}
222
+ </td>
140
223
  <td class="text-end text-nowrap">
141
- <form method="post" action="/admin/catalog_delete_entry" class="d-inline m-0"
224
+ {# Download / Redownload: enqueue an explicit fetch
225
+ of this entry's src into the local cache. The
226
+ button label switches on ``downloaded_at`` so
227
+ the operator sees whether the click will fill
228
+ an empty slot or replace stale bytes. #}
229
+ <form method="post" action="/admin/catalog_download_entry"
230
+ class="d-inline m-0">
231
+ <input type="hidden" name="name" value="{{ e.get('name', '') }}">
232
+ {% if e.get('downloaded_at') %}
233
+ <input type="hidden" name="force" value="1">
234
+ <button type="submit" class="btn btn-sm btn-outline-primary"
235
+ title="Delete the cached bytes and refetch from origin"
236
+ {% if e.get('active_job_status') %}disabled{% endif %}>
237
+ <i class="bi bi-arrow-repeat"></i> Redownload
238
+ </button>
239
+ {% else %}
240
+ <button type="submit" class="btn btn-sm btn-primary"
241
+ title="Fetch this entry's bytes into the local cache"
242
+ {% if e.get('active_job_status') %}disabled{% endif %}>
243
+ <i class="bi bi-download"></i> Download
244
+ </button>
245
+ {% endif %}
246
+ </form>
247
+ <form method="post" action="/admin/catalog_delete_entry"
248
+ class="d-inline m-0"
142
249
  onsubmit="return confirm('Delete catalog entry {{ e.get('name', '') }}?');">
143
250
  <input type="hidden" name="name" value="{{ e.get('name', '') }}">
144
251
  <button type="submit" class="btn btn-sm btn-outline-danger"
@@ -150,7 +257,7 @@
150
257
  </tr>
151
258
  {% endfor %}
152
259
  {% if not catalog_entries %}
153
- <tr><td colspan="5" class="text-muted text-center py-3">
260
+ <tr><td colspan="6" class="text-muted text-center py-3">
154
261
  No catalog entries yet. The startup fetch will populate
155
262
  this list; failures land in the source card above.
156
263
  </td></tr>
@@ -214,6 +214,35 @@ def add_catalog_entry(
214
214
  )
215
215
 
216
216
 
217
+ def download_catalog_entry(
218
+ server: str,
219
+ name: str,
220
+ *,
221
+ force: bool = False,
222
+ timeout: float = CATALOG_TIMEOUT,
223
+ password: str | None = None,
224
+ ) -> dict[str, Any]:
225
+ """POST ``/catalog/entries/<name>/download``: enqueue an explicit
226
+ fetch of the entry's ``src`` into the local cache.
227
+
228
+ Since v0.10.0 withcache does no auto-fetch on cache miss, so
229
+ every downstream consumer (bty flash chain, nbdmux export
230
+ creation) refuses entries that haven't been downloaded. This is
231
+ the one-place control.
232
+
233
+ ``force=True`` deletes any existing cached bytes first so a
234
+ corrupted / stale entry can be replaced without a manual
235
+ intermediate delete. Returns the enqueued job's ``{id, status}``.
236
+
237
+ Raises :class:`WithcacheError` on 404 (no such entry) / 400
238
+ (entry has no fetchable src) / transport failure.
239
+ """
240
+ path = f"/catalog/entries/{urllib.parse.quote(name, safe='')}/download"
241
+ if force:
242
+ path += "?force=1"
243
+ return _catalog_request("POST", server, path, timeout=timeout, password=password)
244
+
245
+
217
246
  def delete_catalog_entry(
218
247
  server: str,
219
248
  name: str,
@@ -1022,12 +1022,6 @@ def main():
1022
1022
  ap.add_argument(
1023
1023
  "--workers", type=int, default=2, help="concurrent background download workers (default: 2)"
1024
1024
  )
1025
- ap.add_argument(
1026
- "--curate",
1027
- action="store_true",
1028
- help="require an operator to approve each pull (default: auto-fetch a "
1029
- "missed artifact in the background so the next request hits)",
1030
- )
1031
1025
  ap.add_argument(
1032
1026
  "--max-bytes",
1033
1027
  default="0",
@@ -1040,10 +1034,12 @@ def main():
1040
1034
  mgr = DownloadManager(store, workers=args.workers)
1041
1035
  auth_password = os.environ.get("WITHCACHE_ADMIN_PASSWORD")
1042
1036
 
1037
+ # Since v0.10.0 there is no auto-fetch on miss: the operator
1038
+ # hits Download on /ui/catalog to enqueue a fetch. The daemon
1039
+ # is always in "curate" mode; the --curate flag went with it.
1043
1040
  print(
1044
1041
  f"withcache cache-host on http://{args.host}:{args.port} "
1045
1042
  f"(data={store.data_dir}, keep_query={args.keep_query}, workers={args.workers}, "
1046
- f"mode={'curate' if args.curate else 'auto-fetch'}, "
1047
1043
  f"max_bytes={'unlimited' if not store.max_bytes else human_size(store.max_bytes)})",
1048
1044
  flush=True,
1049
1045
  )
@@ -1057,7 +1053,6 @@ def main():
1057
1053
  data_dir=store.data_dir,
1058
1054
  store=store,
1059
1055
  mgr=mgr,
1060
- auto_fetch=not args.curate,
1061
1056
  keep_query=args.keep_query,
1062
1057
  max_bytes=parse_size(args.max_bytes),
1063
1058
  run_lifecycle=True,
@@ -172,52 +172,27 @@ class BlobHeadTests(_BlobBase):
172
172
  self.assertEqual(r.status_code, 200)
173
173
 
174
174
 
175
- class BlobMissAutoFetchTests(_BlobBase):
176
- def test_miss_enqueues_on_auto_fetch(self) -> None:
177
- url = "https://example.invalid/enqueue.bin"
178
- self.client.get("/blob", params={"url": url})
179
- self.assertEqual(len(self.enqueue_calls), 1)
180
- self.assertEqual(self.enqueue_calls[0][0], url)
181
-
182
- def test_miss_forwards_authorization_header(self) -> None:
183
- url = "https://example.invalid/token-gated.bin"
184
- self.client.get(
185
- "/blob",
186
- params={"url": url},
187
- headers={"Authorization": "Bearer xyz123"},
188
- )
189
- self.assertEqual(len(self.enqueue_calls), 1)
190
- _, headers = self.enqueue_calls[0]
191
- self.assertIsNotNone(headers)
192
- self.assertEqual(headers["Authorization"], "Bearer xyz123")
193
-
194
- def test_miss_no_authorization_no_headers_forwarded(self) -> None:
195
- self.client.get("/blob", params={"url": "https://example.invalid/anon.bin"})
196
- self.assertEqual(self.enqueue_calls[0][1], None)
197
-
198
-
199
- class BlobAutoFetchOffTests(_BlobBase):
200
- def setUp(self) -> None:
201
- super().setUp()
202
- self.client.close()
203
- outer = self
204
-
205
- class _CaptureMgr:
206
- def enqueue(self, url: str, headers: dict[str, str] | None = None) -> None:
207
- outer.enqueue_calls.append((url, headers))
175
+ class BlobMissTests(_BlobBase):
176
+ """Since v0.10.0 auto-fetch on miss is gone. A miss records the
177
+ request-count against the misses table (for the operator's
178
+ /ui/misses page) and returns 404 with a hint pointing at the
179
+ /ui/catalog Download button; no enqueue fires."""
208
180
 
209
- self.app = create_app(
210
- data_dir=self._tmpdir + "-curate",
211
- secret_key=TEST_SECRET,
212
- mgr=_CaptureMgr(),
213
- auto_fetch=False,
214
- )
215
- self.client = TestClient(self.app, follow_redirects=False)
216
-
217
- def test_miss_records_but_does_not_enqueue(self) -> None:
181
+ def test_miss_records_but_never_enqueues(self) -> None:
218
182
  pre = len(self.enqueue_calls)
219
183
  r = self.client.get("/blob", params={"url": "https://example.invalid/curated.bin"})
220
184
  self.assertEqual(r.status_code, 404)
185
+ self.assertIn("Download", r.text)
186
+ self.assertEqual(len(self.enqueue_calls), pre)
187
+
188
+ def test_miss_with_authorization_header_still_never_enqueues(self) -> None:
189
+ pre = len(self.enqueue_calls)
190
+ r = self.client.get(
191
+ "/blob",
192
+ params={"url": "https://example.invalid/token-gated.bin"},
193
+ headers={"Authorization": "Bearer xyz123"},
194
+ )
195
+ self.assertEqual(r.status_code, 404)
221
196
  self.assertEqual(len(self.enqueue_calls), pre)
222
197
 
223
198
 
@@ -180,6 +180,73 @@ class AddCatalogEntryTests(_CatalogApiBase):
180
180
  self.assertEqual(r.status_code, 409)
181
181
 
182
182
 
183
+ class DownloadCatalogEntryTests(_CatalogApiBase):
184
+ """POST /catalog/entries/{name}/download enqueues an explicit
185
+ fetch of the entry's src via the DownloadManager. Introduced
186
+ when auto-fetch on cache miss was retired; the operator (or a
187
+ sibling service) is the one who triggers a download now."""
188
+
189
+ def setUp(self) -> None:
190
+ super().setUp()
191
+ outer = self
192
+ self.enqueued: list[str] = []
193
+
194
+ class _CaptureMgr:
195
+ def enqueue(self, url: str, headers: dict[str, str] | None = None): # type: ignore[no-untyped-def]
196
+ del headers
197
+ outer.enqueued.append(url)
198
+ return type("Job", (), {"id": len(outer.enqueued), "status": "queued"})()
199
+
200
+ def list(self): # noqa: A003 - matching the DownloadManager API
201
+ return []
202
+
203
+ self.app.state.mgr = _CaptureMgr()
204
+
205
+ def test_download_enqueues_and_returns_job(self) -> None:
206
+ self.catalog.entries = [
207
+ {
208
+ "name": "demo",
209
+ "src": "https://example/demo.img.gz",
210
+ "resolved_src": "https://example/demo.img.gz",
211
+ }
212
+ ]
213
+ r = self.client.post("/catalog/entries/demo/download")
214
+ self.assertEqual(r.status_code, 202)
215
+ body = r.json()
216
+ self.assertEqual(body["name"], "demo")
217
+ self.assertEqual(body["status"], "queued")
218
+ self.assertEqual(self.enqueued, ["https://example/demo.img.gz"])
219
+
220
+ def test_download_unknown_entry_404s(self) -> None:
221
+ r = self.client.post("/catalog/entries/does-not-exist/download")
222
+ self.assertEqual(r.status_code, 404)
223
+ self.assertEqual(self.enqueued, [])
224
+
225
+ def test_download_entry_without_src_400s(self) -> None:
226
+ # Impossible via the JSON add path (src is required) but a
227
+ # hand-edited catalog.toml can produce this. Refuse rather
228
+ # than enqueue an empty URL.
229
+ self.catalog.entries = [{"name": "demo"}]
230
+ r = self.client.post("/catalog/entries/demo/download")
231
+ self.assertEqual(r.status_code, 400)
232
+ self.assertEqual(self.enqueued, [])
233
+
234
+ def test_download_prefers_resolved_src(self) -> None:
235
+ """oras:// entries carry a canonical HTTPS ``resolved_src``
236
+ (the blob URL the manifest walk produced). The download
237
+ target must be the resolved URL, not the raw ``oras://``
238
+ ref, so the fetch worker doesn't have to re-resolve."""
239
+ self.catalog.entries = [
240
+ {
241
+ "name": "demo",
242
+ "src": "oras://ghcr.io/owner/repo:tag",
243
+ "resolved_src": "https://ghcr.io/v2/owner/repo/blobs/sha256:abc",
244
+ }
245
+ ]
246
+ self.client.post("/catalog/entries/demo/download")
247
+ self.assertEqual(self.enqueued, ["https://ghcr.io/v2/owner/repo/blobs/sha256:abc"])
248
+
249
+
183
250
  class DeleteCatalogEntryTests(_CatalogApiBase):
184
251
  def test_delete_removes_entry(self) -> None:
185
252
  self.catalog.entries = [{"name": "demo", "src": "https://example/demo.img.gz"}]
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