withcache 0.6.0__tar.gz → 0.6.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.6.0 → withcache-0.6.1}/PKG-INFO +1 -1
- {withcache-0.6.0 → withcache-0.6.1}/shim/build.zig.zon +1 -1
- {withcache-0.6.0 → withcache-0.6.1}/src/withcache/__init__.py +1 -1
- {withcache-0.6.0 → withcache-0.6.1}/src/withcache/server.py +50 -0
- {withcache-0.6.0 → withcache-0.6.1}/tests/test_withcache.py +62 -0
- {withcache-0.6.0 → withcache-0.6.1}/.gitignore +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/LICENSE +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/README.md +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/deploy/Containerfile +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/deploy/compose.yml +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/hatch_build.py +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/pyproject.toml +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/shim/build.zig +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/shim/shim.zig +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/src/withcache/_shim.py +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/src/withcache/client.py +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/src/withcache/curlwithcache.py +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/src/withcache/oras.py +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/src/withcache/static/pico.min.css +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/src/withcache/wgetwithcache.py +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/tests/test_differential.py +0 -0
- {withcache-0.6.0 → withcache-0.6.1}/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.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>
|
|
@@ -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.1",
|
|
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.1"
|
|
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
|
# --------------------------------------------------------------------------
|
|
@@ -856,6 +899,13 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
856
899
|
self.send_text(400, "missing url\n")
|
|
857
900
|
return
|
|
858
901
|
row = self.store.get_blob(url)
|
|
902
|
+
if row is not None and _oras_tag_moved(url, row["sha256"]):
|
|
903
|
+
# The tag was re-pushed to a different layer since we cached it.
|
|
904
|
+
# Drop the stale bytes and fall through to the miss path so the
|
|
905
|
+
# current content is (auto-)fetched. Deleting first also frees the
|
|
906
|
+
# space the refill needs when the store is near --max-bytes.
|
|
907
|
+
self.store.delete_blob(row["key"])
|
|
908
|
+
row = None
|
|
859
909
|
if row is None:
|
|
860
910
|
self.store.record_miss(url)
|
|
861
911
|
if self.auto_fetch and self.store.has_capacity():
|
|
@@ -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,66 @@ 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
|
+
|
|
964
1026
|
if __name__ == "__main__":
|
|
965
1027
|
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
|