withcache 0.8.10__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.
- {withcache-0.8.10 → withcache-0.9.1}/PKG-INFO +6 -1
- {withcache-0.8.10 → withcache-0.9.1}/pyproject.toml +24 -1
- {withcache-0.8.10 → withcache-0.9.1}/shim/build.zig.zon +1 -1
- {withcache-0.8.10 → withcache-0.9.1}/src/withcache/__init__.py +7 -3
- withcache-0.9.1/src/withcache/_api.py +330 -0
- withcache-0.9.1/src/withcache/_app.py +504 -0
- withcache-0.9.1/src/withcache/_settings_store.py +100 -0
- withcache-0.9.1/src/withcache/_templates/ui/_layout.html +251 -0
- withcache-0.9.1/src/withcache/_templates/ui/cached.html +78 -0
- withcache-0.9.1/src/withcache/_templates/ui/catalog.html +162 -0
- withcache-0.9.1/src/withcache/_templates/ui/downloads.html +100 -0
- withcache-0.9.1/src/withcache/_templates/ui/login.html +30 -0
- withcache-0.9.1/src/withcache/_templates/ui/misses.html +67 -0
- withcache-0.9.1/src/withcache/_templates/ui/settings.html +277 -0
- withcache-0.9.1/src/withcache/client.py +237 -0
- withcache-0.9.1/src/withcache/server.py +1069 -0
- withcache-0.9.1/tests/test_fastapi_admin_forms.py +250 -0
- withcache-0.9.1/tests/test_fastapi_blob.py +225 -0
- withcache-0.9.1/tests/test_fastapi_catalog_api.py +249 -0
- withcache-0.9.1/tests/test_fastapi_scaffold.py +116 -0
- withcache-0.9.1/tests/test_fastapi_settings_persistence.py +250 -0
- withcache-0.9.1/tests/test_fastapi_ui_pages.py +181 -0
- withcache-0.9.1/tests/test_fastapi_uvicorn_smoke.py +126 -0
- {withcache-0.8.10 → withcache-0.9.1}/tests/test_withcache.py +10 -605
- withcache-0.8.10/src/withcache/client.py +0 -89
- withcache-0.8.10/src/withcache/server.py +0 -2286
- {withcache-0.8.10 → withcache-0.9.1}/.gitignore +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/LICENSE +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/README.md +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/deploy/Containerfile +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/deploy/compose.local-build.yml +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/deploy/compose.yml +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/deploy/envvars.example +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/hatch_build.py +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/shim/build.zig +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/shim/shim.zig +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/src/withcache/_shim.py +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/src/withcache/curlwithcache.py +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/src/withcache/oras.py +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/src/withcache/static/bootstrap-icons.min.css +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/src/withcache/static/bootstrap.min.css +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/src/withcache/wgetwithcache.py +0 -0
- {withcache-0.8.10 → withcache-0.9.1}/tests/test_differential.py +0 -0
- {withcache-0.8.10 → 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.
|
|
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>
|
|
@@ -14,6 +14,11 @@ Classifier: License :: OSI Approved :: BSD License
|
|
|
14
14
|
Classifier: Programming Language :: Python :: 3
|
|
15
15
|
Classifier: Topic :: System :: Archiving :: Mirroring
|
|
16
16
|
Requires-Python: >=3.11
|
|
17
|
+
Requires-Dist: fastapi>=0.115
|
|
18
|
+
Requires-Dist: itsdangerous>=2.1
|
|
19
|
+
Requires-Dist: jinja2>=3.1
|
|
20
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
21
|
+
Requires-Dist: uvicorn[standard]>=0.30
|
|
17
22
|
Description-Content-Type: text/markdown
|
|
18
23
|
|
|
19
24
|
# withcache
|
|
@@ -19,7 +19,30 @@ classifiers = [
|
|
|
19
19
|
"Programming Language :: Python :: 3",
|
|
20
20
|
"Topic :: System :: Archiving :: Mirroring",
|
|
21
21
|
]
|
|
22
|
-
dependencies = [
|
|
22
|
+
dependencies = [
|
|
23
|
+
# v0.9.0 shipped the FastAPI port: the daemon UI + control plane
|
|
24
|
+
# moved off stdlib http.server onto the same stack bty-web and
|
|
25
|
+
# nbdmux use. Keeps the trio's testing shape uniform (FastAPI
|
|
26
|
+
# TestClient across all three) and makes the shared chrome +
|
|
27
|
+
# settings-store patterns extractable into a trio-common lib.
|
|
28
|
+
# ``withcache.client`` / ``withcache.oras`` stay stdlib-only so
|
|
29
|
+
# downstream consumers (bty) don't inherit the framework floor.
|
|
30
|
+
"fastapi>=0.115",
|
|
31
|
+
"uvicorn[standard]>=0.30",
|
|
32
|
+
"jinja2>=3.1",
|
|
33
|
+
# Form-encoded POST parsing behind ``Form()`` deps.
|
|
34
|
+
"python-multipart>=0.0.9",
|
|
35
|
+
# Session cookie signing (SessionMiddleware).
|
|
36
|
+
"itsdangerous>=2.1",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
[dependency-groups]
|
|
40
|
+
dev = [
|
|
41
|
+
"pytest>=8",
|
|
42
|
+
"ruff>=0.6",
|
|
43
|
+
# FastAPI TestClient uses httpx as its transport.
|
|
44
|
+
"httpx>=0.27",
|
|
45
|
+
]
|
|
23
46
|
|
|
24
47
|
[project.urls]
|
|
25
48
|
Homepage = "https://github.com/safl/withcache"
|
|
@@ -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.9.1",
|
|
6
6
|
.fingerprint = 0xd7d96c5ed212ccaa,
|
|
7
7
|
.minimum_zig_version = "0.16.0",
|
|
8
8
|
.paths = .{
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""withcache
|
|
1
|
+
"""withcache, operator-curated URL-keyed artifact cache for a small lab.
|
|
2
2
|
|
|
3
3
|
- ``withcache-server`` (withcache.server:main): the cache-host.
|
|
4
4
|
- ``curlwithcache`` / ``wgetwithcache``: transparent curl/wget shims, shipped
|
|
@@ -11,12 +11,16 @@
|
|
|
11
11
|
uses it on a cold miss; library consumers (e.g. ``bty``) import it to
|
|
12
12
|
validate catalog entries and pre-resolve content digests.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
Since v0.9.0 the daemon runs on FastAPI + Jinja + Bootstrap 5 + htmx
|
|
15
|
+
(matching bty-web + nbdmux, the eventual ``trio-common`` extraction
|
|
16
|
+
target); ``withcache.client``, ``withcache.oras``, and the shim
|
|
17
|
+
launchers stay stdlib-only so downstream consumers (bty) don't
|
|
18
|
+
inherit the framework floor.
|
|
15
19
|
"""
|
|
16
20
|
|
|
17
21
|
from . import oras
|
|
18
22
|
from .client import blob_url, cache_base, is_cached, serve_url
|
|
19
23
|
|
|
20
|
-
__version__ = "0.
|
|
24
|
+
__version__ = "0.9.1"
|
|
21
25
|
|
|
22
26
|
__all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Byte-serving routes for the withcache FastAPI app.
|
|
2
|
+
|
|
3
|
+
Ports ``GET /blob?url=<origin>`` and ``GET /b/<b64>/<name>`` (+
|
|
4
|
+
their HEAD counterparts) from the stdlib ``server.py`` handler.
|
|
5
|
+
This is the bty-critical surface: bty at
|
|
6
|
+
``bty.web._withcache.blob_url`` builds these URLs and the live
|
|
7
|
+
env hands them to a ``wget|dd`` chain, so the wire contract must
|
|
8
|
+
stay byte-identical:
|
|
9
|
+
|
|
10
|
+
- URL shape: ``/b/<urlsafe_b64(src_url)>/<name>``. Name segment is
|
|
11
|
+
decorative; the b64 token is the source of truth.
|
|
12
|
+
- Cache hit -> 200 + ``Content-Type`` + ``Content-Length`` +
|
|
13
|
+
``X-Withcache-Sha256`` + streamed body (64 KiB chunks).
|
|
14
|
+
- Cache miss -> 404 with ``cache miss (recorded)`` body,
|
|
15
|
+
side-effect of enqueueing a background fetch when
|
|
16
|
+
``auto_fetch`` is on and the store has capacity.
|
|
17
|
+
- HEAD support: same headers, no body (probes from
|
|
18
|
+
bty.web._withcache.is_cached use HEAD).
|
|
19
|
+
- ``Authorization`` request header forwarded onto the background
|
|
20
|
+
fetch worker so a token-gated origin (typical use case: an OCI
|
|
21
|
+
bearer minted by bty at catalog import time) can be pulled by
|
|
22
|
+
the worker with the same credential.
|
|
23
|
+
|
|
24
|
+
Streaming shape: FastAPI ``StreamingResponse`` with a generator
|
|
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.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import base64
|
|
33
|
+
import urllib.parse
|
|
34
|
+
from collections.abc import Iterator
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
38
|
+
from fastapi.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse
|
|
39
|
+
|
|
40
|
+
from .server import CHUNK, CatalogState, StreamRegistry, _oras_tag_moved, _serialise_catalog
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _decode_blob_origin(path: str, query: str) -> str:
|
|
44
|
+
"""Extract the source URL from either shape.
|
|
45
|
+
|
|
46
|
+
``/blob?url=<origin>``: read ``url`` query param.
|
|
47
|
+
``/b/<b64>/<name>``: base64-decode the first path segment. The
|
|
48
|
+
trailing ``<name>`` is decorative -- a client that derives
|
|
49
|
+
format from URL filename (e.g. bty's flash chain) gets the
|
|
50
|
+
right extension without the b64 token having to include it.
|
|
51
|
+
"""
|
|
52
|
+
if path.startswith("/b/"):
|
|
53
|
+
token = path[len("/b/") :].split("/", 1)[0]
|
|
54
|
+
try:
|
|
55
|
+
return base64.urlsafe_b64decode(token + "=" * (-len(token) % 4)).decode("utf-8")
|
|
56
|
+
except (ValueError, UnicodeDecodeError):
|
|
57
|
+
return ""
|
|
58
|
+
return (urllib.parse.parse_qs(query).get("url") or [""])[0]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _serve_blob(
|
|
62
|
+
request: Request,
|
|
63
|
+
url: str,
|
|
64
|
+
head_only: bool,
|
|
65
|
+
) -> Response:
|
|
66
|
+
"""Shared implementation for GET and HEAD blob requests.
|
|
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.
|
|
71
|
+
"""
|
|
72
|
+
if not url:
|
|
73
|
+
return PlainTextResponse("missing url\n", status_code=400)
|
|
74
|
+
|
|
75
|
+
store = request.app.state.store
|
|
76
|
+
mgr = request.app.state.mgr
|
|
77
|
+
auto_fetch: bool = request.app.state.auto_fetch
|
|
78
|
+
streams: StreamRegistry = request.app.state.streams
|
|
79
|
+
|
|
80
|
+
row = store.get_blob(url)
|
|
81
|
+
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.
|
|
84
|
+
store.delete_blob(row["key"])
|
|
85
|
+
row = None
|
|
86
|
+
|
|
87
|
+
if row is None:
|
|
88
|
+
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)
|
|
101
|
+
|
|
102
|
+
path = store.blob_path(row["key"])
|
|
103
|
+
headers = {
|
|
104
|
+
"Content-Length": str(row["size"]),
|
|
105
|
+
"X-Withcache-Sha256": row["sha256"],
|
|
106
|
+
}
|
|
107
|
+
media_type = row["content_type"] or "application/octet-stream"
|
|
108
|
+
|
|
109
|
+
if head_only:
|
|
110
|
+
# No body; the shim's HEAD probe doesn't count as a served
|
|
111
|
+
# download so record_hit + stream registration are skipped.
|
|
112
|
+
return Response(status_code=200, media_type=media_type, headers=headers)
|
|
113
|
+
|
|
114
|
+
store.record_hit(row["key"])
|
|
115
|
+
client = f"{request.client.host}:{request.client.port}" if request.client else "unknown"
|
|
116
|
+
stream = streams.start(url=url, client=client, total=row["size"])
|
|
117
|
+
|
|
118
|
+
def _chunks() -> Iterator[bytes]:
|
|
119
|
+
"""Read the blob in 64 KiB chunks. StreamingResponse iterates
|
|
120
|
+
this + writes each chunk to the client. Progress ticks fire
|
|
121
|
+
every :data:`StreamRegistry.PROGRESS_STRIDE` chunks so the
|
|
122
|
+
dashboard's 1 Hz refresh sees updates without lock-contention
|
|
123
|
+
on a busy box."""
|
|
124
|
+
sent = 0
|
|
125
|
+
ticks = 0
|
|
126
|
+
try:
|
|
127
|
+
with open(path, "rb") as f:
|
|
128
|
+
while True:
|
|
129
|
+
chunk = f.read(CHUNK)
|
|
130
|
+
if not chunk:
|
|
131
|
+
break
|
|
132
|
+
yield chunk
|
|
133
|
+
sent += len(chunk)
|
|
134
|
+
ticks += 1
|
|
135
|
+
if ticks % StreamRegistry.PROGRESS_STRIDE == 0:
|
|
136
|
+
streams.bump(stream.id, sent)
|
|
137
|
+
streams.bump(stream.id, sent)
|
|
138
|
+
finally:
|
|
139
|
+
streams.finish(stream.id)
|
|
140
|
+
|
|
141
|
+
return StreamingResponse(_chunks(), media_type=media_type, headers=headers)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def register_api_routes(app: FastAPI) -> None:
|
|
145
|
+
"""Attach the byte-serving routes to ``app``.
|
|
146
|
+
|
|
147
|
+
Runtime objects (``store``, ``mgr``, ``streams``, ``auto_fetch``)
|
|
148
|
+
are read from ``app.state`` at request time so tests + the
|
|
149
|
+
lifespan hook can inject fresh instances without rebuilding
|
|
150
|
+
the app.
|
|
151
|
+
"""
|
|
152
|
+
|
|
153
|
+
@app.get("/blob", response_class=Response)
|
|
154
|
+
def blob_get_legacy(request: Request) -> Response:
|
|
155
|
+
"""Legacy ``?url=<origin>`` shape. Kept for downstream
|
|
156
|
+
consumers that pinned this before the ``/b/<b64>/<name>``
|
|
157
|
+
canonical URL landed."""
|
|
158
|
+
url = _decode_blob_origin("/blob", request.url.query)
|
|
159
|
+
return _serve_blob(request, url, head_only=False)
|
|
160
|
+
|
|
161
|
+
@app.head("/blob")
|
|
162
|
+
def blob_head_legacy(request: Request) -> Response:
|
|
163
|
+
url = _decode_blob_origin("/blob", request.url.query)
|
|
164
|
+
return _serve_blob(request, url, head_only=True)
|
|
165
|
+
|
|
166
|
+
@app.get("/b/{token}/{name:path}", response_class=Response)
|
|
167
|
+
def blob_get_b64(token: str, name: str, request: Request) -> Response:
|
|
168
|
+
"""Canonical ``/b/<b64>/<name>`` shape. bty's flash chain
|
|
169
|
+
builds these URLs; the ``<name>`` segment is decorative so
|
|
170
|
+
the client derives extension for downstream format probes."""
|
|
171
|
+
del name # decorative segment; source of truth is the b64 token
|
|
172
|
+
url = _decode_blob_origin(f"/b/{token}/x", "")
|
|
173
|
+
return _serve_blob(request, url, head_only=False)
|
|
174
|
+
|
|
175
|
+
@app.head("/b/{token}/{name:path}")
|
|
176
|
+
def blob_head_b64(token: str, name: str, request: Request) -> Response:
|
|
177
|
+
del name
|
|
178
|
+
url = _decode_blob_origin(f"/b/{token}/x", "")
|
|
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))
|