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