withcache 0.3.0__tar.gz → 0.4.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.3.0
3
+ Version: 0.4.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>
@@ -18,7 +18,7 @@ Description-Content-Type: text/markdown
18
18
 
19
19
  # withcache
20
20
 
21
- [![ci](https://github.com/safl/withcache/actions/workflows/ci.yml/badge.svg)](https://github.com/safl/withcache/actions/workflows/ci.yml)
21
+ [![ci](https://github.com/safl/withcache/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/safl/withcache/actions/workflows/ci-cd.yml)
22
22
  [![PyPI](https://img.shields.io/pypi/v/withcache.svg)](https://pypi.org/project/withcache/)
23
23
  [![license](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](LICENSE)
24
24
  [![built with Zig](https://img.shields.io/badge/built%20with-Zig%200.16.0-f7a41d.svg)](https://ziglang.org)
@@ -1,6 +1,6 @@
1
1
  # withcache
2
2
 
3
- [![ci](https://github.com/safl/withcache/actions/workflows/ci.yml/badge.svg)](https://github.com/safl/withcache/actions/workflows/ci.yml)
3
+ [![ci](https://github.com/safl/withcache/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/safl/withcache/actions/workflows/ci-cd.yml)
4
4
  [![PyPI](https://img.shields.io/pypi/v/withcache.svg)](https://pypi.org/project/withcache/)
5
5
  [![license](https://img.shields.io/badge/license-BSD--3--Clause-blue.svg)](LICENSE)
6
6
  [![built with Zig](https://img.shields.io/badge/built%20with-Zig%200.16.0-f7a41d.svg)](https://ziglang.org)
@@ -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.3.0",
5
+ .version = "0.4.1",
6
6
  .fingerprint = 0xd7d96c5ed212ccaa,
7
7
  .minimum_zig_version = "0.16.0",
8
8
  .paths = .{
@@ -12,6 +12,6 @@ All modules are stdlib-only and self-contained.
12
12
 
13
13
  from .client import blob_url, cache_base, is_cached, serve_url
14
14
 
15
- __version__ = "0.3.0"
15
+ __version__ = "0.4.1"
16
16
 
17
17
  __all__ = ["__version__", "blob_url", "cache_base", "is_cached", "serve_url"]
@@ -37,13 +37,31 @@ def blob_url(server: str, origin: str) -> str:
37
37
  return _shim.blob_url(_shim.cache_base(server), origin)
38
38
 
39
39
 
40
- def is_cached(server: str, origin: str, timeout: float = PROBE_TIMEOUT) -> bool:
40
+ def is_cached(
41
+ server: str,
42
+ origin: str,
43
+ timeout: float = PROBE_TIMEOUT,
44
+ headers: dict[str, str] | None = None,
45
+ ) -> bool:
41
46
  """True if the cache-host already holds ``origin`` (a ``HEAD`` on ``/b/``
42
47
  returns 200). A miss (404), an unreachable host, a timeout, or any error
43
48
  returns False, so a caller can safely fall back to the origin. The HEAD
44
49
  also *warms* an auto-fetch cache-host: the miss is recorded and the
45
- background fill enqueued, so a later probe flips to cached."""
50
+ background fill enqueued, so a later probe flips to cached.
51
+
52
+ ``headers`` (optional) attaches request headers to the HEAD. The
53
+ cache-host forwards a client-supplied ``Authorization`` into its
54
+ background-fetch worker, so a consumer that has just minted an OCI
55
+ bearer (the typical use case: bty resolving an ``oras://`` catalog
56
+ entry to a ``ghcr.io`` blob URL at import time) can warm the cache
57
+ against that token-gated origin in one probe. Other entries in
58
+ ``headers`` round-trip the same way; only ``Authorization`` is
59
+ forwarded into the fetch on the server side.
60
+ """
46
61
  req = urllib.request.Request(blob_url(server, origin), method="HEAD")
62
+ if headers:
63
+ for k, v in headers.items():
64
+ req.add_header(k, v)
47
65
  try:
48
66
  with urllib.request.urlopen(req, timeout=timeout) as resp:
49
67
  return bool(resp.status == 200)
@@ -53,10 +71,19 @@ def is_cached(server: str, origin: str, timeout: float = PROBE_TIMEOUT) -> bool:
53
71
  return False # unreachable / timeout -> caller serves the origin itself
54
72
 
55
73
 
56
- def serve_url(server: str, origin: str, timeout: float = PROBE_TIMEOUT) -> str | None:
74
+ def serve_url(
75
+ server: str,
76
+ origin: str,
77
+ timeout: float = PROBE_TIMEOUT,
78
+ headers: dict[str, str] | None = None,
79
+ ) -> str | None:
57
80
  """The cache-host serve URL for ``origin`` if the cache holds it, else
58
81
  ``None`` -- the convenience form of "use the cache when warm":
59
82
 
60
83
  url = client.serve_url(cache, origin) or origin
84
+
85
+ ``headers`` is passed through to :func:`is_cached` for the HEAD probe;
86
+ the returned serve URL never carries auth (cached bytes are served
87
+ without revisiting the origin).
61
88
  """
62
- return blob_url(server, origin) if is_cached(server, origin, timeout) else None
89
+ return blob_url(server, origin) if is_cached(server, origin, timeout, headers=headers) else None
@@ -1,16 +1,17 @@
1
1
  #!/usr/bin/env python3
2
- """withcache cache-host — an operator-curated artifact cache.
2
+ """withcache cache-host — a URL-keyed artifact cache.
3
3
 
4
4
  Stdlib only (http.server + sqlite3 + urllib). Serves cached blobs keyed by
5
- their origin URL. A cache miss is *not* fetched automatically: it is recorded
6
- in a miss table so an operator can review it and press "Download", at which
7
- point the cache-host pulls the artifact from origin and stores it. There is
8
- also an "add from URI" form to pre-seed an artifact before anyone misses it.
5
+ their origin URL. By default a cache miss is auto-fetched: it is recorded in the
6
+ miss table and pulled from origin in the background, so the next request hits
7
+ (the client falls through to origin on the first miss). Run with `--curate` to
8
+ require an operator to approve each pull via a small web UI instead; either way
9
+ you can pre-seed an artifact with the "Add from URI" form.
9
10
 
10
11
  This is the only component that needs internet egress (and any vendor creds).
11
12
  Clients never write to it.
12
13
 
13
- Auth (modelled on bty's single-tenant approach, minus PAM): the read path
14
+ Auth (single-tenant: env password + signed cookie): the read path
14
15
  (`/blob`, `/healthz`) is open so clients never log in; the operator surface
15
16
  (`/` and `/admin/*`) is gated behind a server-signed session cookie. Login at
16
17
  `POST /ui/login` checks the password in $WITHCACHE_ADMIN_PASSWORD and flips the
@@ -324,6 +325,17 @@ class Store:
324
325
  size += len(chunk)
325
326
  if progress:
326
327
  progress(size, total)
328
+ # urllib's read loop exits on clean EOF AND on transport-
329
+ # aborted close; HTTPResponse only raises IncompleteRead
330
+ # in some configurations. When the origin declared
331
+ # Content-Length, treat that as the contract and refuse
332
+ # to promote a short blob. A silent partial-promotion
333
+ # would serve malformed bytes to every future consumer
334
+ # with no way for them to invalidate the entry.
335
+ if total is not None and size != total:
336
+ raise TruncatedDownload(
337
+ f"upstream truncated for {url}: declared {total} bytes, got {size}"
338
+ )
327
339
  os.replace(tmp, self.blob_path(key))
328
340
  except BaseException:
329
341
  if os.path.exists(tmp):
@@ -368,6 +380,14 @@ class CacheFull(Exception):
368
380
  """Raised when --max-bytes is reached; the fill is refused, not evicted."""
369
381
 
370
382
 
383
+ class TruncatedDownload(Exception):
384
+ """Raised when the upstream stream ended before the declared
385
+ Content-Length. The temp file is removed and no blob row is
386
+ written, so the same URL re-enqueues cleanly on the next request
387
+ instead of permanently serving a malformed file.
388
+ """
389
+
390
+
371
391
  @dataclass
372
392
  class Job:
373
393
  id: int
@@ -663,7 +683,21 @@ class Handler(http.server.BaseHTTPRequestHandler):
663
683
  # falls through on a miss). In --curate mode an operator triggers
664
684
  # the pull instead; when the cache is full we record the miss but
665
685
  # schedule nothing (delete something first).
666
- self.mgr.enqueue(url)
686
+ #
687
+ # Forward the client's ``Authorization`` header into the worker
688
+ # job so a token-gated origin (typical use case: a fresh OCI
689
+ # bearer on a ghcr.io blob URL minted by bty-web at catalog
690
+ # import time) can be fetched. Without this the worker runs
691
+ # anonymous and 401s; the URL stays uncached forever. Keep the
692
+ # allowlist narrow on purpose: ``Authorization`` is the only
693
+ # request header we proxy onto the worker. The ``/admin/fetch``
694
+ # operator endpoint still carries its own ``headers=`` payload
695
+ # for the curated path.
696
+ fwd_headers = None
697
+ auth = self.headers.get("Authorization")
698
+ if auth:
699
+ fwd_headers = {"Authorization": auth}
700
+ self.mgr.enqueue(url, headers=fwd_headers)
667
701
  self.send_text(404, "cache miss (recorded)\n")
668
702
  return
669
703
  path = self.store.blob_path(row["key"])
@@ -7,10 +7,12 @@ without an install.
7
7
  import http.server
8
8
  import os
9
9
  import shutil
10
+ import socket
10
11
  import socketserver
11
12
  import sys
12
13
  import tempfile
13
14
  import threading
15
+ import time
14
16
  import unittest
15
17
 
16
18
  sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
@@ -166,6 +168,78 @@ class TestStoreFromOrigin(unittest.TestCase):
166
168
  store.store_from_origin(f"http://127.0.0.1:{self.port}/b.bin")
167
169
 
168
170
 
171
+ class _TruncatingOrigin(http.server.BaseHTTPRequestHandler):
172
+ """Declare a full Content-Length, then send half the payload and
173
+ close the socket. Mirrors the real-world failure mode where the
174
+ upstream drops the connection mid-stream (lab-box fedora-44-desktop
175
+ flash that surfaced this bug)."""
176
+
177
+ PAYLOAD = b"abcdefghij" * 100 # 1000 bytes; will write half then close
178
+
179
+ def do_GET(self):
180
+ self.send_response(200)
181
+ self.send_header("Content-Type", "application/octet-stream")
182
+ self.send_header("Content-Length", str(len(self.PAYLOAD)))
183
+ self.end_headers()
184
+ half = len(self.PAYLOAD) // 2
185
+ self.wfile.write(self.PAYLOAD[:half])
186
+ # close the underlying socket so urllib observes EOF before
187
+ # Content-Length bytes arrive
188
+ self.wfile.flush()
189
+ try:
190
+ self.connection.shutdown(socket.SHUT_RDWR)
191
+ except OSError:
192
+ pass
193
+
194
+ def log_message(self, format, *args):
195
+ pass
196
+
197
+
198
+ class TestTruncatedDownloadRejected(unittest.TestCase):
199
+ """Regression for the lab-spotted bug where a transport-aborted
200
+ upstream stream silently became a permanent cached blob: future
201
+ HEADs returned 200 with the partial bytes, every consumer got a
202
+ malformed file, and the only escape was hand-deleting the blob.
203
+ Content-Length mismatches now fail loudly and leave no entry."""
204
+
205
+ def setUp(self):
206
+ self.httpd = socketserver.TCPServer(("127.0.0.1", 0), _TruncatingOrigin)
207
+ self.port = self.httpd.server_address[1]
208
+ self.t = threading.Thread(target=self.httpd.serve_forever, daemon=True)
209
+ self.t.start()
210
+ self.store = server.Store(tempfile.mkdtemp(), keep_query=False)
211
+
212
+ def tearDown(self):
213
+ self.httpd.shutdown()
214
+ self.httpd.server_close()
215
+
216
+ def test_truncated_upstream_raises_and_leaves_no_blob(self):
217
+ url = f"http://127.0.0.1:{self.port}/truncated.bin"
218
+ with self.assertRaises(server.TruncatedDownload) as cm:
219
+ self.store.store_from_origin(url)
220
+ # the message must name both totals so the operator can see
221
+ # how short the upstream came
222
+ msg = str(cm.exception)
223
+ self.assertIn("1000", msg) # declared
224
+ self.assertIn("500", msg) # got
225
+ # no row was written; no blob file lingers on disk
226
+ self.assertIsNone(self.store.get_blob(url))
227
+ blobs = list(self.store.blob_path("").rsplit("/", 1)[0:1])
228
+ if os.path.isdir(blobs[0]):
229
+ self.assertEqual(os.listdir(blobs[0]), [])
230
+
231
+ def test_repeat_request_after_truncation_can_retry_cleanly(self):
232
+ url = f"http://127.0.0.1:{self.port}/truncated.bin"
233
+ with self.assertRaises(server.TruncatedDownload):
234
+ self.store.store_from_origin(url)
235
+ # second attempt against the same URL would have hit the
236
+ # poisoned cache before the fix; now it must repeat the
237
+ # failure mode (no sticky blob blocking the retry) so a
238
+ # later origin recovery can re-fill the entry cleanly.
239
+ with self.assertRaises(server.TruncatedDownload):
240
+ self.store.store_from_origin(url)
241
+
242
+
169
243
  # --------------------------------------------------------------------------
170
244
  # _shim: URL detection, rewrite, real-tool resolution, env, path-encoding
171
245
  # --------------------------------------------------------------------------
@@ -482,6 +556,60 @@ class TestFetchWithHeaders(unittest.TestCase):
482
556
  self.assertEqual(row["size"], len(PAYLOAD))
483
557
 
484
558
 
559
+ # --------------------------------------------------------------------------
560
+ # HEAD with an Authorization header should propagate that header into the
561
+ # auto-fetch worker so a 401-gated origin (e.g. a ghcr.io blob URL behind a
562
+ # bty-minted OCI bearer) actually fills. Without this propagation the worker
563
+ # pulls anonymous, the origin 401s, and the URL stays uncached forever.
564
+ # --------------------------------------------------------------------------
565
+ class TestHeadForwardsAuthorizationToAutoFetch(unittest.TestCase):
566
+ def setUp(self):
567
+ self.origin = socketserver.TCPServer(("127.0.0.1", 0), _AuthOrigin)
568
+ threading.Thread(target=self.origin.serve_forever, daemon=True).start()
569
+ self.origin_url = f"http://127.0.0.1:{self.origin.server_address[1]}/blob.bin"
570
+ self.httpd, self.store = _start_withcache(auto_fetch=True)
571
+ self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
572
+
573
+ def tearDown(self):
574
+ for s in (self.origin, self.httpd):
575
+ s.shutdown()
576
+ s.server_close()
577
+
578
+ def _wait_for_fill(self, timeout_s=2.0):
579
+ deadline = time.monotonic() + timeout_s
580
+ while time.monotonic() < deadline:
581
+ if self.store.get_blob(self.origin_url) is not None:
582
+ return True
583
+ time.sleep(0.02)
584
+ return False
585
+
586
+ def test_head_with_authorization_triggers_authed_fetch(self):
587
+ bu = _shim.blob_url(self.base, self.origin_url)
588
+ req = urllib.request.Request(bu, method="HEAD")
589
+ req.add_header("Authorization", _AuthOrigin.TOKEN)
590
+ with self.assertRaises(urllib.error.HTTPError) as cm:
591
+ urllib.request.urlopen(req)
592
+ self.assertEqual(cm.exception.code, 404) # miss; recorded + enqueued
593
+ # The worker should have fetched in the background using the header.
594
+ self.assertTrue(
595
+ self._wait_for_fill(),
596
+ "expected blob to be cached after auth-bearing HEAD",
597
+ )
598
+
599
+ def test_head_without_authorization_leaves_origin_401_and_cache_empty(self):
600
+ # Negative: no Authorization on the HEAD means the worker is enqueued
601
+ # anonymous, the origin 401s, nothing lands. Verifies the new code
602
+ # path is genuinely opt-in (HEAD without auth keeps the old behaviour).
603
+ bu = _shim.blob_url(self.base, self.origin_url)
604
+ with self.assertRaises(urllib.error.HTTPError) as cm:
605
+ urllib.request.urlopen(urllib.request.Request(bu, method="HEAD"))
606
+ self.assertEqual(cm.exception.code, 404)
607
+ self.assertFalse(
608
+ self._wait_for_fill(timeout_s=0.5),
609
+ "expected no blob without forwarded auth",
610
+ )
611
+
612
+
485
613
  # --------------------------------------------------------------------------
486
614
  # Pure helpers
487
615
  # --------------------------------------------------------------------------
@@ -539,5 +667,61 @@ class TestClientLibrary(unittest.TestCase):
539
667
  self.assertFalse(client.is_cached("http://127.0.0.1:9", self.origin_url, timeout=0.5))
540
668
 
541
669
 
670
+ # --------------------------------------------------------------------------
671
+ # Client + server end-to-end: a HEAD with ``headers={"Authorization": ...}``
672
+ # warms the cache against a 401-gated origin. Mirrors the bty oras case
673
+ # (resolved ghcr.io blob URL + freshly-minted OCI bearer).
674
+ # --------------------------------------------------------------------------
675
+ class TestClientLibraryAuthForwarding(unittest.TestCase):
676
+ def setUp(self):
677
+ self.origin = socketserver.TCPServer(("127.0.0.1", 0), _AuthOrigin)
678
+ threading.Thread(target=self.origin.serve_forever, daemon=True).start()
679
+ self.origin_url = f"http://127.0.0.1:{self.origin.server_address[1]}/blob.bin"
680
+ self.httpd, self.store = _start_withcache(auto_fetch=True)
681
+ self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
682
+
683
+ def tearDown(self):
684
+ for s in (self.origin, self.httpd):
685
+ s.shutdown()
686
+ s.server_close()
687
+
688
+ def test_is_cached_with_authorization_warms_auth_gated_origin(self):
689
+ # Cold: no auth -> background fetch goes anonymous, 401s, cache empty.
690
+ self.assertFalse(client.is_cached(self.base, self.origin_url))
691
+ deadline = time.monotonic() + 0.5
692
+ while time.monotonic() < deadline:
693
+ if self.store.get_blob(self.origin_url) is not None:
694
+ break
695
+ time.sleep(0.02)
696
+ self.assertIsNone(self.store.get_blob(self.origin_url))
697
+
698
+ # Warm-with-token: HEAD carries Authorization; server forwards it
699
+ # into the fetch worker; the auth-gated origin returns the bytes.
700
+ self.assertFalse(
701
+ client.is_cached(
702
+ self.base,
703
+ self.origin_url,
704
+ headers={"Authorization": _AuthOrigin.TOKEN},
705
+ )
706
+ )
707
+ deadline = time.monotonic() + 2.0
708
+ while time.monotonic() < deadline:
709
+ if self.store.get_blob(self.origin_url) is not None:
710
+ break
711
+ time.sleep(0.02)
712
+ self.assertIsNotNone(
713
+ self.store.get_blob(self.origin_url),
714
+ "expected auth-bearing HEAD to fill the cache via forwarded Authorization",
715
+ )
716
+
717
+ # And once cached, the auth header is no longer needed: a plain HEAD
718
+ # hits 200, serve_url returns the blob URL without auth.
719
+ self.assertTrue(client.is_cached(self.base, self.origin_url))
720
+ self.assertEqual(
721
+ client.serve_url(self.base, self.origin_url),
722
+ client.blob_url(self.base, self.origin_url),
723
+ )
724
+
725
+
542
726
  if __name__ == "__main__":
543
727
  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