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.
- {withcache-0.9.1 → withcache-0.10.0}/PKG-INFO +1 -1
- {withcache-0.9.1 → withcache-0.10.0}/shim/build.zig.zon +1 -1
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/__init__.py +1 -1
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_api.py +78 -22
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_app.py +110 -6
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_templates/ui/catalog.html +115 -8
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/client.py +29 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/server.py +3 -8
- {withcache-0.9.1 → withcache-0.10.0}/tests/test_fastapi_blob.py +17 -42
- {withcache-0.9.1 → withcache-0.10.0}/tests/test_fastapi_catalog_api.py +67 -0
- {withcache-0.9.1 → withcache-0.10.0}/.gitignore +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/LICENSE +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/README.md +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/deploy/Containerfile +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/deploy/compose.local-build.yml +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/deploy/compose.yml +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/deploy/envvars.example +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/hatch_build.py +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/pyproject.toml +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/shim/build.zig +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/shim/shim.zig +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_settings_store.py +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_shim.py +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_templates/ui/_layout.html +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_templates/ui/cached.html +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_templates/ui/downloads.html +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_templates/ui/login.html +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_templates/ui/misses.html +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/_templates/ui/settings.html +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/curlwithcache.py +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/oras.py +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/static/bootstrap-icons.min.css +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/static/bootstrap.min.css +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/src/withcache/wgetwithcache.py +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/tests/test_differential.py +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/tests/test_fastapi_admin_forms.py +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/tests/test_fastapi_scaffold.py +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/tests/test_fastapi_settings_persistence.py +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/tests/test_fastapi_ui_pages.py +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/tests/test_fastapi_uvicorn_smoke.py +0 -0
- {withcache-0.9.1 → withcache-0.10.0}/tests/test_oras.py +0 -0
- {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.
|
|
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.
|
|
5
|
+
.version = "0.10.0",
|
|
6
6
|
.fingerprint = 0xd7d96c5ed212ccaa,
|
|
7
7
|
.minimum_zig_version = "0.16.0",
|
|
8
8
|
.paths = .{
|
|
@@ -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
|
|
69
|
-
miss
|
|
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
|
|
83
|
-
#
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
|
222
|
-
(
|
|
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":
|
|
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``
|
|
94
|
-
|
|
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=
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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
|
-
|
|
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="
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|