withcache 0.8.1__tar.gz → 0.8.2__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.8.1 → withcache-0.8.2}/PKG-INFO +1 -1
  2. {withcache-0.8.1 → withcache-0.8.2}/shim/build.zig.zon +1 -1
  3. {withcache-0.8.1 → withcache-0.8.2}/src/withcache/__init__.py +1 -1
  4. {withcache-0.8.1 → withcache-0.8.2}/src/withcache/server.py +4 -3
  5. {withcache-0.8.1 → withcache-0.8.2}/tests/test_withcache.py +174 -0
  6. {withcache-0.8.1 → withcache-0.8.2}/.gitignore +0 -0
  7. {withcache-0.8.1 → withcache-0.8.2}/LICENSE +0 -0
  8. {withcache-0.8.1 → withcache-0.8.2}/README.md +0 -0
  9. {withcache-0.8.1 → withcache-0.8.2}/deploy/Containerfile +0 -0
  10. {withcache-0.8.1 → withcache-0.8.2}/deploy/compose.yml +0 -0
  11. {withcache-0.8.1 → withcache-0.8.2}/hatch_build.py +0 -0
  12. {withcache-0.8.1 → withcache-0.8.2}/pyproject.toml +0 -0
  13. {withcache-0.8.1 → withcache-0.8.2}/shim/build.zig +0 -0
  14. {withcache-0.8.1 → withcache-0.8.2}/shim/shim.zig +0 -0
  15. {withcache-0.8.1 → withcache-0.8.2}/src/withcache/_shim.py +0 -0
  16. {withcache-0.8.1 → withcache-0.8.2}/src/withcache/client.py +0 -0
  17. {withcache-0.8.1 → withcache-0.8.2}/src/withcache/curlwithcache.py +0 -0
  18. {withcache-0.8.1 → withcache-0.8.2}/src/withcache/oras.py +0 -0
  19. {withcache-0.8.1 → withcache-0.8.2}/src/withcache/static/bootstrap-icons.min.css +0 -0
  20. {withcache-0.8.1 → withcache-0.8.2}/src/withcache/static/bootstrap.min.css +0 -0
  21. {withcache-0.8.1 → withcache-0.8.2}/src/withcache/static/fonts/bootstrap-icons.woff +0 -0
  22. {withcache-0.8.1 → withcache-0.8.2}/src/withcache/static/fonts/bootstrap-icons.woff2 +0 -0
  23. {withcache-0.8.1 → withcache-0.8.2}/src/withcache/static/htmx.min.js +0 -0
  24. {withcache-0.8.1 → withcache-0.8.2}/src/withcache/wgetwithcache.py +0 -0
  25. {withcache-0.8.1 → withcache-0.8.2}/tests/test_differential.py +0 -0
  26. {withcache-0.8.1 → withcache-0.8.2}/tests/test_oras.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: withcache
3
- Version: 0.8.1
3
+ Version: 0.8.2
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.8.1",
5
+ .version = "0.8.2",
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.1"
20
+ __version__ = "0.8.2"
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 and the
192
- same file can be re-served verbatim to consumers on a future
193
- ``GET /catalog.toml`` route.
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