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.
Files changed (26) hide show
  1. {withcache-0.6.4 → withcache-0.7.0}/PKG-INFO +11 -11
  2. {withcache-0.6.4 → withcache-0.7.0}/README.md +9 -9
  3. {withcache-0.6.4 → withcache-0.7.0}/deploy/Containerfile +3 -3
  4. {withcache-0.6.4 → withcache-0.7.0}/deploy/compose.yml +1 -1
  5. {withcache-0.6.4 → withcache-0.7.0}/pyproject.toml +2 -2
  6. {withcache-0.6.4 → withcache-0.7.0}/shim/build.zig.zon +1 -1
  7. {withcache-0.6.4 → withcache-0.7.0}/src/withcache/__init__.py +1 -1
  8. {withcache-0.6.4 → withcache-0.7.0}/src/withcache/_shim.py +1 -1
  9. {withcache-0.6.4 → withcache-0.7.0}/src/withcache/client.py +2 -2
  10. {withcache-0.6.4 → withcache-0.7.0}/src/withcache/curlwithcache.py +1 -1
  11. {withcache-0.6.4 → withcache-0.7.0}/src/withcache/server.py +186 -4
  12. {withcache-0.6.4 → withcache-0.7.0}/src/withcache/wgetwithcache.py +1 -1
  13. {withcache-0.6.4 → withcache-0.7.0}/tests/test_differential.py +1 -1
  14. {withcache-0.6.4 → withcache-0.7.0}/tests/test_withcache.py +18 -10
  15. {withcache-0.6.4 → withcache-0.7.0}/.gitignore +0 -0
  16. {withcache-0.6.4 → withcache-0.7.0}/LICENSE +0 -0
  17. {withcache-0.6.4 → withcache-0.7.0}/hatch_build.py +0 -0
  18. {withcache-0.6.4 → withcache-0.7.0}/shim/build.zig +0 -0
  19. {withcache-0.6.4 → withcache-0.7.0}/shim/shim.zig +0 -0
  20. {withcache-0.6.4 → withcache-0.7.0}/src/withcache/oras.py +0 -0
  21. {withcache-0.6.4 → withcache-0.7.0}/src/withcache/static/bootstrap-icons.min.css +0 -0
  22. {withcache-0.6.4 → withcache-0.7.0}/src/withcache/static/bootstrap.min.css +0 -0
  23. {withcache-0.6.4 → withcache-0.7.0}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  24. {withcache-0.6.4 → withcache-0.7.0}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  25. {withcache-0.6.4 → withcache-0.7.0}/src/withcache/static/htmx.min.js +0 -0
  26. {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.6.4
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.10
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:3000/
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 3000
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:3000
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:3000
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:3000
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:3000
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:3000' \
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:3000/` (Pico.css + HTMX, bundled offline) shows:
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:3000", origin) or origin
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:3000/
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 3000
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:3000
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:3000
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:3000
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:3000
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:3000' \
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:3000/` (Pico.css + HTMX, bundled offline) shows:
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:3000", origin) or origin
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 3000
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:3000/healthz',timeout=2).status==200 else 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", "3000", "--data-dir", "/data"]
30
+ ENTRYPOINT ["withcache-server", "--host", "0.0.0.0", "--port", "8081", "--data-dir", "/data"]
@@ -9,7 +9,7 @@ services:
9
9
  image: withcache:latest
10
10
  container_name: withcache
11
11
  ports:
12
- - "3000:3000"
12
+ - "8081:8081"
13
13
  environment:
14
14
  # Protect the operator UI. Unset => UI is open (a warning is logged).
15
15
  - WITHCACHE_ADMIN_PASSWORD=${WITHCACHE_ADMIN_PASSWORD:-}
@@ -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"
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 = "py310"
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.6.4",
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.6.4"
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:3000', or 'http://withcache-server:3000'."""
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:3000", origin) or origin
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:3000', or 'http://host:3000'.
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:3000
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 datetime, timezone
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(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
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
  &middot; {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
+ &nbsp;<code>{html.escape(self.catalog.url)}</code></div>
1513
+ <div><small class="text-muted">fetched</small>
1514
+ &nbsp;<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=3000)
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:3000
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:3000"
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:3000"), "http://box:3000")
485
- self.assertEqual(_shim.cache_base("https://box:3000/"), "https://box:3000")
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:3000"
544
- os.environ["CURLWITHCACHE_SERVER"] = "http://curl-only:3000"
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:3000")
547
- self.assertEqual(_shim.env_server("wget"), "http://shared:3000")
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:3000"
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:3000/b/"))
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:3000"
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:3000"
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