withcache 0.9.0__tar.gz → 0.9.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.9.0 → withcache-0.9.1}/PKG-INFO +1 -1
- {withcache-0.9.0 → withcache-0.9.1}/shim/build.zig.zon +1 -1
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/__init__.py +1 -1
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_api.py +159 -11
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_app.py +35 -34
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_settings_store.py +4 -4
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_templates/ui/cached.html +2 -2
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_templates/ui/misses.html +2 -2
- withcache-0.9.1/src/withcache/client.py +237 -0
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/server.py +27 -16
- {withcache-0.9.0 → withcache-0.9.1}/tests/test_fastapi_admin_forms.py +2 -2
- {withcache-0.9.0 → withcache-0.9.1}/tests/test_fastapi_blob.py +2 -2
- withcache-0.9.1/tests/test_fastapi_catalog_api.py +249 -0
- {withcache-0.9.0 → withcache-0.9.1}/tests/test_fastapi_scaffold.py +2 -6
- {withcache-0.9.0 → withcache-0.9.1}/tests/test_fastapi_settings_persistence.py +2 -2
- {withcache-0.9.0 → withcache-0.9.1}/tests/test_fastapi_ui_pages.py +2 -2
- {withcache-0.9.0 → withcache-0.9.1}/tests/test_withcache.py +9 -26
- withcache-0.9.0/src/withcache/client.py +0 -89
- {withcache-0.9.0 → withcache-0.9.1}/.gitignore +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/LICENSE +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/README.md +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/deploy/Containerfile +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/deploy/compose.local-build.yml +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/deploy/compose.yml +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/deploy/envvars.example +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/hatch_build.py +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/pyproject.toml +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/shim/build.zig +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/shim/shim.zig +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_shim.py +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_templates/ui/_layout.html +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_templates/ui/catalog.html +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_templates/ui/downloads.html +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_templates/ui/login.html +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/_templates/ui/settings.html +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/curlwithcache.py +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/oras.py +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/static/bootstrap-icons.min.css +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/static/bootstrap.min.css +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/src/withcache/wgetwithcache.py +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/tests/test_differential.py +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/tests/test_fastapi_uvicorn_smoke.py +0 -0
- {withcache-0.9.0 → withcache-0.9.1}/tests/test_oras.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: withcache
|
|
3
|
-
Version: 0.9.
|
|
3
|
+
Version: 0.9.1
|
|
4
4
|
Summary: Operator-curated, URL-keyed artifact cache for a small lab (CUDA/ROCm/DOCA/firmware)
|
|
5
5
|
Project-URL: Homepage, https://github.com/safl/withcache
|
|
6
6
|
Author-email: "Simon A. F. Lund" <safl@safl.dk>
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
.name = .withcache_shim,
|
|
3
3
|
// Zig requires a literal here; keep it in lockstep with the project's
|
|
4
4
|
// single source (src/withcache/__init__.py) via `make bump` / `make version-check`.
|
|
5
|
-
.version = "0.9.
|
|
5
|
+
.version = "0.9.1",
|
|
6
6
|
.fingerprint = 0xd7d96c5ed212ccaa,
|
|
7
7
|
.minimum_zig_version = "0.16.0",
|
|
8
8
|
.paths = .{
|
|
@@ -22,11 +22,9 @@ stay byte-identical:
|
|
|
22
22
|
the worker with the same credential.
|
|
23
23
|
|
|
24
24
|
Streaming shape: FastAPI ``StreamingResponse`` with a generator
|
|
25
|
-
that reads chunks from the on-disk blob
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
:class:`StreamRegistry` progress ticks so the operator dashboard
|
|
29
|
-
shows the same numbers.
|
|
25
|
+
that reads chunks from the on-disk blob at 64 KiB reads;
|
|
26
|
+
:class:`StreamRegistry` ticks progress per chunk so the operator
|
|
27
|
+
Downloads page shows in-flight transfers as they run.
|
|
30
28
|
"""
|
|
31
29
|
|
|
32
30
|
from __future__ import annotations
|
|
@@ -34,11 +32,12 @@ from __future__ import annotations
|
|
|
34
32
|
import base64
|
|
35
33
|
import urllib.parse
|
|
36
34
|
from collections.abc import Iterator
|
|
35
|
+
from typing import Any
|
|
37
36
|
|
|
38
|
-
from fastapi import FastAPI, Request
|
|
39
|
-
from fastapi.responses import PlainTextResponse, Response, StreamingResponse
|
|
37
|
+
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
38
|
+
from fastapi.responses import JSONResponse, PlainTextResponse, Response, StreamingResponse
|
|
40
39
|
|
|
41
|
-
from .server import CHUNK, StreamRegistry, _oras_tag_moved
|
|
40
|
+
from .server import CHUNK, CatalogState, StreamRegistry, _oras_tag_moved, _serialise_catalog
|
|
42
41
|
|
|
43
42
|
|
|
44
43
|
def _decode_blob_origin(path: str, query: str) -> str:
|
|
@@ -68,8 +67,7 @@ def _serve_blob(
|
|
|
68
67
|
|
|
69
68
|
Returns 400 when the origin URL couldn't be decoded, 404 (with
|
|
70
69
|
miss recording + optional fetch enqueue) on cache miss, or 200
|
|
71
|
-
+ a StreamingResponse on hit.
|
|
72
|
-
the pre-port stdlib handler emitted.
|
|
70
|
+
+ a StreamingResponse on hit.
|
|
73
71
|
"""
|
|
74
72
|
if not url:
|
|
75
73
|
return PlainTextResponse("missing url\n", status_code=400)
|
|
@@ -83,7 +81,6 @@ def _serve_blob(
|
|
|
83
81
|
if row is not None and _oras_tag_moved(url, row["sha256"]):
|
|
84
82
|
# Tag re-pushed since we cached it: drop the stale bytes so
|
|
85
83
|
# the miss branch below re-fetches the current content.
|
|
86
|
-
# Same shape as the pre-port handler.
|
|
87
84
|
store.delete_blob(row["key"])
|
|
88
85
|
row = None
|
|
89
86
|
|
|
@@ -180,3 +177,154 @@ def register_api_routes(app: FastAPI) -> None:
|
|
|
180
177
|
del name
|
|
181
178
|
url = _decode_blob_origin(f"/b/{token}/x", "")
|
|
182
179
|
return _serve_blob(request, url, head_only=True)
|
|
180
|
+
|
|
181
|
+
# ---------- Catalog JSON API -----------------------------------------
|
|
182
|
+
#
|
|
183
|
+
# Bty consumes the catalog through these endpoints: withcache is the
|
|
184
|
+
# single source of truth for what images exist. Read is open (mirrors
|
|
185
|
+
# /blob's LAN-only trust model); writes gate on the same Bearer
|
|
186
|
+
# pattern nbdmux uses -- an ``Authorization: Bearer <pw>`` header
|
|
187
|
+
# whose value matches ``$WITHCACHE_ADMIN_PASSWORD``, or a session
|
|
188
|
+
# cookie for browser-based operators.
|
|
189
|
+
|
|
190
|
+
def _bearer_or_session_authed(request: Request) -> None:
|
|
191
|
+
"""Auth dependency for catalog writes.
|
|
192
|
+
|
|
193
|
+
- No password configured -> open (single-tenant LAN deploy).
|
|
194
|
+
- Session cookie present -> OK (browser path).
|
|
195
|
+
- Matching Bearer token -> OK (service-to-service path;
|
|
196
|
+
bty reads ``$WITHCACHE_ADMIN_PASSWORD`` and posts it).
|
|
197
|
+
- Otherwise -> 401.
|
|
198
|
+
"""
|
|
199
|
+
auth = request.app.state.auth
|
|
200
|
+
if not auth.enabled:
|
|
201
|
+
return
|
|
202
|
+
# Session cookie check -- Starlette's SessionMiddleware
|
|
203
|
+
# stores the flag under ``withcache_authed`` in
|
|
204
|
+
# ``request.session``. Mirrors the flag ``_app.py`` sets.
|
|
205
|
+
if request.session.get("withcache_authed"):
|
|
206
|
+
return
|
|
207
|
+
header = request.headers.get("Authorization") or ""
|
|
208
|
+
if header.startswith("Bearer ") and auth.check_bearer(header[len("Bearer ") :]):
|
|
209
|
+
return
|
|
210
|
+
raise HTTPException(status_code=401, detail="auth required")
|
|
211
|
+
|
|
212
|
+
@app.get("/catalog")
|
|
213
|
+
def list_catalog(request: Request) -> JSONResponse:
|
|
214
|
+
"""Return the current catalog as JSON.
|
|
215
|
+
|
|
216
|
+
Open route: bty polls this from a sibling container without a
|
|
217
|
+
session (mirrors the ``/blob`` byte-serving surface's trust
|
|
218
|
+
model -- LAN-only, no auth on reads). Returns a snapshot of
|
|
219
|
+
the in-process ``CatalogState.entries`` plus provenance
|
|
220
|
+
metadata (URL, env pin, fetch timestamps) so clients can
|
|
221
|
+
detect staleness. No pagination -- the catalog is small
|
|
222
|
+
(dozens of entries at most).
|
|
223
|
+
"""
|
|
224
|
+
cs: CatalogState = request.app.state.catalog
|
|
225
|
+
return JSONResponse(
|
|
226
|
+
{
|
|
227
|
+
"url": cs.url,
|
|
228
|
+
"env_url": cs.env_url,
|
|
229
|
+
"fetched_at": cs.fetched_at,
|
|
230
|
+
"last_error": cs.last_error,
|
|
231
|
+
"entries": [dict(e) for e in cs.entries],
|
|
232
|
+
}
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
@app.post("/catalog/entries", status_code=201)
|
|
236
|
+
def add_catalog_entry(
|
|
237
|
+
body: dict[str, Any],
|
|
238
|
+
request: Request,
|
|
239
|
+
_auth: None = Depends(_bearer_or_session_authed),
|
|
240
|
+
) -> JSONResponse:
|
|
241
|
+
"""Insert one catalog entry.
|
|
242
|
+
|
|
243
|
+
Body: ``{"name": "...", "src": "...", "format?", "arch?",
|
|
244
|
+
"sha256?", "size_bytes?", "resolved_src?", "description?"}``.
|
|
245
|
+
Only ``src`` is required; every other field is optional and
|
|
246
|
+
gets persisted through the ``catalog.toml`` round-trip.
|
|
247
|
+
|
|
248
|
+
409 when an entry with the same ``name`` already exists;
|
|
249
|
+
rejecting on duplicate name (not src) because the display
|
|
250
|
+
surface keys on name and two rows with the same name would
|
|
251
|
+
collide on the /ui/catalog table.
|
|
252
|
+
|
|
253
|
+
Rejects any unknown top-level key -- the emitter allowlists
|
|
254
|
+
keys anyway, but failing loud at the API boundary saves the
|
|
255
|
+
operator from wondering why their ``notes`` field vanished.
|
|
256
|
+
"""
|
|
257
|
+
allowed = {
|
|
258
|
+
"name",
|
|
259
|
+
"src",
|
|
260
|
+
"resolved_src",
|
|
261
|
+
"format",
|
|
262
|
+
"arch",
|
|
263
|
+
"sha256",
|
|
264
|
+
"size_bytes",
|
|
265
|
+
"description",
|
|
266
|
+
}
|
|
267
|
+
if not isinstance(body, dict):
|
|
268
|
+
raise HTTPException(status_code=400, detail="body must be a JSON object")
|
|
269
|
+
unknown = set(body.keys()) - allowed
|
|
270
|
+
if unknown:
|
|
271
|
+
raise HTTPException(
|
|
272
|
+
status_code=400,
|
|
273
|
+
detail=f"unknown key(s) {sorted(unknown)}; allowed: {sorted(allowed)}",
|
|
274
|
+
)
|
|
275
|
+
name = body.get("name")
|
|
276
|
+
src = body.get("src")
|
|
277
|
+
if not isinstance(name, str) or not name.strip():
|
|
278
|
+
raise HTTPException(status_code=400, detail="name: non-empty string required")
|
|
279
|
+
if not isinstance(src, str) or not src.strip():
|
|
280
|
+
raise HTTPException(status_code=400, detail="src: non-empty string required")
|
|
281
|
+
|
|
282
|
+
cs: CatalogState = request.app.state.catalog
|
|
283
|
+
if any(e.get("name") == name for e in cs.entries):
|
|
284
|
+
raise HTTPException(
|
|
285
|
+
status_code=409,
|
|
286
|
+
detail=f"catalog entry with name={name!r} already exists",
|
|
287
|
+
)
|
|
288
|
+
entry = {k: v for k, v in body.items() if v is not None and v != ""}
|
|
289
|
+
cs.entries.append(entry)
|
|
290
|
+
_persist_catalog(cs)
|
|
291
|
+
return JSONResponse(entry, status_code=201)
|
|
292
|
+
|
|
293
|
+
@app.delete("/catalog/entries", status_code=204)
|
|
294
|
+
def delete_catalog_entry(
|
|
295
|
+
request: Request,
|
|
296
|
+
name: str | None = None,
|
|
297
|
+
_auth: None = Depends(_bearer_or_session_authed),
|
|
298
|
+
) -> Response:
|
|
299
|
+
"""Delete via ``?name=<name>`` query param (URL-safe;
|
|
300
|
+
avoids path-encoding TOML entry names).
|
|
301
|
+
|
|
302
|
+
204 on success. 404 when no entry with that name exists.
|
|
303
|
+
"""
|
|
304
|
+
if not name or not name.strip():
|
|
305
|
+
raise HTTPException(status_code=400, detail="name: query param required")
|
|
306
|
+
cs: CatalogState = request.app.state.catalog
|
|
307
|
+
original = list(cs.entries)
|
|
308
|
+
cs.entries = [e for e in cs.entries if e.get("name") != name]
|
|
309
|
+
if len(cs.entries) == len(original):
|
|
310
|
+
raise HTTPException(
|
|
311
|
+
status_code=404,
|
|
312
|
+
detail=f"no catalog entry with name={name!r}",
|
|
313
|
+
)
|
|
314
|
+
_persist_catalog(cs)
|
|
315
|
+
return Response(status_code=204)
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _persist_catalog(cs: CatalogState) -> None:
|
|
319
|
+
"""Write the current in-memory catalog back to
|
|
320
|
+
``<data_dir>/catalog.toml`` (when ``persist_path`` is set).
|
|
321
|
+
|
|
322
|
+
Silent no-op when ``persist_path`` is ``None`` -- tests inject
|
|
323
|
+
a stub CatalogState without disk backing and still exercise
|
|
324
|
+
the in-memory mutation."""
|
|
325
|
+
if cs.persist_path is None:
|
|
326
|
+
return
|
|
327
|
+
from pathlib import Path
|
|
328
|
+
|
|
329
|
+
Path(cs.persist_path).parent.mkdir(parents=True, exist_ok=True)
|
|
330
|
+
Path(cs.persist_path).write_bytes(_serialise_catalog(cs.entries))
|
|
@@ -1,18 +1,16 @@
|
|
|
1
|
-
"""FastAPI app factory for withcache
|
|
1
|
+
"""FastAPI app factory for withcache.
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
chrome pattern; the eventual ``trio-common`` extraction rolls
|
|
7
|
-
these into one library.
|
|
3
|
+
Mirrors :mod:`nbdmux._app` in shape so the trio's three consoles
|
|
4
|
+
share one testing + auth + chrome pattern; the eventual
|
|
5
|
+
``trio-common`` extraction rolls these into one library.
|
|
8
6
|
|
|
9
7
|
Hosts the operator UI (Cached / Downloads / Misses / Catalog /
|
|
10
8
|
Settings), the byte-serving routes registered via
|
|
11
9
|
:func:`._api.register_api_routes`, the admin form endpoints the
|
|
12
10
|
UI action buttons post to, and the persistent-override Settings
|
|
13
|
-
form for the Warming card's log level.
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
form for the Warming card's log level. :func:`withcache.server.main`
|
|
12
|
+
constructs the runtime objects and launches uvicorn against this
|
|
13
|
+
factory.
|
|
16
14
|
"""
|
|
17
15
|
|
|
18
16
|
from __future__ import annotations
|
|
@@ -94,9 +92,8 @@ def create_app(
|
|
|
94
92
|
|
|
95
93
|
``store`` / ``mgr`` / ``streams`` / ``auto_fetch`` let tests
|
|
96
94
|
inject stubs / capture doubles without spawning the real
|
|
97
|
-
:class:`DownloadManager` worker thread.
|
|
98
|
-
|
|
99
|
-
lifespan hook.
|
|
95
|
+
:class:`DownloadManager` worker thread. :func:`server.main`
|
|
96
|
+
passes real instances at daemon start.
|
|
100
97
|
"""
|
|
101
98
|
data_dir_str = str(data_dir)
|
|
102
99
|
Path(data_dir_str).mkdir(parents=True, exist_ok=True)
|
|
@@ -118,9 +115,9 @@ def create_app(
|
|
|
118
115
|
"""
|
|
119
116
|
if run_lifecycle:
|
|
120
117
|
# Kick the startup catalog fetch when the on-disk
|
|
121
|
-
# persisted catalog is empty.
|
|
122
|
-
#
|
|
123
|
-
#
|
|
118
|
+
# persisted catalog is empty. daemon=True so a slow /
|
|
119
|
+
# broken upstream doesn't block ``uvicorn.run``'s serve
|
|
120
|
+
# loop.
|
|
124
121
|
if not _app.state.catalog.entries:
|
|
125
122
|
threading.Thread(
|
|
126
123
|
target=_app.state.catalog.fetch_now,
|
|
@@ -146,8 +143,8 @@ def create_app(
|
|
|
146
143
|
)
|
|
147
144
|
|
|
148
145
|
# SessionMiddleware signs a cookie with the same shape (name +
|
|
149
|
-
# HttpOnly + SameSite=Lax + Max-Age)
|
|
150
|
-
#
|
|
146
|
+
# HttpOnly + SameSite=Lax + Max-Age) as bty + nbdmux so a
|
|
147
|
+
# rolling deploy across the trio doesn't invalidate existing
|
|
151
148
|
# browser sessions.
|
|
152
149
|
app.add_middleware(
|
|
153
150
|
SessionMiddleware,
|
|
@@ -173,11 +170,17 @@ def create_app(
|
|
|
173
170
|
app.state.mgr = mgr if mgr is not None else DownloadManager(app.state.store)
|
|
174
171
|
app.state.streams = streams if streams is not None else StreamRegistry()
|
|
175
172
|
app.state.auto_fetch = auto_fetch
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
173
|
+
# Auth object exposed on app.state so :func:`register_api_routes`
|
|
174
|
+
# (which owns the JSON catalog write endpoints) can gate them on
|
|
175
|
+
# ``Authorization: Bearer <pw>``. The UI form + login flow read
|
|
176
|
+
# ``auth`` from this closure directly, but the register-routes
|
|
177
|
+
# pattern doesn't have the closure, so app.state is the bridge.
|
|
178
|
+
app.state.auth = auth
|
|
179
|
+
# CatalogState resolution: env pin wins over on-disk override,
|
|
180
|
+
# on-disk override wins over the shipping default (nosi's rolling
|
|
181
|
+
# catalog manifest). ``load_persisted`` seeds entries from the
|
|
182
|
+
# last successful fetch so a restart doesn't wipe the cache.
|
|
183
|
+
# Tests pass a stub via ``catalog=`` to skip disk IO.
|
|
181
184
|
if catalog is not None:
|
|
182
185
|
app.state.catalog = catalog
|
|
183
186
|
else:
|
|
@@ -257,7 +260,7 @@ def create_app(
|
|
|
257
260
|
request.session.clear()
|
|
258
261
|
return RedirectResponse(url="/ui/login", status_code=status.HTTP_303_SEE_OTHER)
|
|
259
262
|
|
|
260
|
-
# ---------- Root redirect +
|
|
263
|
+
# ---------- Root redirect + operator UI pages -----------------------
|
|
261
264
|
|
|
262
265
|
@app.get("/")
|
|
263
266
|
def _root() -> RedirectResponse:
|
|
@@ -299,9 +302,8 @@ def create_app(
|
|
|
299
302
|
@app.get("/ui/catalog", response_class=HTMLResponse)
|
|
300
303
|
def ui_catalog(request: Request, _auth_check: None = Depends(require_ui_auth)) -> HTMLResponse:
|
|
301
304
|
"""Catalog view: current URL + env pin + on-disk override
|
|
302
|
-
provenance, plus the entries table.
|
|
303
|
-
|
|
304
|
-
follow-up PR."""
|
|
305
|
+
provenance, plus the entries table. Refresh + Set URL +
|
|
306
|
+
Add oras + Delete are on the subnav / row forms."""
|
|
305
307
|
cs: CatalogState = app.state.catalog
|
|
306
308
|
return render(
|
|
307
309
|
"ui/catalog.html",
|
|
@@ -399,12 +401,11 @@ def create_app(
|
|
|
399
401
|
|
|
400
402
|
# ---------- Admin form endpoints ------------------------------------
|
|
401
403
|
#
|
|
402
|
-
# Form-encoded
|
|
403
|
-
#
|
|
404
|
-
#
|
|
405
|
-
#
|
|
406
|
-
#
|
|
407
|
-
# container) but writes gate on the session cookie.
|
|
404
|
+
# Form-encoded operator actions the UI action buttons post to.
|
|
405
|
+
# Each 303s to a sensible /ui/* target so the browser flips to
|
|
406
|
+
# GET and the dashboard reflects the mutation. Auth-gated -- the
|
|
407
|
+
# JSON /blob + /b/ byte-serving routes stay open (bty polls from
|
|
408
|
+
# a sibling container) but writes gate on the session cookie.
|
|
408
409
|
|
|
409
410
|
@app.post("/admin/fetch")
|
|
410
411
|
def ui_admin_fetch(
|
|
@@ -413,8 +414,8 @@ def create_app(
|
|
|
413
414
|
_auth_check: None = Depends(require_ui_auth),
|
|
414
415
|
) -> RedirectResponse:
|
|
415
416
|
"""Enqueue a background fetch. Optional ``header`` field
|
|
416
|
-
carries a curated authorization payload
|
|
417
|
-
|
|
417
|
+
carries a curated authorization payload, parsed by
|
|
418
|
+
:func:`withcache.server.parse_headers`."""
|
|
418
419
|
from .server import parse_headers
|
|
419
420
|
|
|
420
421
|
u = (url or "").strip()
|
|
@@ -9,10 +9,10 @@ to change without redeploying:
|
|
|
9
9
|
- :data:`KEY_LOG_LEVEL` -- uvicorn / logging level.
|
|
10
10
|
Env: :data:`ENV_LOG_LEVEL`. Default: ``info``.
|
|
11
11
|
- :data:`KEY_CATALOG_URL_OVERRIDE` is intentionally NOT here --
|
|
12
|
-
:class:`CatalogState.set_url_override`
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
12
|
+
:class:`CatalogState.set_url_override` persists it to a dedicated
|
|
13
|
+
``<data-dir>/catalog_url`` file the daemon reads at startup, so
|
|
14
|
+
the Catalog page's Set-URL form remains the single source of
|
|
15
|
+
truth for that knob.
|
|
16
16
|
|
|
17
17
|
Resolution order is always **override -> env -> default**, so
|
|
18
18
|
operators can drop an env / systemd-unit config without hunting
|
|
@@ -21,8 +21,8 @@
|
|
|
21
21
|
<div class="alert alert-secondary small mb-3">
|
|
22
22
|
Blobs bty flashes through withcache land here. Each row is a
|
|
23
23
|
cached artifact keyed by the sha256 of its canonical source URL.
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
Use the trash button to drop a blob; the next request for that
|
|
25
|
+
URL will refetch from origin.
|
|
26
26
|
</div>
|
|
27
27
|
{% endblock %}
|
|
28
28
|
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
<div class="alert alert-secondary small mb-3">
|
|
6
6
|
Cache misses recorded on ``/blob`` / ``/b/<b64>/<name>`` requests.
|
|
7
7
|
In auto-fetch mode withcache enqueues a fetch in the background;
|
|
8
|
-
in curate mode an operator triggers the pull manually.
|
|
9
|
-
Fetch
|
|
8
|
+
in curate mode an operator triggers the pull manually. Per-row
|
|
9
|
+
Fetch enqueues the pull; Dismiss removes the recorded miss.
|
|
10
10
|
</div>
|
|
11
11
|
{% endblock %}
|
|
12
12
|
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""A tiny client for consuming a withcache cache-host from other tools.
|
|
2
|
+
|
|
3
|
+
Lets a consumer (e.g. bty) point downloads at withcache without re-implementing
|
|
4
|
+
the ``/b/`` URL scheme, and also lets bty consume withcache's catalog as the
|
|
5
|
+
single source of truth for what images are flashable. Stdlib only, so
|
|
6
|
+
importing it pulls in no third-party dependencies.
|
|
7
|
+
|
|
8
|
+
from withcache import client
|
|
9
|
+
|
|
10
|
+
# Byte-serving: "use the cache when it's warm, the origin otherwise"
|
|
11
|
+
url = client.serve_url("http://cache:8081", origin) or origin
|
|
12
|
+
|
|
13
|
+
# Catalog: list what's flashable, add a new entry
|
|
14
|
+
entries = client.list_catalog("http://cache:8081")
|
|
15
|
+
client.add_catalog_entry(
|
|
16
|
+
"http://cache:8081",
|
|
17
|
+
{"name": "debian", "src": "https://.../debian.img.gz"},
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
The ``/b/<urlsafe-b64(origin)>/<basename>`` encoding is shared with the shims
|
|
21
|
+
and the server (one definition in :mod:`withcache._shim`), so consumers stay in
|
|
22
|
+
lockstep with the cache-host automatically. Catalog reads are open (LAN-only
|
|
23
|
+
trust model); writes gate on ``$WITHCACHE_ADMIN_PASSWORD`` -- the client picks
|
|
24
|
+
it up from the env var by default so cross-container callers work out of the
|
|
25
|
+
box on the bty deploy (which sets the password on both ends).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import urllib.error
|
|
33
|
+
import urllib.parse
|
|
34
|
+
import urllib.request
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
from . import _shim
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"CATALOG_TIMEOUT",
|
|
41
|
+
"PROBE_TIMEOUT",
|
|
42
|
+
"WithcacheError",
|
|
43
|
+
"add_catalog_entry",
|
|
44
|
+
"blob_url",
|
|
45
|
+
"cache_base",
|
|
46
|
+
"delete_catalog_entry",
|
|
47
|
+
"is_cached",
|
|
48
|
+
"list_catalog",
|
|
49
|
+
"serve_url",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
PROBE_TIMEOUT = 3.0 # seconds; never block the caller on a slow/unreachable cache
|
|
53
|
+
CATALOG_TIMEOUT = 5.0 # seconds; catalog reads/writes are small, keep short
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class WithcacheError(Exception):
|
|
57
|
+
"""Raised on a catalog-plane failure: network, HTTP error, parse error.
|
|
58
|
+
|
|
59
|
+
Inherits from :class:`Exception` (not :class:`OSError`) so callers can
|
|
60
|
+
opt-in to surfacing withcache failures distinctly from generic network
|
|
61
|
+
errors."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
#: Normalize a server value: accepts 'host', 'host:8081', or 'http://host:8081'.
|
|
65
|
+
cache_base = _shim.cache_base
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def blob_url(server: str, origin: str) -> str:
|
|
69
|
+
"""The cache-host serve URL for ``origin``:
|
|
70
|
+
``<server>/b/<urlsafe-b64(origin), unpadded>/<basename>``. The trailing
|
|
71
|
+
basename is cosmetic (so any downloader names the saved file after the
|
|
72
|
+
artifact); the cache keys on the decoded origin URL."""
|
|
73
|
+
return _shim.blob_url(_shim.cache_base(server), origin)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def is_cached(
|
|
77
|
+
server: str,
|
|
78
|
+
origin: str,
|
|
79
|
+
timeout: float = PROBE_TIMEOUT,
|
|
80
|
+
headers: dict[str, str] | None = None,
|
|
81
|
+
) -> bool:
|
|
82
|
+
"""True if the cache-host already holds ``origin`` (a ``HEAD`` on ``/b/``
|
|
83
|
+
returns 200). A miss (404), an unreachable host, a timeout, or any error
|
|
84
|
+
returns False, so a caller can safely fall back to the origin. The HEAD
|
|
85
|
+
also *warms* an auto-fetch cache-host: the miss is recorded and the
|
|
86
|
+
background fill enqueued, so a later probe flips to cached.
|
|
87
|
+
|
|
88
|
+
``headers`` (optional) attaches request headers to the HEAD. The
|
|
89
|
+
cache-host forwards a client-supplied ``Authorization`` into its
|
|
90
|
+
background-fetch worker, so a consumer that has just minted an OCI
|
|
91
|
+
bearer (the typical use case: bty resolving an ``oras://`` catalog
|
|
92
|
+
entry to a ``ghcr.io`` blob URL at import time) can warm the cache
|
|
93
|
+
against that token-gated origin in one probe. Other entries in
|
|
94
|
+
``headers`` round-trip the same way; only ``Authorization`` is
|
|
95
|
+
forwarded into the fetch on the server side.
|
|
96
|
+
"""
|
|
97
|
+
req = urllib.request.Request(blob_url(server, origin), method="HEAD")
|
|
98
|
+
if headers:
|
|
99
|
+
for k, v in headers.items():
|
|
100
|
+
req.add_header(k, v)
|
|
101
|
+
try:
|
|
102
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
103
|
+
return bool(resp.status == 200)
|
|
104
|
+
except urllib.error.HTTPError:
|
|
105
|
+
return False # 404 miss (now recorded + enqueued by the cache-host)
|
|
106
|
+
except (urllib.error.URLError, OSError):
|
|
107
|
+
return False # unreachable / timeout -> caller serves the origin itself
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def serve_url(
|
|
111
|
+
server: str,
|
|
112
|
+
origin: str,
|
|
113
|
+
timeout: float = PROBE_TIMEOUT,
|
|
114
|
+
headers: dict[str, str] | None = None,
|
|
115
|
+
) -> str | None:
|
|
116
|
+
"""The cache-host serve URL for ``origin`` if the cache holds it, else
|
|
117
|
+
``None`` -- the convenience form of "use the cache when warm":
|
|
118
|
+
|
|
119
|
+
url = client.serve_url(cache, origin) or origin
|
|
120
|
+
|
|
121
|
+
``headers`` is passed through to :func:`is_cached` for the HEAD probe;
|
|
122
|
+
the returned serve URL never carries auth (cached bytes are served
|
|
123
|
+
without revisiting the origin).
|
|
124
|
+
"""
|
|
125
|
+
return blob_url(server, origin) if is_cached(server, origin, timeout, headers=headers) else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _resolve_password(password: str | None) -> str | None:
|
|
129
|
+
"""``password`` if given, else ``$WITHCACHE_ADMIN_PASSWORD``, else ``None``.
|
|
130
|
+
Empty / whitespace values are treated as unset."""
|
|
131
|
+
if password:
|
|
132
|
+
return password
|
|
133
|
+
env = (os.environ.get("WITHCACHE_ADMIN_PASSWORD") or "").strip()
|
|
134
|
+
return env or None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _catalog_request(
|
|
138
|
+
method: str,
|
|
139
|
+
server: str,
|
|
140
|
+
path: str,
|
|
141
|
+
body: dict[str, Any] | None = None,
|
|
142
|
+
timeout: float = CATALOG_TIMEOUT,
|
|
143
|
+
password: str | None = None,
|
|
144
|
+
) -> Any:
|
|
145
|
+
"""Shared request wiring for the catalog JSON endpoints.
|
|
146
|
+
|
|
147
|
+
``password`` -> ``Authorization: Bearer <pw>`` on writes. Reads
|
|
148
|
+
are open on the server side so ``password`` is a no-op there;
|
|
149
|
+
always sending it is fine (server ignores the header on reads).
|
|
150
|
+
"""
|
|
151
|
+
url = f"{cache_base(server)}{path}"
|
|
152
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
153
|
+
req = urllib.request.Request(url, data=data, method=method)
|
|
154
|
+
if body is not None:
|
|
155
|
+
req.add_header("Content-Type", "application/json")
|
|
156
|
+
resolved_pw = _resolve_password(password)
|
|
157
|
+
if resolved_pw is not None:
|
|
158
|
+
req.add_header("Authorization", f"Bearer {resolved_pw}")
|
|
159
|
+
try:
|
|
160
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
161
|
+
if resp.status == 204:
|
|
162
|
+
return None
|
|
163
|
+
raw = resp.read()
|
|
164
|
+
if not raw:
|
|
165
|
+
return None
|
|
166
|
+
return json.loads(raw)
|
|
167
|
+
except urllib.error.HTTPError as exc:
|
|
168
|
+
try:
|
|
169
|
+
payload = json.loads(exc.read() or b"{}")
|
|
170
|
+
detail = payload.get("detail") if isinstance(payload, dict) else None
|
|
171
|
+
except (json.JSONDecodeError, ValueError):
|
|
172
|
+
detail = None
|
|
173
|
+
raise WithcacheError(f"{method} {path} -> HTTP {exc.code}: {detail or exc.reason}") from exc
|
|
174
|
+
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
175
|
+
raise WithcacheError(f"{method} {path} -> {exc}") from exc
|
|
176
|
+
except json.JSONDecodeError as exc:
|
|
177
|
+
raise WithcacheError(f"{method} {path} -> invalid JSON: {exc}") from exc
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def list_catalog(
|
|
181
|
+
server: str,
|
|
182
|
+
timeout: float = CATALOG_TIMEOUT,
|
|
183
|
+
) -> dict[str, Any]:
|
|
184
|
+
"""Return the catalog snapshot as ``{url, env_url, fetched_at,
|
|
185
|
+
last_error, entries: [...]}``. Open route: no password needed.
|
|
186
|
+
|
|
187
|
+
Raises :class:`WithcacheError` on any failure (network, HTTP,
|
|
188
|
+
parse). Callers typically want the ``entries`` field only:
|
|
189
|
+
|
|
190
|
+
entries = client.list_catalog(server)["entries"]
|
|
191
|
+
"""
|
|
192
|
+
return _catalog_request("GET", server, "/catalog", timeout=timeout)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def add_catalog_entry(
|
|
196
|
+
server: str,
|
|
197
|
+
entry: dict[str, Any],
|
|
198
|
+
*,
|
|
199
|
+
timeout: float = CATALOG_TIMEOUT,
|
|
200
|
+
password: str | None = None,
|
|
201
|
+
) -> dict[str, Any]:
|
|
202
|
+
"""POST one entry into the catalog.
|
|
203
|
+
|
|
204
|
+
``entry`` must carry at least ``name`` + ``src``. Optional
|
|
205
|
+
fields: ``format``, ``arch``, ``sha256``, ``size_bytes``,
|
|
206
|
+
``resolved_src``, ``description``.
|
|
207
|
+
|
|
208
|
+
Raises :class:`WithcacheError` on any HTTP failure (409 if the
|
|
209
|
+
entry name already exists; 401 if auth is enabled and no valid
|
|
210
|
+
password reached the server).
|
|
211
|
+
"""
|
|
212
|
+
return _catalog_request(
|
|
213
|
+
"POST", server, "/catalog/entries", body=entry, timeout=timeout, password=password
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def delete_catalog_entry(
|
|
218
|
+
server: str,
|
|
219
|
+
name: str,
|
|
220
|
+
*,
|
|
221
|
+
timeout: float = CATALOG_TIMEOUT,
|
|
222
|
+
password: str | None = None,
|
|
223
|
+
) -> None:
|
|
224
|
+
"""DELETE the catalog entry with ``name=<name>``. 404 (no such
|
|
225
|
+
entry) is treated as success so the call is idempotent for the
|
|
226
|
+
operator's "make sure this is gone" intent, mirroring
|
|
227
|
+
:func:`nbdmux.client.remove_export`.
|
|
228
|
+
|
|
229
|
+
Raises :class:`WithcacheError` on transport failure but NOT on
|
|
230
|
+
404."""
|
|
231
|
+
path = f"/catalog/entries?name={urllib.parse.quote(name, safe='')}"
|
|
232
|
+
try:
|
|
233
|
+
_catalog_request("DELETE", server, path, timeout=timeout, password=password)
|
|
234
|
+
except WithcacheError as exc:
|
|
235
|
+
if "HTTP 404" in str(exc):
|
|
236
|
+
return
|
|
237
|
+
raise
|
|
@@ -19,8 +19,8 @@ the parts of the daemon that stay stdlib:
|
|
|
19
19
|
bty flashes against, persisted to
|
|
20
20
|
``<data-dir>/catalog.toml`` between restarts.
|
|
21
21
|
- Helpers: ``resolve_secret``, ``parse_size``, ``parse_headers``,
|
|
22
|
-
``now_iso``, ``
|
|
23
|
-
``
|
|
22
|
+
``now_iso``, ``human_size``, ``_b64e`` / ``_b64d``,
|
|
23
|
+
``_serialise_catalog``, ``_oras_tag_moved``.
|
|
24
24
|
- ``main()``: CLI entrypoint that constructs Store + DownloadManager
|
|
25
25
|
and hands them to :func:`withcache._app.create_app`, then boots
|
|
26
26
|
uvicorn. SIGTERM / SIGINT handling is uvicorn's.
|
|
@@ -74,18 +74,6 @@ def now_iso() -> str:
|
|
|
74
74
|
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
75
75
|
|
|
76
76
|
|
|
77
|
-
def _age_human(started_at: float, *, now: float | None = None) -> str:
|
|
78
|
-
"""Render seconds-since as a compact ``Ns`` / ``Nm`` / ``Nh`` string for
|
|
79
|
-
the streams table. ``now`` is injectable so tests don't need
|
|
80
|
-
monkeypatching ``time.time`` to assert formatting."""
|
|
81
|
-
elapsed = int(max(0.0, (now if now is not None else time.time()) - started_at))
|
|
82
|
-
if elapsed < 60:
|
|
83
|
-
return f"{elapsed}s"
|
|
84
|
-
if elapsed < 3600:
|
|
85
|
-
return f"{elapsed // 60}m{elapsed % 60:02d}s"
|
|
86
|
-
return f"{elapsed // 3600}h{(elapsed % 3600) // 60:02d}m"
|
|
87
|
-
|
|
88
|
-
|
|
89
77
|
def human_size(n: int) -> str:
|
|
90
78
|
f = float(n)
|
|
91
79
|
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
|
|
@@ -157,11 +145,21 @@ def _serialise_catalog(entries: list[dict[str, Any]]) -> bytes:
|
|
|
157
145
|
``tomli_w`` dep: the schema is flat so a hand-rolled emitter is
|
|
158
146
|
safer than pulling in a write library. Only known scalar keys are
|
|
159
147
|
emitted; unknown keys are dropped (silently) so an operator-added
|
|
160
|
-
row can't smuggle arbitrary TOML through.
|
|
148
|
+
row can't smuggle arbitrary TOML through.
|
|
149
|
+
|
|
150
|
+
Scalar keys emitted: ``name``, ``src``, ``resolved_src``,
|
|
151
|
+
``format``, ``arch``, ``sha256``, ``description``. Integer:
|
|
152
|
+
``size_bytes``. ``resolved_src`` is the canonical fetch URL
|
|
153
|
+
(for ``oras://`` refs; equal to ``src`` for plain HTTPS);
|
|
154
|
+
bty needs it to render iPXE flash chains against the byte
|
|
155
|
+
URL directly. ``description`` is operator-supplied prose so
|
|
156
|
+
the /ui/images page has something better than the URL to
|
|
157
|
+
display.
|
|
158
|
+
"""
|
|
161
159
|
out: list[str] = ["version = 1", ""]
|
|
162
160
|
for e in entries:
|
|
163
161
|
out.append("[[images]]")
|
|
164
|
-
for key in ("name", "src", "format", "arch", "sha256"):
|
|
162
|
+
for key in ("name", "src", "resolved_src", "format", "arch", "sha256", "description"):
|
|
165
163
|
val = e.get(key)
|
|
166
164
|
if val is None or val == "":
|
|
167
165
|
continue
|
|
@@ -432,6 +430,19 @@ class Auth:
|
|
|
432
430
|
return False
|
|
433
431
|
return hmac.compare_digest(pw.encode("utf-8"), self.password.encode("utf-8"))
|
|
434
432
|
|
|
433
|
+
def check_bearer(self, token: str) -> bool:
|
|
434
|
+
"""Constant-time compare a raw Bearer token against the
|
|
435
|
+
configured admin password. Used by the JSON control-plane's
|
|
436
|
+
service-to-service path (bty reads
|
|
437
|
+
``$WITHCACHE_ADMIN_PASSWORD`` and sends it as
|
|
438
|
+
``Authorization: Bearer <pw>``). Equivalent trust surface
|
|
439
|
+
to ``check_password`` -- both are password comparisons --
|
|
440
|
+
so callers can pick whichever channel their transport
|
|
441
|
+
supports."""
|
|
442
|
+
if not self.password:
|
|
443
|
+
return False
|
|
444
|
+
return hmac.compare_digest(token.encode("utf-8"), self.password.encode("utf-8"))
|
|
445
|
+
|
|
435
446
|
|
|
436
447
|
# --------------------------------------------------------------------------
|
|
437
448
|
# Storage
|
|
@@ -20,8 +20,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
|
20
20
|
|
|
21
21
|
try:
|
|
22
22
|
from fastapi.testclient import TestClient # noqa: E402
|
|
23
|
-
except
|
|
24
|
-
raise unittest.SkipTest("fastapi
|
|
23
|
+
except ImportError: # pragma: no cover
|
|
24
|
+
raise unittest.SkipTest("fastapi not installed") from None
|
|
25
25
|
|
|
26
26
|
from withcache._app import create_app # noqa: E402
|
|
27
27
|
from withcache.server import CatalogState # noqa: E402
|
|
@@ -23,8 +23,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
|
23
23
|
|
|
24
24
|
try:
|
|
25
25
|
from fastapi.testclient import TestClient # noqa: E402
|
|
26
|
-
except
|
|
27
|
-
raise unittest.SkipTest("fastapi
|
|
26
|
+
except ImportError: # pragma: no cover
|
|
27
|
+
raise unittest.SkipTest("fastapi not installed") from None
|
|
28
28
|
|
|
29
29
|
from withcache._app import create_app # noqa: E402
|
|
30
30
|
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""TestClient tests for the JSON catalog API.
|
|
2
|
+
|
|
3
|
+
Pins the ``GET /catalog`` + ``POST /catalog/entries`` +
|
|
4
|
+
``DELETE /catalog/entries`` surface that bty consumes as the
|
|
5
|
+
single source of truth for what images are flashable. Mirrors the
|
|
6
|
+
Bearer / session dual-auth pattern nbdmux uses on its write
|
|
7
|
+
routes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import sys
|
|
15
|
+
import tempfile
|
|
16
|
+
import unittest
|
|
17
|
+
|
|
18
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from fastapi.testclient import TestClient # noqa: E402
|
|
22
|
+
except ImportError: # pragma: no cover
|
|
23
|
+
raise unittest.SkipTest("fastapi not installed") from None
|
|
24
|
+
|
|
25
|
+
from withcache._app import create_app # noqa: E402
|
|
26
|
+
from withcache.server import CatalogState # noqa: E402
|
|
27
|
+
|
|
28
|
+
TEST_PASSWORD = "test-admin-pw"
|
|
29
|
+
TEST_SECRET = b"test-secret-not-for-prod-use-32b_"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _CatalogApiBase(unittest.TestCase):
|
|
33
|
+
ENABLE_AUTH = False
|
|
34
|
+
|
|
35
|
+
def setUp(self) -> None:
|
|
36
|
+
self._tmpdir = tempfile.mkdtemp()
|
|
37
|
+
self._saved_pw = os.environ.get("WITHCACHE_ADMIN_PASSWORD")
|
|
38
|
+
self._saved_cat = os.environ.get("WITHCACHE_CATALOG_URL")
|
|
39
|
+
if self.ENABLE_AUTH:
|
|
40
|
+
os.environ["WITHCACHE_ADMIN_PASSWORD"] = TEST_PASSWORD
|
|
41
|
+
else:
|
|
42
|
+
os.environ.pop("WITHCACHE_ADMIN_PASSWORD", None)
|
|
43
|
+
os.environ.pop("WITHCACHE_CATALOG_URL", None)
|
|
44
|
+
|
|
45
|
+
self.catalog = CatalogState(
|
|
46
|
+
url="https://example.invalid/catalog.toml",
|
|
47
|
+
persist_path=os.path.join(self._tmpdir, "catalog.toml"),
|
|
48
|
+
env_url="",
|
|
49
|
+
url_override_path=os.path.join(self._tmpdir, "catalog_url"),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def _fake_fetch_now(**_kw: object) -> None:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
self.catalog.fetch_now = _fake_fetch_now # type: ignore[assignment]
|
|
56
|
+
|
|
57
|
+
self.app = create_app(
|
|
58
|
+
data_dir=self._tmpdir,
|
|
59
|
+
secret_key=TEST_SECRET,
|
|
60
|
+
catalog=self.catalog,
|
|
61
|
+
)
|
|
62
|
+
self.client = TestClient(self.app, follow_redirects=False)
|
|
63
|
+
|
|
64
|
+
def tearDown(self) -> None:
|
|
65
|
+
self.client.close()
|
|
66
|
+
for key, saved in (
|
|
67
|
+
("WITHCACHE_ADMIN_PASSWORD", self._saved_pw),
|
|
68
|
+
("WITHCACHE_CATALOG_URL", self._saved_cat),
|
|
69
|
+
):
|
|
70
|
+
if saved is None:
|
|
71
|
+
os.environ.pop(key, None)
|
|
72
|
+
else:
|
|
73
|
+
os.environ[key] = saved
|
|
74
|
+
shutil.rmtree(self._tmpdir, ignore_errors=True)
|
|
75
|
+
|
|
76
|
+
def _login(self) -> None:
|
|
77
|
+
r = self.client.post("/ui/login", data={"password": TEST_PASSWORD}, follow_redirects=False)
|
|
78
|
+
self.assertEqual(r.status_code, 303, r.text)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class ListCatalogTests(_CatalogApiBase):
|
|
82
|
+
def test_list_empty_returns_metadata_with_empty_entries(self) -> None:
|
|
83
|
+
r = self.client.get("/catalog")
|
|
84
|
+
self.assertEqual(r.status_code, 200)
|
|
85
|
+
body = r.json()
|
|
86
|
+
self.assertEqual(body["entries"], [])
|
|
87
|
+
self.assertEqual(body["url"], "https://example.invalid/catalog.toml")
|
|
88
|
+
self.assertIn("fetched_at", body)
|
|
89
|
+
self.assertIn("last_error", body)
|
|
90
|
+
|
|
91
|
+
def test_list_returns_seeded_entries(self) -> None:
|
|
92
|
+
self.catalog.entries = [
|
|
93
|
+
{
|
|
94
|
+
"name": "demo",
|
|
95
|
+
"src": "https://example/demo.img.gz",
|
|
96
|
+
"format": "img.gz",
|
|
97
|
+
"sha256": "a" * 64,
|
|
98
|
+
"size_bytes": 1024,
|
|
99
|
+
"description": "demo image",
|
|
100
|
+
"resolved_src": "https://example/demo.img.gz",
|
|
101
|
+
}
|
|
102
|
+
]
|
|
103
|
+
r = self.client.get("/catalog")
|
|
104
|
+
self.assertEqual(r.status_code, 200)
|
|
105
|
+
entries = r.json()["entries"]
|
|
106
|
+
self.assertEqual(len(entries), 1)
|
|
107
|
+
self.assertEqual(entries[0]["name"], "demo")
|
|
108
|
+
self.assertEqual(entries[0]["sha256"], "a" * 64)
|
|
109
|
+
self.assertEqual(entries[0]["description"], "demo image")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class AddCatalogEntryTests(_CatalogApiBase):
|
|
113
|
+
def test_add_minimum_entry(self) -> None:
|
|
114
|
+
"""Only ``name`` + ``src`` are required."""
|
|
115
|
+
r = self.client.post(
|
|
116
|
+
"/catalog/entries",
|
|
117
|
+
json={"name": "demo", "src": "https://example/demo.img.gz"},
|
|
118
|
+
)
|
|
119
|
+
self.assertEqual(r.status_code, 201, r.text)
|
|
120
|
+
body = r.json()
|
|
121
|
+
self.assertEqual(body["name"], "demo")
|
|
122
|
+
self.assertEqual(body["src"], "https://example/demo.img.gz")
|
|
123
|
+
# And it landed in the catalog state.
|
|
124
|
+
self.assertEqual(len(self.catalog.entries), 1)
|
|
125
|
+
|
|
126
|
+
def test_add_full_entry_round_trips_all_fields(self) -> None:
|
|
127
|
+
r = self.client.post(
|
|
128
|
+
"/catalog/entries",
|
|
129
|
+
json={
|
|
130
|
+
"name": "debian",
|
|
131
|
+
"src": "https://example/debian.img.gz",
|
|
132
|
+
"resolved_src": "https://example/debian.img.gz",
|
|
133
|
+
"format": "img.gz",
|
|
134
|
+
"arch": "x86_64",
|
|
135
|
+
"sha256": "b" * 64,
|
|
136
|
+
"size_bytes": 2048,
|
|
137
|
+
"description": "Debian sysdev",
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
self.assertEqual(r.status_code, 201, r.text)
|
|
141
|
+
stored = self.catalog.entries[0]
|
|
142
|
+
self.assertEqual(stored["description"], "Debian sysdev")
|
|
143
|
+
self.assertEqual(stored["resolved_src"], "https://example/debian.img.gz")
|
|
144
|
+
|
|
145
|
+
def test_add_persists_to_disk(self) -> None:
|
|
146
|
+
self.client.post(
|
|
147
|
+
"/catalog/entries", json={"name": "demo", "src": "https://example/demo.img.gz"}
|
|
148
|
+
)
|
|
149
|
+
toml_path = os.path.join(self._tmpdir, "catalog.toml")
|
|
150
|
+
self.assertTrue(os.path.exists(toml_path))
|
|
151
|
+
content = open(toml_path, encoding="utf-8").read()
|
|
152
|
+
self.assertIn('name = "demo"', content)
|
|
153
|
+
|
|
154
|
+
def test_add_rejects_missing_name(self) -> None:
|
|
155
|
+
r = self.client.post("/catalog/entries", json={"src": "https://example/demo.img.gz"})
|
|
156
|
+
self.assertEqual(r.status_code, 400)
|
|
157
|
+
|
|
158
|
+
def test_add_rejects_missing_src(self) -> None:
|
|
159
|
+
r = self.client.post("/catalog/entries", json={"name": "demo"})
|
|
160
|
+
self.assertEqual(r.status_code, 400)
|
|
161
|
+
|
|
162
|
+
def test_add_rejects_unknown_field(self) -> None:
|
|
163
|
+
"""Unknown fields are refused at the API boundary so operators
|
|
164
|
+
catch typos loud (``notes`` → ``description``) instead of
|
|
165
|
+
watching them silently drop through the emitter."""
|
|
166
|
+
r = self.client.post(
|
|
167
|
+
"/catalog/entries",
|
|
168
|
+
json={"name": "demo", "src": "https://example/x", "notes": "typo"},
|
|
169
|
+
)
|
|
170
|
+
self.assertEqual(r.status_code, 400)
|
|
171
|
+
self.assertIn("notes", r.json()["detail"])
|
|
172
|
+
|
|
173
|
+
def test_add_duplicate_name_409s(self) -> None:
|
|
174
|
+
first = {"name": "demo", "src": "https://example/one.img.gz"}
|
|
175
|
+
self.client.post("/catalog/entries", json=first)
|
|
176
|
+
r = self.client.post(
|
|
177
|
+
"/catalog/entries",
|
|
178
|
+
json={"name": "demo", "src": "https://example/two.img.gz"},
|
|
179
|
+
)
|
|
180
|
+
self.assertEqual(r.status_code, 409)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class DeleteCatalogEntryTests(_CatalogApiBase):
|
|
184
|
+
def test_delete_removes_entry(self) -> None:
|
|
185
|
+
self.catalog.entries = [{"name": "demo", "src": "https://example/demo.img.gz"}]
|
|
186
|
+
r = self.client.delete("/catalog/entries?name=demo")
|
|
187
|
+
self.assertEqual(r.status_code, 204)
|
|
188
|
+
self.assertEqual(self.catalog.entries, [])
|
|
189
|
+
|
|
190
|
+
def test_delete_unknown_returns_404(self) -> None:
|
|
191
|
+
r = self.client.delete("/catalog/entries?name=missing")
|
|
192
|
+
self.assertEqual(r.status_code, 404)
|
|
193
|
+
|
|
194
|
+
def test_delete_requires_name(self) -> None:
|
|
195
|
+
r = self.client.delete("/catalog/entries")
|
|
196
|
+
self.assertEqual(r.status_code, 400)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class CatalogApiAuthTests(_CatalogApiBase):
|
|
200
|
+
ENABLE_AUTH = True
|
|
201
|
+
|
|
202
|
+
def test_get_catalog_open_even_with_auth(self) -> None:
|
|
203
|
+
"""Bty polls the catalog from a sibling container without a
|
|
204
|
+
session; read stays open regardless of auth."""
|
|
205
|
+
r = self.client.get("/catalog")
|
|
206
|
+
self.assertEqual(r.status_code, 200)
|
|
207
|
+
|
|
208
|
+
def test_add_entry_401_without_auth(self) -> None:
|
|
209
|
+
r = self.client.post("/catalog/entries", json={"name": "demo", "src": "https://example/x"})
|
|
210
|
+
self.assertEqual(r.status_code, 401)
|
|
211
|
+
|
|
212
|
+
def test_add_entry_200_with_session(self) -> None:
|
|
213
|
+
self._login()
|
|
214
|
+
r = self.client.post("/catalog/entries", json={"name": "demo", "src": "https://example/x"})
|
|
215
|
+
self.assertEqual(r.status_code, 201)
|
|
216
|
+
|
|
217
|
+
def test_add_entry_200_with_bearer(self) -> None:
|
|
218
|
+
"""Service-to-service path: bty reads WITHCACHE_ADMIN_PASSWORD
|
|
219
|
+
from env and sends it as ``Authorization: Bearer <pw>``."""
|
|
220
|
+
r = self.client.post(
|
|
221
|
+
"/catalog/entries",
|
|
222
|
+
json={"name": "demo", "src": "https://example/x"},
|
|
223
|
+
headers={"Authorization": f"Bearer {TEST_PASSWORD}"},
|
|
224
|
+
)
|
|
225
|
+
self.assertEqual(r.status_code, 201)
|
|
226
|
+
|
|
227
|
+
def test_add_entry_401_with_bearer_mismatch(self) -> None:
|
|
228
|
+
r = self.client.post(
|
|
229
|
+
"/catalog/entries",
|
|
230
|
+
json={"name": "demo", "src": "https://example/x"},
|
|
231
|
+
headers={"Authorization": "Bearer wrong-pw"},
|
|
232
|
+
)
|
|
233
|
+
self.assertEqual(r.status_code, 401)
|
|
234
|
+
|
|
235
|
+
def test_delete_entry_401_without_auth(self) -> None:
|
|
236
|
+
r = self.client.delete("/catalog/entries?name=demo")
|
|
237
|
+
self.assertEqual(r.status_code, 401)
|
|
238
|
+
|
|
239
|
+
def test_delete_entry_204_with_bearer(self) -> None:
|
|
240
|
+
self.catalog.entries = [{"name": "demo", "src": "https://example/x"}]
|
|
241
|
+
r = self.client.delete(
|
|
242
|
+
"/catalog/entries?name=demo",
|
|
243
|
+
headers={"Authorization": f"Bearer {TEST_PASSWORD}"},
|
|
244
|
+
)
|
|
245
|
+
self.assertEqual(r.status_code, 204)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
if __name__ == "__main__": # pragma: no cover
|
|
249
|
+
unittest.main()
|
|
@@ -33,12 +33,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
|
33
33
|
|
|
34
34
|
try:
|
|
35
35
|
from fastapi.testclient import TestClient # noqa: E402
|
|
36
|
-
except
|
|
37
|
-
|
|
38
|
-
# ``testclient`` raises when httpx isn't installed. Either way,
|
|
39
|
-
# skip so an unprepped CI runner reports a green skip rather
|
|
40
|
-
# than a red error.
|
|
41
|
-
raise unittest.SkipTest("fastapi + httpx not available (port scaffolding deps)") from None
|
|
36
|
+
except ImportError: # pragma: no cover
|
|
37
|
+
raise unittest.SkipTest("fastapi not installed") from None
|
|
42
38
|
|
|
43
39
|
from withcache._app import create_app # noqa: E402
|
|
44
40
|
|
|
@@ -26,8 +26,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
|
26
26
|
|
|
27
27
|
try:
|
|
28
28
|
from fastapi.testclient import TestClient # noqa: E402
|
|
29
|
-
except
|
|
30
|
-
raise unittest.SkipTest("fastapi
|
|
29
|
+
except ImportError: # pragma: no cover
|
|
30
|
+
raise unittest.SkipTest("fastapi not installed") from None
|
|
31
31
|
|
|
32
32
|
from withcache import _settings_store # noqa: E402
|
|
33
33
|
from withcache._app import create_app # noqa: E402
|
|
@@ -20,8 +20,8 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
|
20
20
|
|
|
21
21
|
try:
|
|
22
22
|
from fastapi.testclient import TestClient # noqa: E402
|
|
23
|
-
except
|
|
24
|
-
raise unittest.SkipTest("fastapi
|
|
23
|
+
except ImportError: # pragma: no cover
|
|
24
|
+
raise unittest.SkipTest("fastapi not installed") from None
|
|
25
25
|
|
|
26
26
|
from withcache._app import create_app # noqa: E402
|
|
27
27
|
from withcache.server import CatalogState # noqa: E402
|
|
@@ -20,8 +20,6 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
|
20
20
|
|
|
21
21
|
import base64
|
|
22
22
|
import urllib.error
|
|
23
|
-
import urllib.parse
|
|
24
|
-
import urllib.request
|
|
25
23
|
|
|
26
24
|
from withcache import _shim, server
|
|
27
25
|
|
|
@@ -115,6 +113,15 @@ class TestAuth(unittest.TestCase):
|
|
|
115
113
|
a = server.Auth(b"k", None)
|
|
116
114
|
self.assertFalse(a.enabled)
|
|
117
115
|
self.assertFalse(a.check_password("anything"))
|
|
116
|
+
self.assertFalse(a.check_bearer("anything"))
|
|
117
|
+
|
|
118
|
+
def test_bearer_check(self):
|
|
119
|
+
"""Bearer path uses constant-time compare against the same
|
|
120
|
+
admin password ``check_password`` gates the UI login on."""
|
|
121
|
+
a = server.Auth(b"k", "hunter2")
|
|
122
|
+
self.assertTrue(a.check_bearer("hunter2"))
|
|
123
|
+
self.assertFalse(a.check_bearer("nope"))
|
|
124
|
+
self.assertFalse(a.check_bearer(""))
|
|
118
125
|
|
|
119
126
|
|
|
120
127
|
# --------------------------------------------------------------------------
|
|
@@ -508,30 +515,6 @@ class TestStreamRegistry(unittest.TestCase):
|
|
|
508
515
|
self.assertEqual(reg.snapshot(), [])
|
|
509
516
|
|
|
510
517
|
|
|
511
|
-
class TestAgeHuman(unittest.TestCase):
|
|
512
|
-
"""``_age_human`` renders elapsed seconds into the compact form the
|
|
513
|
-
Streams table cell shows. Inject ``now`` so the test doesn't have
|
|
514
|
-
to monkeypatch ``time.time``."""
|
|
515
|
-
|
|
516
|
-
def test_seconds_only(self):
|
|
517
|
-
self.assertEqual(server._age_human(100.0, now=100.0), "0s")
|
|
518
|
-
self.assertEqual(server._age_human(100.0, now=159.0), "59s")
|
|
519
|
-
|
|
520
|
-
def test_minutes_pad_seconds(self):
|
|
521
|
-
self.assertEqual(server._age_human(100.0, now=160.0), "1m00s")
|
|
522
|
-
self.assertEqual(server._age_human(100.0, now=222.0), "2m02s")
|
|
523
|
-
|
|
524
|
-
def test_hours_pad_minutes(self):
|
|
525
|
-
self.assertEqual(server._age_human(0.0, now=3600.0), "1h00m")
|
|
526
|
-
self.assertEqual(server._age_human(0.0, now=3661.0), "1h01m")
|
|
527
|
-
self.assertEqual(server._age_human(0.0, now=7320.0), "2h02m")
|
|
528
|
-
|
|
529
|
-
def test_negative_clamps_to_zero(self):
|
|
530
|
-
# Started-at in the future (clock skew, replayed snapshot) renders
|
|
531
|
-
# as 0s rather than a confusing negative.
|
|
532
|
-
self.assertEqual(server._age_human(200.0, now=100.0), "0s")
|
|
533
|
-
|
|
534
|
-
|
|
535
518
|
# --------------------------------------------------------------------------
|
|
536
519
|
# _shim: URL detection, rewrite, real-tool resolution, env, path-encoding
|
|
537
520
|
# --------------------------------------------------------------------------
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
"""A tiny client for consuming a withcache cache-host from other tools.
|
|
2
|
-
|
|
3
|
-
Lets a consumer (e.g. bty) point downloads at withcache without re-implementing
|
|
4
|
-
the ``/b/`` URL scheme. Stdlib only, so importing it pulls in no third-party
|
|
5
|
-
dependencies.
|
|
6
|
-
|
|
7
|
-
from withcache import client
|
|
8
|
-
|
|
9
|
-
# "use the cache when it's warm, the origin otherwise"
|
|
10
|
-
url = client.serve_url("http://cache:8081", origin) or origin
|
|
11
|
-
|
|
12
|
-
The ``/b/<urlsafe-b64(origin)>/<basename>`` encoding is shared with the shims
|
|
13
|
-
and the server (one definition in :mod:`withcache._shim`), so consumers stay in
|
|
14
|
-
lockstep with the cache-host automatically.
|
|
15
|
-
"""
|
|
16
|
-
|
|
17
|
-
from __future__ import annotations
|
|
18
|
-
|
|
19
|
-
import urllib.error
|
|
20
|
-
import urllib.request
|
|
21
|
-
|
|
22
|
-
from . import _shim
|
|
23
|
-
|
|
24
|
-
__all__ = ["PROBE_TIMEOUT", "blob_url", "cache_base", "is_cached", "serve_url"]
|
|
25
|
-
|
|
26
|
-
PROBE_TIMEOUT = 3.0 # seconds; never block the caller on a slow/unreachable cache
|
|
27
|
-
|
|
28
|
-
#: Normalize a server value: accepts 'host', 'host:8081', or 'http://host:8081'.
|
|
29
|
-
cache_base = _shim.cache_base
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def blob_url(server: str, origin: str) -> str:
|
|
33
|
-
"""The cache-host serve URL for ``origin``:
|
|
34
|
-
``<server>/b/<urlsafe-b64(origin), unpadded>/<basename>``. The trailing
|
|
35
|
-
basename is cosmetic (so any downloader names the saved file after the
|
|
36
|
-
artifact); the cache keys on the decoded origin URL."""
|
|
37
|
-
return _shim.blob_url(_shim.cache_base(server), origin)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def is_cached(
|
|
41
|
-
server: str,
|
|
42
|
-
origin: str,
|
|
43
|
-
timeout: float = PROBE_TIMEOUT,
|
|
44
|
-
headers: dict[str, str] | None = None,
|
|
45
|
-
) -> bool:
|
|
46
|
-
"""True if the cache-host already holds ``origin`` (a ``HEAD`` on ``/b/``
|
|
47
|
-
returns 200). A miss (404), an unreachable host, a timeout, or any error
|
|
48
|
-
returns False, so a caller can safely fall back to the origin. The HEAD
|
|
49
|
-
also *warms* an auto-fetch cache-host: the miss is recorded and the
|
|
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
|
-
"""
|
|
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)
|
|
65
|
-
try:
|
|
66
|
-
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
67
|
-
return bool(resp.status == 200)
|
|
68
|
-
except urllib.error.HTTPError:
|
|
69
|
-
return False # 404 miss (now recorded + enqueued by the cache-host)
|
|
70
|
-
except (urllib.error.URLError, OSError):
|
|
71
|
-
return False # unreachable / timeout -> caller serves the origin itself
|
|
72
|
-
|
|
73
|
-
|
|
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:
|
|
80
|
-
"""The cache-host serve URL for ``origin`` if the cache holds it, else
|
|
81
|
-
``None`` -- the convenience form of "use the cache when warm":
|
|
82
|
-
|
|
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).
|
|
88
|
-
"""
|
|
89
|
-
return blob_url(server, origin) if is_cached(server, origin, timeout, headers=headers) else None
|
|
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
|
|
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
|