withcache 0.6.5__tar.gz → 0.7.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.6.5 → withcache-0.7.0}/PKG-INFO +2 -2
- {withcache-0.6.5 → withcache-0.7.0}/pyproject.toml +2 -2
- {withcache-0.6.5 → withcache-0.7.0}/shim/build.zig.zon +1 -1
- {withcache-0.6.5 → withcache-0.7.0}/src/withcache/__init__.py +1 -1
- {withcache-0.6.5 → withcache-0.7.0}/src/withcache/server.py +185 -3
- {withcache-0.6.5 → withcache-0.7.0}/tests/test_withcache.py +8 -0
- {withcache-0.6.5 → withcache-0.7.0}/.gitignore +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/LICENSE +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/README.md +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/deploy/Containerfile +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/deploy/compose.yml +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/hatch_build.py +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/shim/build.zig +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/shim/shim.zig +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/src/withcache/_shim.py +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/src/withcache/client.py +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/src/withcache/curlwithcache.py +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/src/withcache/oras.py +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/src/withcache/static/bootstrap-icons.min.css +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/src/withcache/static/bootstrap.min.css +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/src/withcache/wgetwithcache.py +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/tests/test_differential.py +0 -0
- {withcache-0.6.5 → withcache-0.7.0}/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.7.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>
|
|
@@ -13,7 +13,7 @@ Classifier: Intended Audience :: System Administrators
|
|
|
13
13
|
Classifier: License :: OSI Approved :: BSD License
|
|
14
14
|
Classifier: Programming Language :: Python :: 3
|
|
15
15
|
Classifier: Topic :: System :: Archiving :: Mirroring
|
|
16
|
-
Requires-Python: >=3.
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
|
|
19
19
|
# withcache
|
|
@@ -7,7 +7,7 @@ name = "withcache"
|
|
|
7
7
|
dynamic = ["version"] # single source of truth: src/withcache/__init__.py:__version__
|
|
8
8
|
description = "Operator-curated, URL-keyed artifact cache for a small lab (CUDA/ROCm/DOCA/firmware)"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
11
|
license = { text = "BSD-3-Clause" }
|
|
12
12
|
authors = [{ name = "Simon A. F. Lund", email = "safl@safl.dk" }]
|
|
13
13
|
keywords = ["cache", "artifacts", "cuda", "rocm", "doca", "firmware", "lab"]
|
|
@@ -45,7 +45,7 @@ include = ["src/withcache", "shim", "README.md", "LICENSE", "deploy", "tests"]
|
|
|
45
45
|
|
|
46
46
|
[tool.ruff]
|
|
47
47
|
line-length = 100
|
|
48
|
-
target-version = "
|
|
48
|
+
target-version = "py311"
|
|
49
49
|
|
|
50
50
|
[tool.ruff.lint]
|
|
51
51
|
select = ["E", "F", "W", "I", "UP", "B"]
|
|
@@ -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.7.0",
|
|
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.
|
|
20
|
+
__version__ = "0.7.0"
|
|
21
21
|
|
|
22
22
|
__all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
|
|
@@ -37,11 +37,13 @@ import socketserver
|
|
|
37
37
|
import sqlite3
|
|
38
38
|
import threading
|
|
39
39
|
import time
|
|
40
|
+
import tomllib
|
|
41
|
+
import urllib.error
|
|
40
42
|
import urllib.parse
|
|
41
43
|
import urllib.request
|
|
42
44
|
from dataclasses import dataclass, field
|
|
43
|
-
from datetime import
|
|
44
|
-
from typing import ClassVar
|
|
45
|
+
from datetime import UTC, datetime
|
|
46
|
+
from typing import Any, ClassVar
|
|
45
47
|
|
|
46
48
|
from . import __version__, oras
|
|
47
49
|
|
|
@@ -70,7 +72,7 @@ _DB_WRITE_LOCK = threading.Lock()
|
|
|
70
72
|
|
|
71
73
|
|
|
72
74
|
def now_iso() -> str:
|
|
73
|
-
return datetime.now(
|
|
75
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
74
76
|
|
|
75
77
|
|
|
76
78
|
def _age_human(started_at: float, *, now: float | None = None) -> str:
|
|
@@ -147,6 +149,84 @@ def resolve_secret(data_dir: str) -> bytes:
|
|
|
147
149
|
return secret
|
|
148
150
|
|
|
149
151
|
|
|
152
|
+
DEFAULT_CATALOG_URL = "https://github.com/safl/nosi/releases/latest/download/catalog.toml"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class CatalogState:
|
|
157
|
+
"""Live state of the fetched image catalog.
|
|
158
|
+
|
|
159
|
+
withcache does not run a background poller. The catalog is
|
|
160
|
+
fetched at process start (best-effort, persisted result from
|
|
161
|
+
the last successful fetch survives a restart), refetched
|
|
162
|
+
lazily when the dashboard renders and the in-memory copy is
|
|
163
|
+
older than :data:`CATALOG_CACHE_TTL`, and force-refetched by
|
|
164
|
+
the operator via the Refresh button (POST /admin/catalog_refresh).
|
|
165
|
+
|
|
166
|
+
The raw TOML bytes are persisted to ``<data_dir>/catalog.toml``
|
|
167
|
+
so a restart doesn't wipe the last known good catalog and the
|
|
168
|
+
same file can be re-served verbatim to consumers on a future
|
|
169
|
+
``GET /catalog.toml`` route.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
url: str
|
|
173
|
+
persist_path: str
|
|
174
|
+
entries: list[dict[str, Any]] = field(default_factory=list)
|
|
175
|
+
fetched_at: str = ""
|
|
176
|
+
last_error: str = ""
|
|
177
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
178
|
+
|
|
179
|
+
def load_persisted(self) -> None:
|
|
180
|
+
"""Best-effort: seed ``entries`` + ``fetched_at`` from the on-disk
|
|
181
|
+
``catalog.toml`` if it exists. Never raises; a corrupt or missing
|
|
182
|
+
file leaves the state empty so the operator sees the "not
|
|
183
|
+
fetched yet" hint in the dashboard."""
|
|
184
|
+
if not os.path.isfile(self.persist_path):
|
|
185
|
+
return
|
|
186
|
+
try:
|
|
187
|
+
with open(self.persist_path, "rb") as f:
|
|
188
|
+
raw = f.read()
|
|
189
|
+
parsed = tomllib.loads(raw.decode("utf-8"))
|
|
190
|
+
mtime = os.path.getmtime(self.persist_path)
|
|
191
|
+
self.entries = list(parsed.get("images") or [])
|
|
192
|
+
self.fetched_at = datetime.fromtimestamp(mtime, UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
193
|
+
except (OSError, ValueError, UnicodeDecodeError, tomllib.TOMLDecodeError) as e:
|
|
194
|
+
self.last_error = f"failed to load {self.persist_path}: {e}"
|
|
195
|
+
|
|
196
|
+
def fetch_now(self, *, timeout: float = 15.0) -> None:
|
|
197
|
+
"""Fetch the catalog URL, parse, persist raw bytes, populate
|
|
198
|
+
``entries``. On failure the previously-cached entries remain;
|
|
199
|
+
the error is recorded so the tab can surface it. Single-writer
|
|
200
|
+
via ``self._lock`` so a burst of clicks doesn't double-fetch."""
|
|
201
|
+
with self._lock:
|
|
202
|
+
try:
|
|
203
|
+
req = urllib.request.Request(self.url, headers={"User-Agent": USER_AGENT})
|
|
204
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
205
|
+
raw = resp.read()
|
|
206
|
+
# Validate before persisting -- a 500 from upstream that
|
|
207
|
+
# returns HTML must not clobber a previously-good
|
|
208
|
+
# catalog.toml on disk.
|
|
209
|
+
parsed = tomllib.loads(raw.decode("utf-8"))
|
|
210
|
+
entries = list(parsed.get("images") or [])
|
|
211
|
+
# Atomic write: tempfile + rename so a crash mid-write
|
|
212
|
+
# never leaves a half-written catalog.toml.
|
|
213
|
+
tmp = self.persist_path + ".tmp"
|
|
214
|
+
with open(tmp, "wb") as f:
|
|
215
|
+
f.write(raw)
|
|
216
|
+
os.replace(tmp, self.persist_path)
|
|
217
|
+
self.entries = entries
|
|
218
|
+
self.fetched_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
219
|
+
self.last_error = ""
|
|
220
|
+
except (
|
|
221
|
+
urllib.error.URLError,
|
|
222
|
+
OSError,
|
|
223
|
+
ValueError,
|
|
224
|
+
UnicodeDecodeError,
|
|
225
|
+
tomllib.TOMLDecodeError,
|
|
226
|
+
) as e:
|
|
227
|
+
self.last_error = str(e)
|
|
228
|
+
|
|
229
|
+
|
|
150
230
|
class Auth:
|
|
151
231
|
COOKIE = "withcache-token"
|
|
152
232
|
MAX_AGE = 7 * 24 * 3600 # cookie lifetime, seconds
|
|
@@ -745,6 +825,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
745
825
|
def streams(self) -> StreamRegistry:
|
|
746
826
|
return self.server.streams # type: ignore[attr-defined]
|
|
747
827
|
|
|
828
|
+
@property
|
|
829
|
+
def catalog(self) -> CatalogState:
|
|
830
|
+
return self.server.catalog # type: ignore[attr-defined]
|
|
831
|
+
|
|
748
832
|
def log_message(self, format, *args): # quieter, single-line
|
|
749
833
|
print(f"{self.address_string()} - {format % args}", flush=True)
|
|
750
834
|
|
|
@@ -793,6 +877,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
793
877
|
"/admin/delete",
|
|
794
878
|
"/admin/cancel",
|
|
795
879
|
"/admin/clear",
|
|
880
|
+
"/admin/catalog_refresh",
|
|
796
881
|
)
|
|
797
882
|
|
|
798
883
|
def do_POST(self):
|
|
@@ -820,6 +905,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
820
905
|
self.mgr.cancel(int(jid))
|
|
821
906
|
elif parsed.path == "/admin/clear":
|
|
822
907
|
self.mgr.clear_finished()
|
|
908
|
+
elif parsed.path == "/admin/catalog_refresh":
|
|
909
|
+
self.catalog.fetch_now()
|
|
823
910
|
self.respond_admin()
|
|
824
911
|
else:
|
|
825
912
|
self.send_text(404, "not found\n")
|
|
@@ -1354,6 +1441,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1354
1441
|
nstreams = len(streams)
|
|
1355
1442
|
njobs = len(jobs)
|
|
1356
1443
|
|
|
1444
|
+
catalog_rows = self._catalog_rows()
|
|
1445
|
+
|
|
1357
1446
|
return f"""
|
|
1358
1447
|
<p class="text-muted small mb-2">{nblobs} cached ({used}){full}
|
|
1359
1448
|
· {nmisses} pending miss(es)</p>
|
|
@@ -1367,6 +1456,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1367
1456
|
href="#tab-downloads">Downloads ({njobs})</a></li>
|
|
1368
1457
|
<li class="nav-item"><a class="nav-link {_active("tab-misses").lstrip()}"
|
|
1369
1458
|
href="#tab-misses">Misses ({nmisses})</a></li>
|
|
1459
|
+
<li class="nav-item"><a class="nav-link {_active("tab-catalog").lstrip()}"
|
|
1460
|
+
href="#tab-catalog">Catalog ({len(self.catalog.entries)})</a></li>
|
|
1370
1461
|
</ul>
|
|
1371
1462
|
</nav>
|
|
1372
1463
|
|
|
@@ -1412,8 +1503,80 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1412
1503
|
</tr></thead>
|
|
1413
1504
|
<tbody>{miss_rows}</tbody>
|
|
1414
1505
|
</table></div>
|
|
1506
|
+
</section>
|
|
1507
|
+
|
|
1508
|
+
<section id="tab-catalog" class="tab{_active("tab-catalog")}">
|
|
1509
|
+
<div class="d-flex align-items-center justify-content-between mb-2 flex-wrap gap-2">
|
|
1510
|
+
<div>
|
|
1511
|
+
<div><small class="text-muted">source</small>
|
|
1512
|
+
<code>{html.escape(self.catalog.url)}</code></div>
|
|
1513
|
+
<div><small class="text-muted">fetched</small>
|
|
1514
|
+
<code>{html.escape(self.catalog.fetched_at) or "never"}</code>
|
|
1515
|
+
{
|
|
1516
|
+
(
|
|
1517
|
+
f'<span class="badge bg-danger-subtle text-danger ms-1">'
|
|
1518
|
+
f"{html.escape(self.catalog.last_error)}</span>"
|
|
1519
|
+
)
|
|
1520
|
+
if self.catalog.last_error
|
|
1521
|
+
else ""
|
|
1522
|
+
}</div>
|
|
1523
|
+
</div>
|
|
1524
|
+
<form hx-post="/admin/catalog_refresh" hx-target="#dash" hx-swap="innerHTML"
|
|
1525
|
+
hx-indicator="#spin" class="m-0">
|
|
1526
|
+
<button class="btn btn-sm btn-primary" type="submit">Refresh</button>
|
|
1527
|
+
</form>
|
|
1528
|
+
</div>
|
|
1529
|
+
<div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0">
|
|
1530
|
+
<thead class="table-light"><tr>
|
|
1531
|
+
<th>Name</th><th>Format</th><th>Arch</th><th>Size</th><th>Source</th>
|
|
1532
|
+
</tr></thead>
|
|
1533
|
+
<tbody>{catalog_rows}</tbody>
|
|
1534
|
+
</table></div>
|
|
1415
1535
|
</section>"""
|
|
1416
1536
|
|
|
1537
|
+
def _catalog_rows(self) -> str:
|
|
1538
|
+
"""One <tr> per catalog image. Cells: name, format, arch,
|
|
1539
|
+
human-readable size, and a Source cell whose URL is the
|
|
1540
|
+
withcache /b/ token (so a click through pulls via cache).
|
|
1541
|
+
Empty catalog rows to a "not fetched yet" placeholder + a
|
|
1542
|
+
hint about the Refresh button."""
|
|
1543
|
+
entries = self.catalog.entries
|
|
1544
|
+
if not entries:
|
|
1545
|
+
hint = self.catalog.last_error or "click Refresh above to fetch the catalog manifest"
|
|
1546
|
+
return (
|
|
1547
|
+
'<tr><td colspan="5" class="text-center text-muted">'
|
|
1548
|
+
f"<em>{html.escape(hint)}</em></td></tr>"
|
|
1549
|
+
)
|
|
1550
|
+
rows: list[str] = []
|
|
1551
|
+
for entry in entries:
|
|
1552
|
+
name = str(entry.get("name") or "")
|
|
1553
|
+
src = str(entry.get("src") or "")
|
|
1554
|
+
fmt = str(entry.get("format") or "")
|
|
1555
|
+
arch = str(entry.get("arch") or "")
|
|
1556
|
+
size_bytes = entry.get("size_bytes")
|
|
1557
|
+
size = human_size(int(size_bytes)) if isinstance(size_bytes, int) else ""
|
|
1558
|
+
# Route the Source column via /b/<token>/<name> so a
|
|
1559
|
+
# click serves through withcache -- first hit fills the
|
|
1560
|
+
# cache, subsequent hits serve from disk. Mirrors the
|
|
1561
|
+
# blob-row rendering above.
|
|
1562
|
+
if src.startswith(("http://", "https://", "oras://")):
|
|
1563
|
+
token = base64.urlsafe_b64encode(src.encode("utf-8")).decode("ascii").rstrip("=")
|
|
1564
|
+
filename = urllib.parse.urlsplit(src).path.rsplit("/", 1)[-1] or "download"
|
|
1565
|
+
link = f"/b/{token}/{urllib.parse.quote(filename)}"
|
|
1566
|
+
src_cell = f'<a href="{link}" title="{html.escape(src)}">{html.escape(src)}</a>'
|
|
1567
|
+
else:
|
|
1568
|
+
src_cell = html.escape(src)
|
|
1569
|
+
rows.append(
|
|
1570
|
+
f"<tr>"
|
|
1571
|
+
f"<td>{html.escape(name)}</td>"
|
|
1572
|
+
f'<td class="mono"><small>{html.escape(fmt)}</small></td>'
|
|
1573
|
+
f'<td class="mono"><small>{html.escape(arch)}</small></td>'
|
|
1574
|
+
f"<td>{html.escape(size)}</td>"
|
|
1575
|
+
f'<td class="url">{src_cell}</td>'
|
|
1576
|
+
"</tr>"
|
|
1577
|
+
)
|
|
1578
|
+
return "".join(rows)
|
|
1579
|
+
|
|
1417
1580
|
def _stream_progress_cell(self, s: Stream) -> str:
|
|
1418
1581
|
"""One progress cell for an active stream: a <progress> bar when the
|
|
1419
1582
|
total is known (always for a cached blob, since the size came off
|
|
@@ -1506,6 +1669,25 @@ def main():
|
|
|
1506
1669
|
httpd.mgr = mgr # type: ignore[attr-defined]
|
|
1507
1670
|
httpd.auto_fetch = not args.curate # type: ignore[attr-defined]
|
|
1508
1671
|
httpd.streams = StreamRegistry() # type: ignore[attr-defined]
|
|
1672
|
+
# Catalog state: env override, else the shipping default (nosi's
|
|
1673
|
+
# rolling catalog manifest). Seeded from the last persisted
|
|
1674
|
+
# catalog.toml on disk so a restart doesn't wipe the cache.
|
|
1675
|
+
catalog_url = (os.environ.get("WITHCACHE_CATALOG_URL") or DEFAULT_CATALOG_URL).strip()
|
|
1676
|
+
catalog = CatalogState(
|
|
1677
|
+
url=catalog_url,
|
|
1678
|
+
persist_path=os.path.join(store.data_dir, "catalog.toml"),
|
|
1679
|
+
)
|
|
1680
|
+
catalog.load_persisted()
|
|
1681
|
+
httpd.catalog = catalog # type: ignore[attr-defined]
|
|
1682
|
+
# If no catalog is persisted yet, kick a single startup fetch
|
|
1683
|
+
# in a daemon thread so the operator has something to look at
|
|
1684
|
+
# without needing to click Refresh on their first visit.
|
|
1685
|
+
# Failures record ``last_error`` in memory; the dashboard row
|
|
1686
|
+
# shows it. Never blocks the serve loop.
|
|
1687
|
+
if not catalog.entries:
|
|
1688
|
+
threading.Thread(
|
|
1689
|
+
target=catalog.fetch_now, name="withcache-catalog-init", daemon=True
|
|
1690
|
+
).start()
|
|
1509
1691
|
print(
|
|
1510
1692
|
f"withcache cache-host on http://{args.host}:{args.port} "
|
|
1511
1693
|
f"(data={store.data_dir}, keep_query={args.keep_query}, workers={args.workers}, "
|
|
@@ -643,6 +643,14 @@ def _start_withcache(auto_fetch=False):
|
|
|
643
643
|
httpd.mgr = server.DownloadManager(store, workers=1)
|
|
644
644
|
httpd.auto_fetch = auto_fetch
|
|
645
645
|
httpd.streams = server.StreamRegistry()
|
|
646
|
+
# Empty catalog with a bogus URL so the tab renders "click Refresh
|
|
647
|
+
# above to fetch the catalog manifest" instead of firing a real
|
|
648
|
+
# HTTP call. Tests that specifically exercise the catalog code
|
|
649
|
+
# can override this attribute after the helper returns.
|
|
650
|
+
httpd.catalog = server.CatalogState(
|
|
651
|
+
url="http://localhost/catalog.toml",
|
|
652
|
+
persist_path=os.path.join(store.data_dir, "catalog.toml"),
|
|
653
|
+
)
|
|
646
654
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
647
655
|
return httpd, store
|
|
648
656
|
|
|
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
|