withcache 0.6.4__tar.gz → 0.7.0__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.4 → withcache-0.7.0}/PKG-INFO +11 -11
- {withcache-0.6.4 → withcache-0.7.0}/README.md +9 -9
- {withcache-0.6.4 → withcache-0.7.0}/deploy/Containerfile +3 -3
- {withcache-0.6.4 → withcache-0.7.0}/deploy/compose.yml +1 -1
- {withcache-0.6.4 → withcache-0.7.0}/pyproject.toml +2 -2
- {withcache-0.6.4 → withcache-0.7.0}/shim/build.zig.zon +1 -1
- {withcache-0.6.4 → withcache-0.7.0}/src/withcache/__init__.py +1 -1
- {withcache-0.6.4 → withcache-0.7.0}/src/withcache/_shim.py +1 -1
- {withcache-0.6.4 → withcache-0.7.0}/src/withcache/client.py +2 -2
- {withcache-0.6.4 → withcache-0.7.0}/src/withcache/curlwithcache.py +1 -1
- {withcache-0.6.4 → withcache-0.7.0}/src/withcache/server.py +186 -4
- {withcache-0.6.4 → withcache-0.7.0}/src/withcache/wgetwithcache.py +1 -1
- {withcache-0.6.4 → withcache-0.7.0}/tests/test_differential.py +1 -1
- {withcache-0.6.4 → withcache-0.7.0}/tests/test_withcache.py +18 -10
- {withcache-0.6.4 → withcache-0.7.0}/.gitignore +0 -0
- {withcache-0.6.4 → withcache-0.7.0}/LICENSE +0 -0
- {withcache-0.6.4 → withcache-0.7.0}/hatch_build.py +0 -0
- {withcache-0.6.4 → withcache-0.7.0}/shim/build.zig +0 -0
- {withcache-0.6.4 → withcache-0.7.0}/shim/shim.zig +0 -0
- {withcache-0.6.4 → withcache-0.7.0}/src/withcache/oras.py +0 -0
- {withcache-0.6.4 → withcache-0.7.0}/src/withcache/static/bootstrap-icons.min.css +0 -0
- {withcache-0.6.4 → withcache-0.7.0}/src/withcache/static/bootstrap.min.css +0 -0
- {withcache-0.6.4 → withcache-0.7.0}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
- {withcache-0.6.4 → withcache-0.7.0}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
- {withcache-0.6.4 → withcache-0.7.0}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.6.4 → withcache-0.7.0}/tests/test_oras.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: withcache
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.7.0
|
|
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>
|
|
@@ -13,7 +13,7 @@ Classifier: Intended Audience :: System Administrators
|
|
|
13
13
|
Classifier: License :: OSI Approved :: BSD License
|
|
14
14
|
Classifier: Programming Language :: Python :: 3
|
|
15
15
|
Classifier: Topic :: System :: Archiving :: Mirroring
|
|
16
|
-
Requires-Python: >=3.
|
|
16
|
+
Requires-Python: >=3.11
|
|
17
17
|
Description-Content-Type: text/markdown
|
|
18
18
|
|
|
19
19
|
# withcache
|
|
@@ -100,13 +100,13 @@ asserts the binary and the Python `plan()` rewrite argv identically.
|
|
|
100
100
|
```sh
|
|
101
101
|
export WITHCACHE_ADMIN_PASSWORD=change-me # protects the operator UI
|
|
102
102
|
podman compose -f deploy/compose.yml up -d # or: docker compose -f ...
|
|
103
|
-
# operator UI: http://withcache-server:
|
|
103
|
+
# operator UI: http://withcache-server:8081/
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
Or without containers:
|
|
107
107
|
|
|
108
108
|
```sh
|
|
109
|
-
WITHCACHE_ADMIN_PASSWORD=change-me withcache-server --data-dir ./data --port
|
|
109
|
+
WITHCACHE_ADMIN_PASSWORD=change-me withcache-server --data-dir ./data --port 8081
|
|
110
110
|
```
|
|
111
111
|
|
|
112
112
|
Data (blobs + `cache.db` + `session-secret`) lives in the `/data` volume (or
|
|
@@ -137,7 +137,7 @@ Nothing is renamed; you opt in per command. Good for trying it out or a script
|
|
|
137
137
|
you can edit.
|
|
138
138
|
|
|
139
139
|
```sh
|
|
140
|
-
export WITHCACHE_SERVER=http://withcache-server:
|
|
140
|
+
export WITHCACHE_SERVER=http://withcache-server:8081
|
|
141
141
|
curlwithcache -fsSL https://the/origin/cuda.tar.gz -o cuda.tar.gz
|
|
142
142
|
wgetwithcache https://the/origin/rocm.tar.gz
|
|
143
143
|
```
|
|
@@ -152,7 +152,7 @@ mkdir -p ~/.withcache/bin
|
|
|
152
152
|
ln -sf "$(command -v curlwithcache)" ~/.withcache/bin/curl
|
|
153
153
|
ln -sf "$(command -v wgetwithcache)" ~/.withcache/bin/wget
|
|
154
154
|
|
|
155
|
-
export WITHCACHE_SERVER=http://withcache-server:
|
|
155
|
+
export WITHCACHE_SERVER=http://withcache-server:8081
|
|
156
156
|
export PATH="$HOME/.withcache/bin:$PATH"
|
|
157
157
|
hash -r # forget any cached curl/wget location
|
|
158
158
|
|
|
@@ -174,7 +174,7 @@ ln -sf "$(command -v wgetwithcache)" ~/.withcache/bin/wget
|
|
|
174
174
|
cat >> ~/.bashrc <<'EOF'
|
|
175
175
|
|
|
176
176
|
# withcache: transparent curl/wget caching
|
|
177
|
-
export WITHCACHE_SERVER=http://withcache-server:
|
|
177
|
+
export WITHCACHE_SERVER=http://withcache-server:8081
|
|
178
178
|
export PATH="$HOME/.withcache/bin:$PATH"
|
|
179
179
|
EOF
|
|
180
180
|
```
|
|
@@ -186,7 +186,7 @@ inside that directory.
|
|
|
186
186
|
|
|
187
187
|
```sh
|
|
188
188
|
# .envrc
|
|
189
|
-
export WITHCACHE_SERVER=http://withcache-server:
|
|
189
|
+
export WITHCACHE_SERVER=http://withcache-server:8081
|
|
190
190
|
PATH_add ~/.withcache/bin # assumes the symlinks from approach 2/3 exist
|
|
191
191
|
```
|
|
192
192
|
|
|
@@ -204,7 +204,7 @@ sudo ln -sf "$(command -v wgetwithcache)" /usr/local/bin/wget
|
|
|
204
204
|
|
|
205
205
|
# A login-shell env file (covers interactive logins; daemons started outside a
|
|
206
206
|
# login shell won't see it; set WITHCACHE_SERVER in their unit if you need it).
|
|
207
|
-
echo 'export WITHCACHE_SERVER=http://withcache-server:
|
|
207
|
+
echo 'export WITHCACHE_SERVER=http://withcache-server:8081' \
|
|
208
208
|
| sudo tee /etc/profile.d/withcache.sh >/dev/null
|
|
209
209
|
```
|
|
210
210
|
|
|
@@ -237,7 +237,7 @@ Notes & limits (all degrade gracefully; worst case is "no caching, curl still wo
|
|
|
237
237
|
|
|
238
238
|
## Operator UI
|
|
239
239
|
|
|
240
|
-
`http://withcache-server:
|
|
240
|
+
`http://withcache-server:8081/` (Pico.css + HTMX, bundled offline) shows:
|
|
241
241
|
- **Misses**: auto-fetched by default, or (under `--curate`) each with **Download** (queues a background pull) and **Dismiss**.
|
|
242
242
|
- **Downloads**: live progress bars, `queued/running/completed/cancelled/failed`, **Cancel**, and **Clear finished**. Downloads run in a background worker pool, not in the request, so large pulls never block, modelled on [bty]'s job managers.
|
|
243
243
|
- **Cached artifacts**: URL, size, **hits** (times served) and **misses** (times requested before it was cached), SHA-256, fetched-at, each with **Delete** to free space.
|
|
@@ -276,7 +276,7 @@ scheme. `withcache.client` is stdlib-only, so importing it adds no dependencies:
|
|
|
276
276
|
from withcache import client
|
|
277
277
|
|
|
278
278
|
# "use the cache when it's warm, the origin otherwise"
|
|
279
|
-
url = client.serve_url("http://cache:
|
|
279
|
+
url = client.serve_url("http://cache:8081", origin) or origin
|
|
280
280
|
```
|
|
281
281
|
|
|
282
282
|
`is_cached()` is a graceful `HEAD` (a miss, timeout, or unreachable cache all
|
|
@@ -82,13 +82,13 @@ asserts the binary and the Python `plan()` rewrite argv identically.
|
|
|
82
82
|
```sh
|
|
83
83
|
export WITHCACHE_ADMIN_PASSWORD=change-me # protects the operator UI
|
|
84
84
|
podman compose -f deploy/compose.yml up -d # or: docker compose -f ...
|
|
85
|
-
# operator UI: http://withcache-server:
|
|
85
|
+
# operator UI: http://withcache-server:8081/
|
|
86
86
|
```
|
|
87
87
|
|
|
88
88
|
Or without containers:
|
|
89
89
|
|
|
90
90
|
```sh
|
|
91
|
-
WITHCACHE_ADMIN_PASSWORD=change-me withcache-server --data-dir ./data --port
|
|
91
|
+
WITHCACHE_ADMIN_PASSWORD=change-me withcache-server --data-dir ./data --port 8081
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
Data (blobs + `cache.db` + `session-secret`) lives in the `/data` volume (or
|
|
@@ -119,7 +119,7 @@ Nothing is renamed; you opt in per command. Good for trying it out or a script
|
|
|
119
119
|
you can edit.
|
|
120
120
|
|
|
121
121
|
```sh
|
|
122
|
-
export WITHCACHE_SERVER=http://withcache-server:
|
|
122
|
+
export WITHCACHE_SERVER=http://withcache-server:8081
|
|
123
123
|
curlwithcache -fsSL https://the/origin/cuda.tar.gz -o cuda.tar.gz
|
|
124
124
|
wgetwithcache https://the/origin/rocm.tar.gz
|
|
125
125
|
```
|
|
@@ -134,7 +134,7 @@ mkdir -p ~/.withcache/bin
|
|
|
134
134
|
ln -sf "$(command -v curlwithcache)" ~/.withcache/bin/curl
|
|
135
135
|
ln -sf "$(command -v wgetwithcache)" ~/.withcache/bin/wget
|
|
136
136
|
|
|
137
|
-
export WITHCACHE_SERVER=http://withcache-server:
|
|
137
|
+
export WITHCACHE_SERVER=http://withcache-server:8081
|
|
138
138
|
export PATH="$HOME/.withcache/bin:$PATH"
|
|
139
139
|
hash -r # forget any cached curl/wget location
|
|
140
140
|
|
|
@@ -156,7 +156,7 @@ ln -sf "$(command -v wgetwithcache)" ~/.withcache/bin/wget
|
|
|
156
156
|
cat >> ~/.bashrc <<'EOF'
|
|
157
157
|
|
|
158
158
|
# withcache: transparent curl/wget caching
|
|
159
|
-
export WITHCACHE_SERVER=http://withcache-server:
|
|
159
|
+
export WITHCACHE_SERVER=http://withcache-server:8081
|
|
160
160
|
export PATH="$HOME/.withcache/bin:$PATH"
|
|
161
161
|
EOF
|
|
162
162
|
```
|
|
@@ -168,7 +168,7 @@ inside that directory.
|
|
|
168
168
|
|
|
169
169
|
```sh
|
|
170
170
|
# .envrc
|
|
171
|
-
export WITHCACHE_SERVER=http://withcache-server:
|
|
171
|
+
export WITHCACHE_SERVER=http://withcache-server:8081
|
|
172
172
|
PATH_add ~/.withcache/bin # assumes the symlinks from approach 2/3 exist
|
|
173
173
|
```
|
|
174
174
|
|
|
@@ -186,7 +186,7 @@ sudo ln -sf "$(command -v wgetwithcache)" /usr/local/bin/wget
|
|
|
186
186
|
|
|
187
187
|
# A login-shell env file (covers interactive logins; daemons started outside a
|
|
188
188
|
# login shell won't see it; set WITHCACHE_SERVER in their unit if you need it).
|
|
189
|
-
echo 'export WITHCACHE_SERVER=http://withcache-server:
|
|
189
|
+
echo 'export WITHCACHE_SERVER=http://withcache-server:8081' \
|
|
190
190
|
| sudo tee /etc/profile.d/withcache.sh >/dev/null
|
|
191
191
|
```
|
|
192
192
|
|
|
@@ -219,7 +219,7 @@ Notes & limits (all degrade gracefully; worst case is "no caching, curl still wo
|
|
|
219
219
|
|
|
220
220
|
## Operator UI
|
|
221
221
|
|
|
222
|
-
`http://withcache-server:
|
|
222
|
+
`http://withcache-server:8081/` (Pico.css + HTMX, bundled offline) shows:
|
|
223
223
|
- **Misses**: auto-fetched by default, or (under `--curate`) each with **Download** (queues a background pull) and **Dismiss**.
|
|
224
224
|
- **Downloads**: live progress bars, `queued/running/completed/cancelled/failed`, **Cancel**, and **Clear finished**. Downloads run in a background worker pool, not in the request, so large pulls never block, modelled on [bty]'s job managers.
|
|
225
225
|
- **Cached artifacts**: URL, size, **hits** (times served) and **misses** (times requested before it was cached), SHA-256, fetched-at, each with **Delete** to free space.
|
|
@@ -258,7 +258,7 @@ scheme. `withcache.client` is stdlib-only, so importing it adds no dependencies:
|
|
|
258
258
|
from withcache import client
|
|
259
259
|
|
|
260
260
|
# "use the cache when it's warm, the origin otherwise"
|
|
261
|
-
url = client.serve_url("http://cache:
|
|
261
|
+
url = client.serve_url("http://cache:8081", origin) or origin
|
|
262
262
|
```
|
|
263
263
|
|
|
264
264
|
`is_cached()` is a graceful `HEAD` (a miss, timeout, or unreachable cache all
|
|
@@ -17,7 +17,7 @@ RUN useradd --create-home --uid 10001 app \
|
|
|
17
17
|
&& mkdir -p /data && chown app:app /data
|
|
18
18
|
USER app
|
|
19
19
|
|
|
20
|
-
EXPOSE
|
|
20
|
+
EXPOSE 8081
|
|
21
21
|
VOLUME ["/data"]
|
|
22
22
|
|
|
23
23
|
# Set WITHCACHE_ADMIN_PASSWORD at run time to protect the operator UI.
|
|
@@ -25,6 +25,6 @@ VOLUME ["/data"]
|
|
|
25
25
|
# with WITHCACHE_SESSION_SECRET.
|
|
26
26
|
|
|
27
27
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
|
|
28
|
-
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:
|
|
28
|
+
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8081/healthz',timeout=2).status==200 else 1)"
|
|
29
29
|
|
|
30
|
-
ENTRYPOINT ["withcache-server", "--host", "0.0.0.0", "--port", "
|
|
30
|
+
ENTRYPOINT ["withcache-server", "--host", "0.0.0.0", "--port", "8081", "--data-dir", "/data"]
|
|
@@ -7,7 +7,7 @@ name = "withcache"
|
|
|
7
7
|
dynamic = ["version"] # single source of truth: src/withcache/__init__.py:__version__
|
|
8
8
|
description = "Operator-curated, URL-keyed artifact cache for a small lab (CUDA/ROCm/DOCA/firmware)"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
11
|
license = { text = "BSD-3-Clause" }
|
|
12
12
|
authors = [{ name = "Simon A. F. Lund", email = "safl@safl.dk" }]
|
|
13
13
|
keywords = ["cache", "artifacts", "cuda", "rocm", "doca", "firmware", "lab"]
|
|
@@ -45,7 +45,7 @@ include = ["src/withcache", "shim", "README.md", "LICENSE", "deploy", "tests"]
|
|
|
45
45
|
|
|
46
46
|
[tool.ruff]
|
|
47
47
|
line-length = 100
|
|
48
|
-
target-version = "
|
|
48
|
+
target-version = "py311"
|
|
49
49
|
|
|
50
50
|
[tool.ruff.lint]
|
|
51
51
|
select = ["E", "F", "W", "I", "UP", "B"]
|
|
@@ -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.7.0",
|
|
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.
|
|
20
|
+
__version__ = "0.7.0"
|
|
21
21
|
|
|
22
22
|
__all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
|
|
@@ -26,7 +26,7 @@ _SCHEME = re.compile(r"^[a-zA-Z][a-zA-Z0-9+.\-]*://")
|
|
|
26
26
|
|
|
27
27
|
|
|
28
28
|
def cache_base(server: str) -> str:
|
|
29
|
-
"""Accept 'host', 'host:
|
|
29
|
+
"""Accept 'host', 'host:8081', or 'http://withcache-server:8081'."""
|
|
30
30
|
server = server.strip().rstrip("/")
|
|
31
31
|
if "://" not in server:
|
|
32
32
|
server = "http://" + server
|
|
@@ -7,7 +7,7 @@ dependencies.
|
|
|
7
7
|
from withcache import client
|
|
8
8
|
|
|
9
9
|
# "use the cache when it's warm, the origin otherwise"
|
|
10
|
-
url = client.serve_url("http://cache:
|
|
10
|
+
url = client.serve_url("http://cache:8081", origin) or origin
|
|
11
11
|
|
|
12
12
|
The ``/b/<urlsafe-b64(origin)>/<basename>`` encoding is shared with the shims
|
|
13
13
|
and the server (one definition in :mod:`withcache._shim`), so consumers stay in
|
|
@@ -25,7 +25,7 @@ __all__ = ["PROBE_TIMEOUT", "blob_url", "cache_base", "is_cached", "serve_url"]
|
|
|
25
25
|
|
|
26
26
|
PROBE_TIMEOUT = 3.0 # seconds; never block the caller on a slow/unreachable cache
|
|
27
27
|
|
|
28
|
-
#: Normalize a server value: accepts 'host', 'host:
|
|
28
|
+
#: Normalize a server value: accepts 'host', 'host:8081', or 'http://host:8081'.
|
|
29
29
|
cache_base = _shim.cache_base
|
|
30
30
|
|
|
31
31
|
|
|
@@ -7,7 +7,7 @@ withcache cache-host and the artifact is cached there, the download is served
|
|
|
7
7
|
from the cache; otherwise — server unset, not cached, or unreachable — your curl
|
|
8
8
|
runs exactly as written. Existing scripts need no changes.
|
|
9
9
|
|
|
10
|
-
export WITHCACHE_SERVER=http://withcache-server:
|
|
10
|
+
export WITHCACHE_SERVER=http://withcache-server:8081
|
|
11
11
|
curl -fsSL https://the/origin/cuda.tar.gz -o cuda.tar.gz # cache hit -> local
|
|
12
12
|
|
|
13
13
|
It wraps the system curl, so all curl flags keep working; on a miss it hands
|
|
@@ -37,11 +37,13 @@ import socketserver
|
|
|
37
37
|
import sqlite3
|
|
38
38
|
import threading
|
|
39
39
|
import time
|
|
40
|
+
import tomllib
|
|
41
|
+
import urllib.error
|
|
40
42
|
import urllib.parse
|
|
41
43
|
import urllib.request
|
|
42
44
|
from dataclasses import dataclass, field
|
|
43
|
-
from datetime import
|
|
44
|
-
from typing import ClassVar
|
|
45
|
+
from datetime import UTC, datetime
|
|
46
|
+
from typing import Any, ClassVar
|
|
45
47
|
|
|
46
48
|
from . import __version__, oras
|
|
47
49
|
|
|
@@ -70,7 +72,7 @@ _DB_WRITE_LOCK = threading.Lock()
|
|
|
70
72
|
|
|
71
73
|
|
|
72
74
|
def now_iso() -> str:
|
|
73
|
-
return datetime.now(
|
|
75
|
+
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
74
76
|
|
|
75
77
|
|
|
76
78
|
def _age_human(started_at: float, *, now: float | None = None) -> str:
|
|
@@ -147,6 +149,84 @@ def resolve_secret(data_dir: str) -> bytes:
|
|
|
147
149
|
return secret
|
|
148
150
|
|
|
149
151
|
|
|
152
|
+
DEFAULT_CATALOG_URL = "https://github.com/safl/nosi/releases/latest/download/catalog.toml"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@dataclass
|
|
156
|
+
class CatalogState:
|
|
157
|
+
"""Live state of the fetched image catalog.
|
|
158
|
+
|
|
159
|
+
withcache does not run a background poller. The catalog is
|
|
160
|
+
fetched at process start (best-effort, persisted result from
|
|
161
|
+
the last successful fetch survives a restart), refetched
|
|
162
|
+
lazily when the dashboard renders and the in-memory copy is
|
|
163
|
+
older than :data:`CATALOG_CACHE_TTL`, and force-refetched by
|
|
164
|
+
the operator via the Refresh button (POST /admin/catalog_refresh).
|
|
165
|
+
|
|
166
|
+
The raw TOML bytes are persisted to ``<data_dir>/catalog.toml``
|
|
167
|
+
so a restart doesn't wipe the last known good catalog and the
|
|
168
|
+
same file can be re-served verbatim to consumers on a future
|
|
169
|
+
``GET /catalog.toml`` route.
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
url: str
|
|
173
|
+
persist_path: str
|
|
174
|
+
entries: list[dict[str, Any]] = field(default_factory=list)
|
|
175
|
+
fetched_at: str = ""
|
|
176
|
+
last_error: str = ""
|
|
177
|
+
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
|
178
|
+
|
|
179
|
+
def load_persisted(self) -> None:
|
|
180
|
+
"""Best-effort: seed ``entries`` + ``fetched_at`` from the on-disk
|
|
181
|
+
``catalog.toml`` if it exists. Never raises; a corrupt or missing
|
|
182
|
+
file leaves the state empty so the operator sees the "not
|
|
183
|
+
fetched yet" hint in the dashboard."""
|
|
184
|
+
if not os.path.isfile(self.persist_path):
|
|
185
|
+
return
|
|
186
|
+
try:
|
|
187
|
+
with open(self.persist_path, "rb") as f:
|
|
188
|
+
raw = f.read()
|
|
189
|
+
parsed = tomllib.loads(raw.decode("utf-8"))
|
|
190
|
+
mtime = os.path.getmtime(self.persist_path)
|
|
191
|
+
self.entries = list(parsed.get("images") or [])
|
|
192
|
+
self.fetched_at = datetime.fromtimestamp(mtime, UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
193
|
+
except (OSError, ValueError, UnicodeDecodeError, tomllib.TOMLDecodeError) as e:
|
|
194
|
+
self.last_error = f"failed to load {self.persist_path}: {e}"
|
|
195
|
+
|
|
196
|
+
def fetch_now(self, *, timeout: float = 15.0) -> None:
|
|
197
|
+
"""Fetch the catalog URL, parse, persist raw bytes, populate
|
|
198
|
+
``entries``. On failure the previously-cached entries remain;
|
|
199
|
+
the error is recorded so the tab can surface it. Single-writer
|
|
200
|
+
via ``self._lock`` so a burst of clicks doesn't double-fetch."""
|
|
201
|
+
with self._lock:
|
|
202
|
+
try:
|
|
203
|
+
req = urllib.request.Request(self.url, headers={"User-Agent": USER_AGENT})
|
|
204
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
205
|
+
raw = resp.read()
|
|
206
|
+
# Validate before persisting -- a 500 from upstream that
|
|
207
|
+
# returns HTML must not clobber a previously-good
|
|
208
|
+
# catalog.toml on disk.
|
|
209
|
+
parsed = tomllib.loads(raw.decode("utf-8"))
|
|
210
|
+
entries = list(parsed.get("images") or [])
|
|
211
|
+
# Atomic write: tempfile + rename so a crash mid-write
|
|
212
|
+
# never leaves a half-written catalog.toml.
|
|
213
|
+
tmp = self.persist_path + ".tmp"
|
|
214
|
+
with open(tmp, "wb") as f:
|
|
215
|
+
f.write(raw)
|
|
216
|
+
os.replace(tmp, self.persist_path)
|
|
217
|
+
self.entries = entries
|
|
218
|
+
self.fetched_at = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
219
|
+
self.last_error = ""
|
|
220
|
+
except (
|
|
221
|
+
urllib.error.URLError,
|
|
222
|
+
OSError,
|
|
223
|
+
ValueError,
|
|
224
|
+
UnicodeDecodeError,
|
|
225
|
+
tomllib.TOMLDecodeError,
|
|
226
|
+
) as e:
|
|
227
|
+
self.last_error = str(e)
|
|
228
|
+
|
|
229
|
+
|
|
150
230
|
class Auth:
|
|
151
231
|
COOKIE = "withcache-token"
|
|
152
232
|
MAX_AGE = 7 * 24 * 3600 # cookie lifetime, seconds
|
|
@@ -745,6 +825,10 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
745
825
|
def streams(self) -> StreamRegistry:
|
|
746
826
|
return self.server.streams # type: ignore[attr-defined]
|
|
747
827
|
|
|
828
|
+
@property
|
|
829
|
+
def catalog(self) -> CatalogState:
|
|
830
|
+
return self.server.catalog # type: ignore[attr-defined]
|
|
831
|
+
|
|
748
832
|
def log_message(self, format, *args): # quieter, single-line
|
|
749
833
|
print(f"{self.address_string()} - {format % args}", flush=True)
|
|
750
834
|
|
|
@@ -793,6 +877,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
793
877
|
"/admin/delete",
|
|
794
878
|
"/admin/cancel",
|
|
795
879
|
"/admin/clear",
|
|
880
|
+
"/admin/catalog_refresh",
|
|
796
881
|
)
|
|
797
882
|
|
|
798
883
|
def do_POST(self):
|
|
@@ -820,6 +905,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
820
905
|
self.mgr.cancel(int(jid))
|
|
821
906
|
elif parsed.path == "/admin/clear":
|
|
822
907
|
self.mgr.clear_finished()
|
|
908
|
+
elif parsed.path == "/admin/catalog_refresh":
|
|
909
|
+
self.catalog.fetch_now()
|
|
823
910
|
self.respond_admin()
|
|
824
911
|
else:
|
|
825
912
|
self.send_text(404, "not found\n")
|
|
@@ -1354,6 +1441,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1354
1441
|
nstreams = len(streams)
|
|
1355
1442
|
njobs = len(jobs)
|
|
1356
1443
|
|
|
1444
|
+
catalog_rows = self._catalog_rows()
|
|
1445
|
+
|
|
1357
1446
|
return f"""
|
|
1358
1447
|
<p class="text-muted small mb-2">{nblobs} cached ({used}){full}
|
|
1359
1448
|
· {nmisses} pending miss(es)</p>
|
|
@@ -1367,6 +1456,8 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1367
1456
|
href="#tab-downloads">Downloads ({njobs})</a></li>
|
|
1368
1457
|
<li class="nav-item"><a class="nav-link {_active("tab-misses").lstrip()}"
|
|
1369
1458
|
href="#tab-misses">Misses ({nmisses})</a></li>
|
|
1459
|
+
<li class="nav-item"><a class="nav-link {_active("tab-catalog").lstrip()}"
|
|
1460
|
+
href="#tab-catalog">Catalog ({len(self.catalog.entries)})</a></li>
|
|
1370
1461
|
</ul>
|
|
1371
1462
|
</nav>
|
|
1372
1463
|
|
|
@@ -1412,8 +1503,80 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
|
|
1412
1503
|
</tr></thead>
|
|
1413
1504
|
<tbody>{miss_rows}</tbody>
|
|
1414
1505
|
</table></div>
|
|
1506
|
+
</section>
|
|
1507
|
+
|
|
1508
|
+
<section id="tab-catalog" class="tab{_active("tab-catalog")}">
|
|
1509
|
+
<div class="d-flex align-items-center justify-content-between mb-2 flex-wrap gap-2">
|
|
1510
|
+
<div>
|
|
1511
|
+
<div><small class="text-muted">source</small>
|
|
1512
|
+
<code>{html.escape(self.catalog.url)}</code></div>
|
|
1513
|
+
<div><small class="text-muted">fetched</small>
|
|
1514
|
+
<code>{html.escape(self.catalog.fetched_at) or "never"}</code>
|
|
1515
|
+
{
|
|
1516
|
+
(
|
|
1517
|
+
f'<span class="badge bg-danger-subtle text-danger ms-1">'
|
|
1518
|
+
f"{html.escape(self.catalog.last_error)}</span>"
|
|
1519
|
+
)
|
|
1520
|
+
if self.catalog.last_error
|
|
1521
|
+
else ""
|
|
1522
|
+
}</div>
|
|
1523
|
+
</div>
|
|
1524
|
+
<form hx-post="/admin/catalog_refresh" hx-target="#dash" hx-swap="innerHTML"
|
|
1525
|
+
hx-indicator="#spin" class="m-0">
|
|
1526
|
+
<button class="btn btn-sm btn-primary" type="submit">Refresh</button>
|
|
1527
|
+
</form>
|
|
1528
|
+
</div>
|
|
1529
|
+
<div class="table-responsive"><table class="table table-sm table-striped table-hover mb-0">
|
|
1530
|
+
<thead class="table-light"><tr>
|
|
1531
|
+
<th>Name</th><th>Format</th><th>Arch</th><th>Size</th><th>Source</th>
|
|
1532
|
+
</tr></thead>
|
|
1533
|
+
<tbody>{catalog_rows}</tbody>
|
|
1534
|
+
</table></div>
|
|
1415
1535
|
</section>"""
|
|
1416
1536
|
|
|
1537
|
+
def _catalog_rows(self) -> str:
|
|
1538
|
+
"""One <tr> per catalog image. Cells: name, format, arch,
|
|
1539
|
+
human-readable size, and a Source cell whose URL is the
|
|
1540
|
+
withcache /b/ token (so a click through pulls via cache).
|
|
1541
|
+
Empty catalog rows to a "not fetched yet" placeholder + a
|
|
1542
|
+
hint about the Refresh button."""
|
|
1543
|
+
entries = self.catalog.entries
|
|
1544
|
+
if not entries:
|
|
1545
|
+
hint = self.catalog.last_error or "click Refresh above to fetch the catalog manifest"
|
|
1546
|
+
return (
|
|
1547
|
+
'<tr><td colspan="5" class="text-center text-muted">'
|
|
1548
|
+
f"<em>{html.escape(hint)}</em></td></tr>"
|
|
1549
|
+
)
|
|
1550
|
+
rows: list[str] = []
|
|
1551
|
+
for entry in entries:
|
|
1552
|
+
name = str(entry.get("name") or "")
|
|
1553
|
+
src = str(entry.get("src") or "")
|
|
1554
|
+
fmt = str(entry.get("format") or "")
|
|
1555
|
+
arch = str(entry.get("arch") or "")
|
|
1556
|
+
size_bytes = entry.get("size_bytes")
|
|
1557
|
+
size = human_size(int(size_bytes)) if isinstance(size_bytes, int) else ""
|
|
1558
|
+
# Route the Source column via /b/<token>/<name> so a
|
|
1559
|
+
# click serves through withcache -- first hit fills the
|
|
1560
|
+
# cache, subsequent hits serve from disk. Mirrors the
|
|
1561
|
+
# blob-row rendering above.
|
|
1562
|
+
if src.startswith(("http://", "https://", "oras://")):
|
|
1563
|
+
token = base64.urlsafe_b64encode(src.encode("utf-8")).decode("ascii").rstrip("=")
|
|
1564
|
+
filename = urllib.parse.urlsplit(src).path.rsplit("/", 1)[-1] or "download"
|
|
1565
|
+
link = f"/b/{token}/{urllib.parse.quote(filename)}"
|
|
1566
|
+
src_cell = f'<a href="{link}" title="{html.escape(src)}">{html.escape(src)}</a>'
|
|
1567
|
+
else:
|
|
1568
|
+
src_cell = html.escape(src)
|
|
1569
|
+
rows.append(
|
|
1570
|
+
f"<tr>"
|
|
1571
|
+
f"<td>{html.escape(name)}</td>"
|
|
1572
|
+
f'<td class="mono"><small>{html.escape(fmt)}</small></td>'
|
|
1573
|
+
f'<td class="mono"><small>{html.escape(arch)}</small></td>'
|
|
1574
|
+
f"<td>{html.escape(size)}</td>"
|
|
1575
|
+
f'<td class="url">{src_cell}</td>'
|
|
1576
|
+
"</tr>"
|
|
1577
|
+
)
|
|
1578
|
+
return "".join(rows)
|
|
1579
|
+
|
|
1417
1580
|
def _stream_progress_cell(self, s: Stream) -> str:
|
|
1418
1581
|
"""One progress cell for an active stream: a <progress> bar when the
|
|
1419
1582
|
total is known (always for a cached blob, since the size came off
|
|
@@ -1471,7 +1634,7 @@ class ThreadingHTTPServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
|
|
1471
1634
|
def main():
|
|
1472
1635
|
ap = argparse.ArgumentParser(description="withcache cache-host")
|
|
1473
1636
|
ap.add_argument("--host", default="0.0.0.0")
|
|
1474
|
-
ap.add_argument("--port", type=int, default=
|
|
1637
|
+
ap.add_argument("--port", type=int, default=8081)
|
|
1475
1638
|
ap.add_argument("--data-dir", default="./data")
|
|
1476
1639
|
ap.add_argument(
|
|
1477
1640
|
"--keep-query",
|
|
@@ -1506,6 +1669,25 @@ def main():
|
|
|
1506
1669
|
httpd.mgr = mgr # type: ignore[attr-defined]
|
|
1507
1670
|
httpd.auto_fetch = not args.curate # type: ignore[attr-defined]
|
|
1508
1671
|
httpd.streams = StreamRegistry() # type: ignore[attr-defined]
|
|
1672
|
+
# Catalog state: env override, else the shipping default (nosi's
|
|
1673
|
+
# rolling catalog manifest). Seeded from the last persisted
|
|
1674
|
+
# catalog.toml on disk so a restart doesn't wipe the cache.
|
|
1675
|
+
catalog_url = (os.environ.get("WITHCACHE_CATALOG_URL") or DEFAULT_CATALOG_URL).strip()
|
|
1676
|
+
catalog = CatalogState(
|
|
1677
|
+
url=catalog_url,
|
|
1678
|
+
persist_path=os.path.join(store.data_dir, "catalog.toml"),
|
|
1679
|
+
)
|
|
1680
|
+
catalog.load_persisted()
|
|
1681
|
+
httpd.catalog = catalog # type: ignore[attr-defined]
|
|
1682
|
+
# If no catalog is persisted yet, kick a single startup fetch
|
|
1683
|
+
# in a daemon thread so the operator has something to look at
|
|
1684
|
+
# without needing to click Refresh on their first visit.
|
|
1685
|
+
# Failures record ``last_error`` in memory; the dashboard row
|
|
1686
|
+
# shows it. Never blocks the serve loop.
|
|
1687
|
+
if not catalog.entries:
|
|
1688
|
+
threading.Thread(
|
|
1689
|
+
target=catalog.fetch_now, name="withcache-catalog-init", daemon=True
|
|
1690
|
+
).start()
|
|
1509
1691
|
print(
|
|
1510
1692
|
f"withcache cache-host on http://{args.host}:{args.port} "
|
|
1511
1693
|
f"(data={store.data_dir}, keep_query={args.keep_query}, workers={args.workers}, "
|
|
@@ -6,7 +6,7 @@ The wget sibling of curlwithcache. Drop it on $PATH ahead of the real wget
|
|
|
6
6
|
cache-host and the artifact is cached there, the download is served from the
|
|
7
7
|
cache; otherwise your wget runs exactly as written.
|
|
8
8
|
|
|
9
|
-
export WITHCACHE_SERVER=http://withcache-server:
|
|
9
|
+
export WITHCACHE_SERVER=http://withcache-server:8081
|
|
10
10
|
wget https://the/origin/cuda.tar.gz # cache hit -> local, named cuda.tar.gz
|
|
11
11
|
|
|
12
12
|
Because the cache URL is path-encoded with the real basename, a bare ``wget
|
|
@@ -64,7 +64,7 @@ class TestDifferential(unittest.TestCase):
|
|
|
64
64
|
"WGETWITHCACHE_SERVER",
|
|
65
65
|
)
|
|
66
66
|
}
|
|
67
|
-
os.environ["WITHCACHE_SERVER"] = "http://cache:
|
|
67
|
+
os.environ["WITHCACHE_SERVER"] = "http://cache:8081"
|
|
68
68
|
os.environ["REAL_CURL"] = self.fake_hit # Python find_real just needs an exe
|
|
69
69
|
os.environ["REAL_WGET"] = self.fake_hit
|
|
70
70
|
os.environ.pop("CURLWITHCACHE_SERVER", None)
|
|
@@ -481,8 +481,8 @@ class TestAgeHuman(unittest.TestCase):
|
|
|
481
481
|
# --------------------------------------------------------------------------
|
|
482
482
|
class TestShim(unittest.TestCase):
|
|
483
483
|
def test_cache_base(self):
|
|
484
|
-
self.assertEqual(_shim.cache_base("box:
|
|
485
|
-
self.assertEqual(_shim.cache_base("https://box:
|
|
484
|
+
self.assertEqual(_shim.cache_base("box:8081"), "http://box:8081")
|
|
485
|
+
self.assertEqual(_shim.cache_base("https://box:8081/"), "https://box:8081")
|
|
486
486
|
|
|
487
487
|
def test_find_url_bare(self):
|
|
488
488
|
self.assertEqual(
|
|
@@ -540,11 +540,11 @@ class TestShim(unittest.TestCase):
|
|
|
540
540
|
for k in ("WITHCACHE_SERVER", "CURLWITHCACHE_SERVER", "WGETWITHCACHE_SERVER")
|
|
541
541
|
}
|
|
542
542
|
try:
|
|
543
|
-
os.environ["WITHCACHE_SERVER"] = "http://shared:
|
|
544
|
-
os.environ["CURLWITHCACHE_SERVER"] = "http://curl-only:
|
|
543
|
+
os.environ["WITHCACHE_SERVER"] = "http://shared:8081"
|
|
544
|
+
os.environ["CURLWITHCACHE_SERVER"] = "http://curl-only:8081"
|
|
545
545
|
os.environ.pop("WGETWITHCACHE_SERVER", None)
|
|
546
|
-
self.assertEqual(_shim.env_server("curl"), "http://curl-only:
|
|
547
|
-
self.assertEqual(_shim.env_server("wget"), "http://shared:
|
|
546
|
+
self.assertEqual(_shim.env_server("curl"), "http://curl-only:8081")
|
|
547
|
+
self.assertEqual(_shim.env_server("wget"), "http://shared:8081")
|
|
548
548
|
finally:
|
|
549
549
|
for k, v in saved.items():
|
|
550
550
|
if v is None:
|
|
@@ -593,22 +593,22 @@ class TestShimPlan(unittest.TestCase):
|
|
|
593
593
|
os.environ.pop(k, None) if v is None else os.environ.__setitem__(k, v)
|
|
594
594
|
|
|
595
595
|
def test_hit_rewrites_only_the_url(self):
|
|
596
|
-
os.environ["WITHCACHE_SERVER"] = "http://cache:
|
|
596
|
+
os.environ["WITHCACHE_SERVER"] = "http://cache:8081"
|
|
597
597
|
argv = ["-fsSL", "https://h/p/cuda.tar.gz", "-o", "out"]
|
|
598
598
|
real, final = _shim.plan("curl", lambda r, u: True, argv)
|
|
599
599
|
self.assertEqual(real, self.dummy)
|
|
600
600
|
self.assertEqual([final[0], final[2], final[3]], ["-fsSL", "-o", "out"])
|
|
601
|
-
self.assertTrue(final[1].startswith("http://cache:
|
|
601
|
+
self.assertTrue(final[1].startswith("http://cache:8081/b/"))
|
|
602
602
|
self.assertTrue(final[1].endswith("/cuda.tar.gz"))
|
|
603
603
|
|
|
604
604
|
def test_miss_leaves_argv_untouched(self):
|
|
605
|
-
os.environ["WITHCACHE_SERVER"] = "http://cache:
|
|
605
|
+
os.environ["WITHCACHE_SERVER"] = "http://cache:8081"
|
|
606
606
|
argv = ["https://h/x", "-O"]
|
|
607
607
|
_, final = _shim.plan("curl", lambda r, u: False, argv)
|
|
608
608
|
self.assertEqual(final, argv)
|
|
609
609
|
|
|
610
610
|
def test_unreachable_leaves_argv_untouched(self):
|
|
611
|
-
os.environ["WITHCACHE_SERVER"] = "http://cache:
|
|
611
|
+
os.environ["WITHCACHE_SERVER"] = "http://cache:8081"
|
|
612
612
|
argv = ["https://h/x"]
|
|
613
613
|
_, final = _shim.plan("curl", lambda r, u: None, argv)
|
|
614
614
|
self.assertEqual(final, argv)
|
|
@@ -643,6 +643,14 @@ def _start_withcache(auto_fetch=False):
|
|
|
643
643
|
httpd.mgr = server.DownloadManager(store, workers=1)
|
|
644
644
|
httpd.auto_fetch = auto_fetch
|
|
645
645
|
httpd.streams = server.StreamRegistry()
|
|
646
|
+
# Empty catalog with a bogus URL so the tab renders "click Refresh
|
|
647
|
+
# above to fetch the catalog manifest" instead of firing a real
|
|
648
|
+
# HTTP call. Tests that specifically exercise the catalog code
|
|
649
|
+
# can override this attribute after the helper returns.
|
|
650
|
+
httpd.catalog = server.CatalogState(
|
|
651
|
+
url="http://localhost/catalog.toml",
|
|
652
|
+
persist_path=os.path.join(store.data_dir, "catalog.toml"),
|
|
653
|
+
)
|
|
646
654
|
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
|
647
655
|
return httpd, store
|
|
648
656
|
|
|
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
|