withcache 0.8.1__tar.gz → 0.8.3__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.8.1 → withcache-0.8.3}/PKG-INFO +2 -2
- {withcache-0.8.1 → withcache-0.8.3}/README.md +1 -1
- {withcache-0.8.1 → withcache-0.8.3}/shim/build.zig.zon +1 -1
- {withcache-0.8.1 → withcache-0.8.3}/src/withcache/__init__.py +1 -1
- {withcache-0.8.1 → withcache-0.8.3}/src/withcache/server.py +4 -3
- {withcache-0.8.1 → withcache-0.8.3}/tests/test_withcache.py +174 -0
- {withcache-0.8.1 → withcache-0.8.3}/.gitignore +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/LICENSE +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/deploy/Containerfile +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/deploy/compose.yml +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/hatch_build.py +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/pyproject.toml +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/shim/build.zig +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/shim/shim.zig +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/src/withcache/_shim.py +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/src/withcache/client.py +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/src/withcache/curlwithcache.py +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/src/withcache/oras.py +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/src/withcache/static/bootstrap-icons.min.css +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/src/withcache/static/bootstrap.min.css +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/src/withcache/static/htmx.min.js +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/src/withcache/wgetwithcache.py +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/tests/test_differential.py +0 -0
- {withcache-0.8.1 → withcache-0.8.3}/tests/test_oras.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: withcache
|
|
3
|
-
Version: 0.8.
|
|
3
|
+
Version: 0.8.3
|
|
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>
|
|
@@ -45,7 +45,7 @@ By default a miss is **auto-fetched**: the request falls through to origin (so
|
|
|
45
45
|
the caller gets its file straight away), and the cache-host pulls the same
|
|
46
46
|
artifact in the background, so the next request hits. Run with **`--curate`** to
|
|
47
47
|
require a human instead, who reviews the miss list in a small web UI and presses
|
|
48
|
-
**Download** (or pre-seeds via
|
|
48
|
+
**Download** (or pre-seeds via the Downloads-page **Fetch** form). Either way the cache-host is the
|
|
49
49
|
only box that needs internet egress (and any vendor credentials), and clients
|
|
50
50
|
never write to it.
|
|
51
51
|
|
|
@@ -27,7 +27,7 @@ By default a miss is **auto-fetched**: the request falls through to origin (so
|
|
|
27
27
|
the caller gets its file straight away), and the cache-host pulls the same
|
|
28
28
|
artifact in the background, so the next request hits. Run with **`--curate`** to
|
|
29
29
|
require a human instead, who reviews the miss list in a small web UI and presses
|
|
30
|
-
**Download** (or pre-seeds via
|
|
30
|
+
**Download** (or pre-seeds via the Downloads-page **Fetch** form). Either way the cache-host is the
|
|
31
31
|
only box that needs internet egress (and any vendor credentials), and clients
|
|
32
32
|
never write to it.
|
|
33
33
|
|
|
@@ -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.8.
|
|
5
|
+
.version = "0.8.3",
|
|
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.8.
|
|
20
|
+
__version__ = "0.8.3"
|
|
21
21
|
|
|
22
22
|
__all__ = ["__version__", "blob_url", "cache_base", "is_cached", "oras", "serve_url"]
|
|
@@ -188,9 +188,10 @@ class CatalogState:
|
|
|
188
188
|
other render reads whatever is currently in memory.
|
|
189
189
|
|
|
190
190
|
The raw TOML bytes are persisted to ``<data_dir>/catalog.toml``
|
|
191
|
-
so a restart doesn't wipe the last known good catalog
|
|
192
|
-
|
|
193
|
-
|
|
191
|
+
so a restart doesn't wipe the last known good catalog. There is
|
|
192
|
+
no HTTP route that serves the file back verbatim; consumers
|
|
193
|
+
resolve entries by name through the dashboard or by reading
|
|
194
|
+
``entries`` in-process.
|
|
194
195
|
|
|
195
196
|
``env_url`` records the value pinned via ``$WITHCACHE_CATALOG_URL``
|
|
196
197
|
(empty if unset). The operator can override the effective URL at
|
|
@@ -22,6 +22,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))
|
|
|
22
22
|
|
|
23
23
|
import base64
|
|
24
24
|
import urllib.error
|
|
25
|
+
import urllib.parse
|
|
25
26
|
import urllib.request
|
|
26
27
|
|
|
27
28
|
from withcache import _shim, client, curlwithcache, server, wgetwithcache
|
|
@@ -1088,5 +1089,178 @@ class TestMultiPageNavigation(unittest.TestCase):
|
|
|
1088
1089
|
self.assertNotIn("navbar-brand", body)
|
|
1089
1090
|
|
|
1090
1091
|
|
|
1092
|
+
# --------------------------------------------------------------------------
|
|
1093
|
+
# /admin/catalog_* -- the four operator-facing catalog control endpoints
|
|
1094
|
+
# --------------------------------------------------------------------------
|
|
1095
|
+
class _CatalogOrigin(http.server.BaseHTTPRequestHandler):
|
|
1096
|
+
"""Serves a fixed valid catalog.toml to catalog_refresh tests."""
|
|
1097
|
+
|
|
1098
|
+
PAYLOAD = (
|
|
1099
|
+
b'[[images]]\nname = "demo"\nsrc = "https://example/demo.img.zst"\n'
|
|
1100
|
+
b'format = "img.zst"\n\n'
|
|
1101
|
+
b'[[images]]\nname = "other"\nsrc = "https://example/other.img.zst"\n'
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
def do_GET(self):
|
|
1105
|
+
if self.path.endswith("/broken.toml"):
|
|
1106
|
+
self.send_response(200)
|
|
1107
|
+
self.send_header("Content-Length", "10")
|
|
1108
|
+
self.end_headers()
|
|
1109
|
+
self.wfile.write(b"not a toml")
|
|
1110
|
+
return
|
|
1111
|
+
self.send_response(200)
|
|
1112
|
+
self.send_header("Content-Type", "application/toml")
|
|
1113
|
+
self.send_header("Content-Length", str(len(self.PAYLOAD)))
|
|
1114
|
+
self.end_headers()
|
|
1115
|
+
self.wfile.write(self.PAYLOAD)
|
|
1116
|
+
|
|
1117
|
+
def log_message(self, format, *args):
|
|
1118
|
+
pass
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
class TestCatalogAdminEndpoints(unittest.TestCase):
|
|
1122
|
+
"""Cover the four catalog-admin endpoints
|
|
1123
|
+
(/admin/catalog_refresh, /admin/catalog_set_url,
|
|
1124
|
+
/admin/catalog_add_oras, /admin/catalog_delete_entry).
|
|
1125
|
+
|
|
1126
|
+
Only the shape assertions (form present + subnav layout) were
|
|
1127
|
+
covered before; the wire behaviour -- POST validates, updates
|
|
1128
|
+
state, and 303s back to the Catalog page -- had zero coverage.
|
|
1129
|
+
"""
|
|
1130
|
+
|
|
1131
|
+
def setUp(self):
|
|
1132
|
+
self.origin = socketserver.TCPServer(("127.0.0.1", 0), _CatalogOrigin)
|
|
1133
|
+
threading.Thread(target=self.origin.serve_forever, daemon=True).start()
|
|
1134
|
+
origin_port = self.origin.server_address[1]
|
|
1135
|
+
self.catalog_url = f"http://127.0.0.1:{origin_port}/catalog.toml"
|
|
1136
|
+
self.httpd, self.store = _start_withcache()
|
|
1137
|
+
# Re-point the catalog at the local origin so /admin/catalog_refresh
|
|
1138
|
+
# hits something predictable.
|
|
1139
|
+
self.httpd.catalog.url = self.catalog_url
|
|
1140
|
+
self.base = f"http://127.0.0.1:{self.httpd.server_address[1]}"
|
|
1141
|
+
|
|
1142
|
+
def tearDown(self):
|
|
1143
|
+
self.origin.shutdown()
|
|
1144
|
+
self.origin.server_close()
|
|
1145
|
+
self.httpd.shutdown()
|
|
1146
|
+
self.httpd.server_close()
|
|
1147
|
+
|
|
1148
|
+
def _post(self, path, form):
|
|
1149
|
+
body = urllib.parse.urlencode(form).encode()
|
|
1150
|
+
req = urllib.request.Request(
|
|
1151
|
+
self.base + path,
|
|
1152
|
+
data=body,
|
|
1153
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
1154
|
+
method="POST",
|
|
1155
|
+
)
|
|
1156
|
+
return urllib.request.urlopen(req)
|
|
1157
|
+
|
|
1158
|
+
def test_catalog_refresh_populates_entries(self):
|
|
1159
|
+
self.assertEqual(self.httpd.catalog.entries, [])
|
|
1160
|
+
with self._post("/admin/catalog_refresh", {}) as r:
|
|
1161
|
+
self.assertEqual(r.status, 200)
|
|
1162
|
+
names = [e["name"] for e in self.httpd.catalog.entries]
|
|
1163
|
+
self.assertEqual(names, ["demo", "other"])
|
|
1164
|
+
self.assertEqual(self.httpd.catalog.last_error, "")
|
|
1165
|
+
|
|
1166
|
+
def test_catalog_refresh_bad_toml_keeps_previous_entries(self):
|
|
1167
|
+
# Seed the catalog with a known good entry, then re-point at
|
|
1168
|
+
# a broken TOML endpoint and refresh; entries must survive.
|
|
1169
|
+
self.httpd.catalog.entries = [{"name": "keeper", "src": "https://x/y"}]
|
|
1170
|
+
origin_port = self.origin.server_address[1]
|
|
1171
|
+
self.httpd.catalog.url = f"http://127.0.0.1:{origin_port}/broken.toml"
|
|
1172
|
+
with self._post("/admin/catalog_refresh", {}) as r:
|
|
1173
|
+
self.assertEqual(r.status, 200)
|
|
1174
|
+
names = [e["name"] for e in self.httpd.catalog.entries]
|
|
1175
|
+
self.assertEqual(names, ["keeper"])
|
|
1176
|
+
self.assertTrue(self.httpd.catalog.last_error)
|
|
1177
|
+
|
|
1178
|
+
def test_catalog_set_url_persists_override_and_fetches(self):
|
|
1179
|
+
# No env pin, no persist path -> the URL update takes effect
|
|
1180
|
+
# in-process and an immediate fetch fires.
|
|
1181
|
+
with self._post("/admin/catalog_set_url", {"url": self.catalog_url}) as r:
|
|
1182
|
+
self.assertEqual(r.status, 200)
|
|
1183
|
+
self.assertEqual(self.httpd.catalog.url, self.catalog_url)
|
|
1184
|
+
# fetch_now already ran -> entries populated.
|
|
1185
|
+
self.assertEqual([e["name"] for e in self.httpd.catalog.entries], ["demo", "other"])
|
|
1186
|
+
|
|
1187
|
+
def test_catalog_set_url_rejects_empty(self):
|
|
1188
|
+
with self._post("/admin/catalog_set_url", {"url": " "}) as r:
|
|
1189
|
+
self.assertEqual(r.status, 200)
|
|
1190
|
+
self.assertIn("empty", self.httpd.catalog.last_error)
|
|
1191
|
+
|
|
1192
|
+
def test_catalog_set_url_rejects_non_http_scheme(self):
|
|
1193
|
+
with self._post("/admin/catalog_set_url", {"url": "ftp://x/y"}) as r:
|
|
1194
|
+
self.assertEqual(r.status, 200)
|
|
1195
|
+
self.assertIn("http", self.httpd.catalog.last_error)
|
|
1196
|
+
|
|
1197
|
+
def test_catalog_set_url_rejects_when_env_pinned(self):
|
|
1198
|
+
self.httpd.catalog.env_url = "https://env-pinned/catalog.toml"
|
|
1199
|
+
pinned_url = self.httpd.catalog.url
|
|
1200
|
+
override_attempt = "https://different/catalog.toml"
|
|
1201
|
+
with self._post("/admin/catalog_set_url", {"url": override_attempt}) as r:
|
|
1202
|
+
self.assertEqual(r.status, 200)
|
|
1203
|
+
self.assertIn("pinned", self.httpd.catalog.last_error)
|
|
1204
|
+
# URL was not overridden -- stays at whatever it was before.
|
|
1205
|
+
self.assertEqual(self.httpd.catalog.url, pinned_url)
|
|
1206
|
+
|
|
1207
|
+
def test_catalog_add_oras_rejects_non_oras_url(self):
|
|
1208
|
+
with self._post("/admin/catalog_add_oras", {"url": "https://example/x"}) as r:
|
|
1209
|
+
self.assertEqual(r.status, 200)
|
|
1210
|
+
self.assertIn("oras://", self.httpd.catalog.last_error)
|
|
1211
|
+
self.assertEqual(self.httpd.catalog.entries, [])
|
|
1212
|
+
|
|
1213
|
+
def test_catalog_add_oras_registers_entry_even_when_layer_probe_fails(self):
|
|
1214
|
+
# A well-formed oras URL that will fail the layer-probe fetch
|
|
1215
|
+
# (unreachable registry). The entry still lands per the
|
|
1216
|
+
# add_oras_entry contract; last_info records the reason.
|
|
1217
|
+
oras_url = "oras://127.0.0.1:1/safl/nosi/demo:v1"
|
|
1218
|
+
with self._post("/admin/catalog_add_oras", {"url": oras_url}) as r:
|
|
1219
|
+
self.assertEqual(r.status, 200)
|
|
1220
|
+
entries = self.httpd.catalog.entries
|
|
1221
|
+
self.assertEqual(len(entries), 1, f"expected 1 entry, got {entries!r}")
|
|
1222
|
+
self.assertEqual(entries[0]["src"], oras_url)
|
|
1223
|
+
self.assertEqual(self.httpd.catalog.last_error, "")
|
|
1224
|
+
|
|
1225
|
+
def test_catalog_delete_entry_removes_matching_row(self):
|
|
1226
|
+
self.httpd.catalog.entries = [
|
|
1227
|
+
{"name": "a", "src": "https://x/a"},
|
|
1228
|
+
{"name": "b", "src": "https://x/b"},
|
|
1229
|
+
]
|
|
1230
|
+
with self._post("/admin/catalog_delete_entry", {"name": "a"}) as r:
|
|
1231
|
+
self.assertEqual(r.status, 200)
|
|
1232
|
+
self.assertEqual([e["name"] for e in self.httpd.catalog.entries], ["b"])
|
|
1233
|
+
|
|
1234
|
+
def test_catalog_delete_entry_unknown_name_is_reported(self):
|
|
1235
|
+
self.httpd.catalog.entries = [{"name": "a", "src": "https://x/a"}]
|
|
1236
|
+
with self._post("/admin/catalog_delete_entry", {"name": "ghost"}) as r:
|
|
1237
|
+
self.assertEqual(r.status, 200)
|
|
1238
|
+
self.assertIn("ghost", self.httpd.catalog.last_error)
|
|
1239
|
+
|
|
1240
|
+
def test_admin_endpoints_require_auth_when_password_set(self):
|
|
1241
|
+
# Fresh httpd with auth enabled.
|
|
1242
|
+
httpd2, _ = _start_withcache()
|
|
1243
|
+
httpd2.auth = server.Auth(b"k", "letmein")
|
|
1244
|
+
base = f"http://127.0.0.1:{httpd2.server_address[1]}"
|
|
1245
|
+
for path in (
|
|
1246
|
+
"/admin/catalog_refresh",
|
|
1247
|
+
"/admin/catalog_set_url",
|
|
1248
|
+
"/admin/catalog_add_oras",
|
|
1249
|
+
"/admin/catalog_delete_entry",
|
|
1250
|
+
):
|
|
1251
|
+
with self.subTest(path=path):
|
|
1252
|
+
req = urllib.request.Request(
|
|
1253
|
+
base + path,
|
|
1254
|
+
data=b"",
|
|
1255
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
1256
|
+
method="POST",
|
|
1257
|
+
)
|
|
1258
|
+
with self.assertRaises(urllib.error.HTTPError) as cm:
|
|
1259
|
+
urllib.request.urlopen(req)
|
|
1260
|
+
self.assertEqual(cm.exception.code, 401)
|
|
1261
|
+
httpd2.shutdown()
|
|
1262
|
+
httpd2.server_close()
|
|
1263
|
+
|
|
1264
|
+
|
|
1091
1265
|
if __name__ == "__main__":
|
|
1092
1266
|
unittest.main(verbosity=2)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|