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.
- {withcache-0.3.0 → withcache-0.4.1}/PKG-INFO +2 -2
- {withcache-0.3.0 → withcache-0.4.1}/README.md +1 -1
- {withcache-0.3.0 → withcache-0.4.1}/shim/build.zig.zon +1 -1
- {withcache-0.3.0 → withcache-0.4.1}/src/withcache/__init__.py +1 -1
- {withcache-0.3.0 → withcache-0.4.1}/src/withcache/client.py +31 -4
- {withcache-0.3.0 → withcache-0.4.1}/src/withcache/server.py +41 -7
- {withcache-0.3.0 → withcache-0.4.1}/tests/test_withcache.py +184 -0
- {withcache-0.3.0 → withcache-0.4.1}/.gitignore +0 -0
- {withcache-0.3.0 → withcache-0.4.1}/LICENSE +0 -0
- {withcache-0.3.0 → withcache-0.4.1}/deploy/Containerfile +0 -0
- {withcache-0.3.0 → withcache-0.4.1}/deploy/compose.yml +0 -0
- {withcache-0.3.0 → withcache-0.4.1}/hatch_build.py +0 -0
- {withcache-0.3.0 → withcache-0.4.1}/pyproject.toml +0 -0
- {withcache-0.3.0 → withcache-0.4.1}/shim/build.zig +0 -0
- {withcache-0.3.0 → withcache-0.4.1}/shim/shim.zig +0 -0
- {withcache-0.3.0 → withcache-0.4.1}/src/withcache/_shim.py +0 -0
- {withcache-0.3.0 → withcache-0.4.1}/src/withcache/curlwithcache.py +0 -0
- {withcache-0.3.0 → withcache-0.4.1}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.3.0 → withcache-0.4.1}/src/withcache/static/pico.min.css +0 -0
- {withcache-0.3.0 → withcache-0.4.1}/src/withcache/wgetwithcache.py +0 -0
- {withcache-0.3.0 → withcache-0.4.1}/tests/test_differential.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: withcache
|
|
3
|
-
Version: 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
|
-
[](https://github.com/safl/withcache/actions/workflows/ci.yml)
|
|
21
|
+
[](https://github.com/safl/withcache/actions/workflows/ci-cd.yml)
|
|
22
22
|
[](https://pypi.org/project/withcache/)
|
|
23
23
|
[](LICENSE)
|
|
24
24
|
[](https://ziglang.org)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# withcache
|
|
2
2
|
|
|
3
|
-
[](https://github.com/safl/withcache/actions/workflows/ci.yml)
|
|
3
|
+
[](https://github.com/safl/withcache/actions/workflows/ci-cd.yml)
|
|
4
4
|
[](https://pypi.org/project/withcache/)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
[](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.
|
|
5
|
+
.version = "0.4.1",
|
|
6
6
|
.fingerprint = 0xd7d96c5ed212ccaa,
|
|
7
7
|
.minimum_zig_version = "0.16.0",
|
|
8
8
|
.paths = .{
|
|
@@ -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(
|
|
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(
|
|
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 —
|
|
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.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|