withcache 0.6.0__tar.gz → 0.6.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.6.0 → withcache-0.6.2}/PKG-INFO +1 -1
- {withcache-0.6.0 → withcache-0.6.2}/shim/build.zig.zon +1 -1
- {withcache-0.6.0 → withcache-0.6.2}/src/withcache/__init__.py +1 -1
- {withcache-0.6.0 → withcache-0.6.2}/src/withcache/server.py +95 -13
- {withcache-0.6.0 → withcache-0.6.2}/tests/test_withcache.py +102 -0
- {withcache-0.6.0 → withcache-0.6.2}/.gitignore +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/LICENSE +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/README.md +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/deploy/Containerfile +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/deploy/compose.yml +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/hatch_build.py +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/pyproject.toml +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/shim/build.zig +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/shim/shim.zig +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/src/withcache/_shim.py +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/src/withcache/client.py +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/src/withcache/curlwithcache.py +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/src/withcache/oras.py +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/src/withcache/static/pico.min.css +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/src/withcache/wgetwithcache.py +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/tests/test_differential.py +0 -0
- {withcache-0.6.0 → withcache-0.6.2}/tests/test_oras.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: withcache
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.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.6.
|
|
5
|
+
.version = "0.6.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.6.
|
|
20
|
+
__version__ = "0.6.2"
|
|
21
21
|
|
|
22
22
|
__all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
|
|
@@ -669,6 +669,49 @@ def _set_progress(job: Job, done: int, total: int | None):
|
|
|
669
669
|
job.bytes_total = total
|
|
670
670
|
|
|
671
671
|
|
|
672
|
+
def _oras_tag_moved(url: str, cached_sha: str | None, *, resolve=oras.resolve_ref) -> bool:
|
|
673
|
+
"""True iff ``url`` is an ``oras://`` *tag* whose layer the registry now
|
|
674
|
+
resolves to something other than ``cached_sha``.
|
|
675
|
+
|
|
676
|
+
The store keys on the ref string, not the resolved digest, so a mutable
|
|
677
|
+
tag that is re-pushed (e.g. a rolling weekly image tag) would otherwise
|
|
678
|
+
serve the first-cached bytes forever. Re-resolve the tag and compare its
|
|
679
|
+
current layer digest against the cached bytes' ``sha256`` -- which IS that
|
|
680
|
+
layer's content digest, so the comparison is exact.
|
|
681
|
+
|
|
682
|
+
Returns False (keep the cached copy) for anything we cannot prove has
|
|
683
|
+
moved: a non-oras URL, a digest-pinned ref (content-addressed, immutable),
|
|
684
|
+
a missing ``cached_sha``, or a registry/resolve error -- availability
|
|
685
|
+
beats freshness when the origin is unreachable, and a transient failure
|
|
686
|
+
must never nuke a good entry. Returns True only on a demonstrated move, so
|
|
687
|
+
the caller invalidates and re-fetches. ``resolve`` is injectable for tests.
|
|
688
|
+
"""
|
|
689
|
+
if not oras.is_oras_url(url):
|
|
690
|
+
return False
|
|
691
|
+
try:
|
|
692
|
+
ref = oras.parse_ref(url)
|
|
693
|
+
except Exception:
|
|
694
|
+
return False
|
|
695
|
+
if ref.digest is not None:
|
|
696
|
+
return False # digest-pinned: the ref already names the content
|
|
697
|
+
cached = (cached_sha or "").lower()
|
|
698
|
+
if not cached:
|
|
699
|
+
return False
|
|
700
|
+
try:
|
|
701
|
+
current = resolve(ref).digest.split(":", 1)[-1].lower()
|
|
702
|
+
except Exception as exc:
|
|
703
|
+
print(f"withcache: oras revalidate {url} failed: {exc}; serving cached copy", flush=True)
|
|
704
|
+
return False
|
|
705
|
+
if current == cached:
|
|
706
|
+
return False
|
|
707
|
+
print(
|
|
708
|
+
f"withcache: oras tag moved {url}: "
|
|
709
|
+
f"cached sha256:{cached[:12]} -> registry sha256:{current[:12]}; invalidating",
|
|
710
|
+
flush=True,
|
|
711
|
+
)
|
|
712
|
+
return True
|
|
713
|
+
|
|
714
|
+
|
|
672
715
|
# --------------------------------------------------------------------------
|
|
673
716
|
# HTTP handler
|
|
674
717
|
# --------------------------------------------------------------------------
|
|
@@ -714,7 +757,15 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
714
757
|
if not self.is_authed():
|
|
715
758
|
self.send_text(401, "login required\n")
|
|
716
759
|
else:
|
|
717
|
-
|
|
760
|
+
# The browser sends the current URL hash (the active
|
|
761
|
+
# tab id) via the ``X-Active-Tab`` request header on
|
|
762
|
+
# every 1 Hz refresh. Server bakes the matching
|
|
763
|
+
# ``.active-tab`` class directly into the rendered
|
|
764
|
+
# HTML so the htmx innerHTML swap doesn't visibly
|
|
765
|
+
# blink while the post-swap JS would otherwise
|
|
766
|
+
# re-apply the class. See render_dash().
|
|
767
|
+
active = (self.headers.get("X-Active-Tab") or "").strip()
|
|
768
|
+
self.send_html(200, self.render_dash(active_tab=active))
|
|
718
769
|
elif parsed.path == "/":
|
|
719
770
|
if not self.is_authed():
|
|
720
771
|
self.redirect("/ui/login")
|
|
@@ -856,6 +907,13 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
856
907
|
self.send_text(400, "missing url\n")
|
|
857
908
|
return
|
|
858
909
|
row = self.store.get_blob(url)
|
|
910
|
+
if row is not None and _oras_tag_moved(url, row["sha256"]):
|
|
911
|
+
# The tag was re-pushed to a different layer since we cached it.
|
|
912
|
+
# Drop the stale bytes and fall through to the miss path so the
|
|
913
|
+
# current content is (auto-)fetched. Deleting first also frees the
|
|
914
|
+
# space the refill needs when the store is near --max-bytes.
|
|
915
|
+
self.store.delete_blob(row["key"])
|
|
916
|
+
row = None
|
|
859
917
|
if row is None:
|
|
860
918
|
self.store.record_miss(url)
|
|
861
919
|
if self.auto_fetch and self.store.has_capacity():
|
|
@@ -1043,11 +1101,17 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1043
1101
|
isn't wiped by the 1 Hz refresh. ``isCollapsed`` is true when
|
|
1044
1102
|
there's no selection or the caret is a zero-width point; once
|
|
1045
1103
|
the operator releases / clears the selection polling resumes
|
|
1046
|
-
on the next 1 s tick.
|
|
1104
|
+
on the next 1 s tick.
|
|
1105
|
+
``hx-headers`` sends the current URL hash (the active tab id)
|
|
1106
|
+
as ``X-Active-Tab`` on every refresh so the server can bake
|
|
1107
|
+
``.active-tab`` into the rendered HTML -- eliminating the
|
|
1108
|
+
visible flicker the post-swap JS-applied class would otherwise
|
|
1109
|
+
cause when the new innerHTML lands without the class. -->
|
|
1047
1110
|
<div id="dash" hx-get="/admin/dash"
|
|
1048
1111
|
hx-trigger="load, every 1s [document.getSelection().isCollapsed]"
|
|
1049
|
-
hx-swap="innerHTML"
|
|
1050
|
-
|
|
1112
|
+
hx-swap="innerHTML"
|
|
1113
|
+
hx-headers='js:{{"X-Active-Tab": (location.hash || "").replace(/^#/, "")}}'>
|
|
1114
|
+
{self.render_dash(active_tab=(self.headers.get("X-Active-Tab") or "").strip())}
|
|
1051
1115
|
</div>
|
|
1052
1116
|
|
|
1053
1117
|
<!-- Tab activation. Applies an ``active-tab`` class to the
|
|
@@ -1087,7 +1151,21 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1087
1151
|
</script>
|
|
1088
1152
|
</main></body></html>"""
|
|
1089
1153
|
|
|
1090
|
-
def render_dash(self) -> str:
|
|
1154
|
+
def render_dash(self, active_tab: str = "") -> str:
|
|
1155
|
+
# Tab activation is baked into the rendered HTML so the htmx
|
|
1156
|
+
# innerHTML swap doesn't strip `.active-tab` between the
|
|
1157
|
+
# swap and the post-settle JS re-apply. The client sends
|
|
1158
|
+
# the current hash via the ``X-Active-Tab`` header on each
|
|
1159
|
+
# 1 Hz refresh (and on every operator click via the same
|
|
1160
|
+
# script that watches hashchange). Unknown / empty value
|
|
1161
|
+
# defaults to the first tab.
|
|
1162
|
+
_TAB_IDS = ("tab-cached", "tab-streams", "tab-downloads", "tab-misses")
|
|
1163
|
+
if active_tab not in _TAB_IDS:
|
|
1164
|
+
active_tab = _TAB_IDS[0]
|
|
1165
|
+
|
|
1166
|
+
def _active(tab_id: str) -> str:
|
|
1167
|
+
return " active-tab" if tab_id == active_tab else ""
|
|
1168
|
+
|
|
1091
1169
|
nblobs, nmisses = self.store.counts()
|
|
1092
1170
|
jobs = self.mgr.list()
|
|
1093
1171
|
misses = self.store.list_misses()
|
|
@@ -1221,13 +1299,17 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1221
1299
|
<p><small>{nblobs} cached ({used}){full} · {nmisses} pending miss(es)</small></p>
|
|
1222
1300
|
{tab_style}
|
|
1223
1301
|
<nav class="tabs"><ul>
|
|
1224
|
-
<li><a href="#tab-cached"
|
|
1225
|
-
|
|
1226
|
-
<li><a href="#tab-
|
|
1227
|
-
|
|
1302
|
+
<li><a href="#tab-cached" class="{_active("tab-cached").lstrip()}"
|
|
1303
|
+
>Cached ({nblobs})</a></li>
|
|
1304
|
+
<li><a href="#tab-streams" class="{_active("tab-streams").lstrip()}"
|
|
1305
|
+
>Streams ({nstreams})</a></li>
|
|
1306
|
+
<li><a href="#tab-downloads" class="{_active("tab-downloads").lstrip()}"
|
|
1307
|
+
>Downloads ({njobs})</a></li>
|
|
1308
|
+
<li><a href="#tab-misses" class="{_active("tab-misses").lstrip()}"
|
|
1309
|
+
>Misses ({nmisses})</a></li>
|
|
1228
1310
|
</ul></nav>
|
|
1229
1311
|
|
|
1230
|
-
<section id="tab-cached" class="tab">
|
|
1312
|
+
<section id="tab-cached" class="tab{_active("tab-cached")}">
|
|
1231
1313
|
<figure><table class="striped">
|
|
1232
1314
|
<thead><tr>
|
|
1233
1315
|
<th>URL</th><th>Size</th><th class="num">Hits</th><th class="num">Misses</th>
|
|
@@ -1237,7 +1319,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1237
1319
|
</table></figure>
|
|
1238
1320
|
</section>
|
|
1239
1321
|
|
|
1240
|
-
<section id="tab-streams" class="tab">
|
|
1322
|
+
<section id="tab-streams" class="tab{_active("tab-streams")}">
|
|
1241
1323
|
<figure><table class="striped">
|
|
1242
1324
|
<thead><tr>
|
|
1243
1325
|
<th>URL</th><th>Client</th><th>Progress</th><th>Age</th>
|
|
@@ -1246,7 +1328,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1246
1328
|
</table></figure>
|
|
1247
1329
|
</section>
|
|
1248
1330
|
|
|
1249
|
-
<section id="tab-downloads" class="tab">
|
|
1331
|
+
<section id="tab-downloads" class="tab{_active("tab-downloads")}">
|
|
1250
1332
|
<div class="row">
|
|
1251
1333
|
<small>Auto-fetch workers feeding the cache.</small>
|
|
1252
1334
|
<form hx-post="/admin/clear" hx-target="#dash" hx-swap="innerHTML" style="margin:0">
|
|
@@ -1260,7 +1342,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1260
1342
|
</table></figure>
|
|
1261
1343
|
</section>
|
|
1262
1344
|
|
|
1263
|
-
<section id="tab-misses" class="tab">
|
|
1345
|
+
<section id="tab-misses" class="tab{_active("tab-misses")}">
|
|
1264
1346
|
<figure><table class="striped">
|
|
1265
1347
|
<thead><tr>
|
|
1266
1348
|
<th>URL</th><th class="num">Misses</th><th>Last seen</th><th>Action</th>
|
|
@@ -13,6 +13,7 @@ import sys
|
|
|
13
13
|
import tempfile
|
|
14
14
|
import threading
|
|
15
15
|
import time
|
|
16
|
+
import types
|
|
16
17
|
import unittest
|
|
17
18
|
|
|
18
19
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
@@ -961,5 +962,106 @@ class TestClientLibraryAuthForwarding(unittest.TestCase):
|
|
|
961
962
|
)
|
|
962
963
|
|
|
963
964
|
|
|
965
|
+
class TestOrasTagRevalidation(unittest.TestCase):
|
|
966
|
+
"""server._oras_tag_moved: the store keys on the ref string, so a
|
|
967
|
+
re-pushed *mutable* tag must be detected and invalidated, while
|
|
968
|
+
digest-pinned refs and unreachable registries leave the cache intact."""
|
|
969
|
+
|
|
970
|
+
TAG = "oras://ghcr.io/safl/nosi/ubuntu-2604-headless:2026.W26"
|
|
971
|
+
HEX_A = "a" * 64
|
|
972
|
+
HEX_B = "b" * 64
|
|
973
|
+
|
|
974
|
+
@staticmethod
|
|
975
|
+
def _resolver(digest_hex):
|
|
976
|
+
def _r(_ref):
|
|
977
|
+
return types.SimpleNamespace(digest="sha256:" + digest_hex)
|
|
978
|
+
|
|
979
|
+
return _r
|
|
980
|
+
|
|
981
|
+
def test_moved_when_registry_digest_differs(self):
|
|
982
|
+
self.assertTrue(
|
|
983
|
+
server._oras_tag_moved(self.TAG, self.HEX_A, resolve=self._resolver(self.HEX_B))
|
|
984
|
+
)
|
|
985
|
+
|
|
986
|
+
def test_not_moved_when_digest_matches(self):
|
|
987
|
+
self.assertFalse(
|
|
988
|
+
server._oras_tag_moved(self.TAG, self.HEX_A, resolve=self._resolver(self.HEX_A))
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
def test_digest_match_is_case_insensitive(self):
|
|
992
|
+
self.assertFalse(
|
|
993
|
+
server._oras_tag_moved(self.TAG, self.HEX_A.upper(), resolve=self._resolver(self.HEX_A))
|
|
994
|
+
)
|
|
995
|
+
|
|
996
|
+
def test_digest_pinned_ref_never_revalidates(self):
|
|
997
|
+
pinned = "oras://ghcr.io/safl/nosi/x@sha256:" + self.HEX_A
|
|
998
|
+
calls = []
|
|
999
|
+
|
|
1000
|
+
def _r(ref):
|
|
1001
|
+
calls.append(ref)
|
|
1002
|
+
return types.SimpleNamespace(digest="sha256:" + self.HEX_B)
|
|
1003
|
+
|
|
1004
|
+
self.assertFalse(server._oras_tag_moved(pinned, self.HEX_A, resolve=_r))
|
|
1005
|
+
self.assertEqual(calls, [], "a digest-pinned ref must not hit the registry")
|
|
1006
|
+
|
|
1007
|
+
def test_non_oras_url_is_never_moved(self):
|
|
1008
|
+
self.assertFalse(
|
|
1009
|
+
server._oras_tag_moved(
|
|
1010
|
+
"https://h/x.img.gz", self.HEX_A, resolve=self._resolver(self.HEX_B)
|
|
1011
|
+
)
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
def test_missing_cached_sha_keeps_entry(self):
|
|
1015
|
+
r = self._resolver(self.HEX_B)
|
|
1016
|
+
self.assertFalse(server._oras_tag_moved(self.TAG, "", resolve=r))
|
|
1017
|
+
self.assertFalse(server._oras_tag_moved(self.TAG, None, resolve=r))
|
|
1018
|
+
|
|
1019
|
+
def test_resolve_error_serves_cached(self):
|
|
1020
|
+
def _boom(_ref):
|
|
1021
|
+
raise OSError("registry unreachable")
|
|
1022
|
+
|
|
1023
|
+
self.assertFalse(server._oras_tag_moved(self.TAG, self.HEX_A, resolve=_boom))
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
class TestDashActiveTabFromHeader(unittest.TestCase):
|
|
1027
|
+
"""``GET /admin/dash`` bakes ``.active-tab`` into the rendered HTML
|
|
1028
|
+
based on the ``X-Active-Tab`` request header. The browser sends the
|
|
1029
|
+
current URL hash on every refresh so the htmx innerHTML swap doesn't
|
|
1030
|
+
visibly blink while a post-swap JS would otherwise re-apply the class.
|
|
1031
|
+
"""
|
|
1032
|
+
|
|
1033
|
+
def setUp(self):
|
|
1034
|
+
self.httpd, self.store = _start_withcache()
|
|
1035
|
+
self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
|
|
1036
|
+
|
|
1037
|
+
def tearDown(self):
|
|
1038
|
+
self.httpd.shutdown()
|
|
1039
|
+
self.httpd.server_close()
|
|
1040
|
+
|
|
1041
|
+
def _dash(self, active_tab=None):
|
|
1042
|
+
req = urllib.request.Request(self.base + "/admin/dash")
|
|
1043
|
+
if active_tab is not None:
|
|
1044
|
+
req.add_header("X-Active-Tab", active_tab)
|
|
1045
|
+
return urllib.request.urlopen(req).read().decode("utf-8")
|
|
1046
|
+
|
|
1047
|
+
def test_default_tab_when_header_missing(self):
|
|
1048
|
+
body = self._dash()
|
|
1049
|
+
# tab-cached is the first tab and the no-header default
|
|
1050
|
+
self.assertIn('<section id="tab-cached" class="tab active-tab">', body)
|
|
1051
|
+
self.assertIn('<section id="tab-streams" class="tab">', body)
|
|
1052
|
+
|
|
1053
|
+
def test_header_picks_the_active_tab(self):
|
|
1054
|
+
body = self._dash("tab-misses")
|
|
1055
|
+
self.assertIn('<section id="tab-misses" class="tab active-tab">', body)
|
|
1056
|
+
self.assertIn('<section id="tab-cached" class="tab">', body)
|
|
1057
|
+
|
|
1058
|
+
def test_unknown_header_value_falls_back_to_first(self):
|
|
1059
|
+
"""A hand-crafted X-Active-Tab with a bogus value must not echo
|
|
1060
|
+
into the HTML; the renderer falls back to the first tab."""
|
|
1061
|
+
body = self._dash("tab-totally-not-real")
|
|
1062
|
+
self.assertIn('<section id="tab-cached" class="tab active-tab">', body)
|
|
1063
|
+
self.assertNotIn("tab-totally-not-real", body)
|
|
1064
|
+
|
|
1065
|
+
|
|
964
1066
|
if __name__ == "__main__":
|
|
965
1067
|
unittest.main(verbosity=2)
|
|
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
|