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.
Files changed (23) hide show
  1. {withcache-0.6.0 → withcache-0.6.1}/PKG-INFO +1 -1
  2. {withcache-0.6.0 → withcache-0.6.1}/shim/build.zig.zon +1 -1
  3. {withcache-0.6.0 → withcache-0.6.1}/src/withcache/__init__.py +1 -1
  4. {withcache-0.6.0 → withcache-0.6.1}/src/withcache/server.py +50 -0
  5. {withcache-0.6.0 → withcache-0.6.1}/tests/test_withcache.py +62 -0
  6. {withcache-0.6.0 → withcache-0.6.1}/.gitignore +0 -0
  7. {withcache-0.6.0 → withcache-0.6.1}/LICENSE +0 -0
  8. {withcache-0.6.0 → withcache-0.6.1}/README.md +0 -0
  9. {withcache-0.6.0 → withcache-0.6.1}/deploy/Containerfile +0 -0
  10. {withcache-0.6.0 → withcache-0.6.1}/deploy/compose.yml +0 -0
  11. {withcache-0.6.0 → withcache-0.6.1}/hatch_build.py +0 -0
  12. {withcache-0.6.0 → withcache-0.6.1}/pyproject.toml +0 -0
  13. {withcache-0.6.0 → withcache-0.6.1}/shim/build.zig +0 -0
  14. {withcache-0.6.0 → withcache-0.6.1}/shim/shim.zig +0 -0
  15. {withcache-0.6.0 → withcache-0.6.1}/src/withcache/_shim.py +0 -0
  16. {withcache-0.6.0 → withcache-0.6.1}/src/withcache/client.py +0 -0
  17. {withcache-0.6.0 → withcache-0.6.1}/src/withcache/curlwithcache.py +0 -0
  18. {withcache-0.6.0 → withcache-0.6.1}/src/withcache/oras.py +0 -0
  19. {withcache-0.6.0 → withcache-0.6.1}/src/withcache/static/htmx.min.js +0 -0
  20. {withcache-0.6.0 → withcache-0.6.1}/src/withcache/static/pico.min.css +0 -0
  21. {withcache-0.6.0 → withcache-0.6.1}/src/withcache/wgetwithcache.py +0 -0
  22. {withcache-0.6.0 → withcache-0.6.1}/tests/test_differential.py +0 -0
  23. {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.0
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.0",
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.0"
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