Merge ~cjwatson/lp-archive:translate-paths into lp-archive:main
- Git
- lp:~cjwatson/lp-archive
- translate-paths
- Merge into main
Proposed by
Colin Watson
Status: | Merged |
---|---|
Merged at revision: | e586b91ccc7fb4c6caf4f006f44b49421b9ade45 |
Proposed branch: | ~cjwatson/lp-archive:translate-paths |
Merge into: | lp-archive:main |
Diff against target: |
419 lines (+303/-6) 12 files modified
.gitignore (+1/-0) .mypy.ini (+5/-0) lp_archive/__init__.py (+11/-2) lp_archive/archive.py (+80/-0) lp_archive/routing.py (+23/-0) requirements.in (+1/-0) requirements.txt (+2/-0) setup.cfg (+1/-0) tests/conftest.py (+1/-2) tests/test_archive.py (+121/-0) tests/test_factory.py (+16/-2) tests/test_routing.py (+41/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Jürgen Gmach | Approve | ||
Review via email: mp+431569@code.launchpad.net |
Commit message
Add archive endpoints
Description of the change
If configured with a suitable Launchpad XML-RPC endpoint, this can serve apt archives based on publishing records and the librarian without needing access to the published archive on a local file system.
To post a comment you must log in.
Revision history for this message
Jürgen Gmach (jugmac00) : | # |
review:
Approve
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | diff --git a/.gitignore b/.gitignore |
2 | index af6421f..2c2f17b 100644 |
3 | --- a/.gitignore |
4 | +++ b/.gitignore |
5 | @@ -3,6 +3,7 @@ |
6 | /.python-version |
7 | /.tox |
8 | /build |
9 | +/config.toml |
10 | /env |
11 | /htmlcov |
12 | /tmp |
13 | diff --git a/.mypy.ini b/.mypy.ini |
14 | new file mode 100644 |
15 | index 0000000..07a8171 |
16 | --- /dev/null |
17 | +++ b/.mypy.ini |
18 | @@ -0,0 +1,5 @@ |
19 | +[mypy] |
20 | +python_version = 3.10 |
21 | + |
22 | +[mypy-tomllib.*] |
23 | +ignore_missing_imports = true |
24 | diff --git a/lp_archive/__init__.py b/lp_archive/__init__.py |
25 | index 63a22b1..83c12ec 100644 |
26 | --- a/lp_archive/__init__.py |
27 | +++ b/lp_archive/__init__.py |
28 | @@ -3,16 +3,25 @@ |
29 | |
30 | """The Launchpad archive service.""" |
31 | |
32 | +try: |
33 | + import tomllib |
34 | +except ModuleNotFoundError: |
35 | + import tomli as tomllib # type: ignore |
36 | from typing import Any |
37 | |
38 | from flask import Flask |
39 | |
40 | -from lp_archive import root |
41 | +from lp_archive import archive, root, routing |
42 | |
43 | |
44 | def create_app(test_config: dict[str, Any] | None = None) -> Flask: |
45 | app = Flask(__name__) |
46 | - if test_config is not None: |
47 | + if test_config is None: |
48 | + with open("config.toml", "rb") as f: |
49 | + app.config.from_mapping(tomllib.load(f)) |
50 | + else: |
51 | app.config.from_mapping(test_config) |
52 | + app.url_map.converters["archive"] = routing.ArchiveConverter |
53 | app.register_blueprint(root.bp) |
54 | + app.register_blueprint(archive.bp) |
55 | return app |
56 | diff --git a/lp_archive/archive.py b/lp_archive/archive.py |
57 | new file mode 100644 |
58 | index 0000000..cf38f7a |
59 | --- /dev/null |
60 | +++ b/lp_archive/archive.py |
61 | @@ -0,0 +1,80 @@ |
62 | +# Copyright 2022 Canonical Ltd. This software is licensed under the |
63 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
64 | + |
65 | +"""The main archive view.""" |
66 | + |
67 | +from xmlrpc.client import Fault, ServerProxy |
68 | + |
69 | +from flask import Blueprint, current_app, g, request |
70 | +from werkzeug.datastructures import WWWAuthenticate |
71 | +from werkzeug.exceptions import Unauthorized |
72 | +from werkzeug.wrappers import Response |
73 | + |
74 | +bp = Blueprint("archive", __name__) |
75 | + |
76 | + |
77 | +def get_archive_proxy() -> ServerProxy: |
78 | + archive_proxy = getattr(g, "archive_proxy", None) |
79 | + if archive_proxy is None: |
80 | + archive_proxy = ServerProxy( |
81 | + current_app.config["ARCHIVE_ENDPOINT"], allow_none=True |
82 | + ) |
83 | + g.archive_proxy = archive_proxy |
84 | + return archive_proxy |
85 | + |
86 | + |
87 | +def check_auth(archive: str) -> None: |
88 | + """Check whether the current request may access an archive. |
89 | + |
90 | + If unauthorized, raises a suitable exception. |
91 | + """ |
92 | + # Ideally we'd use a Flask extension for this rather than rolling most |
93 | + # of the HTTP Basic Authentication logic for ourselves, but nothing |
94 | + # seems to be quite suitable. In particular, `flask-httpauth` doesn't |
95 | + # currently support passing additional data from the route through to |
96 | + # `verify_password`. |
97 | + if request.authorization is None: |
98 | + username = None |
99 | + password = None |
100 | + log_prefix = f"<anonymous>@{archive}" |
101 | + else: |
102 | + username = request.authorization.username |
103 | + password = request.authorization.password |
104 | + log_prefix = f"{username}@{archive}" |
105 | + try: |
106 | + # XXX cjwatson 2022-10-14: We should cache positive responses (maybe |
107 | + # using `flask-caching`) for a while to reduce database load. |
108 | + get_archive_proxy().checkArchiveAuthToken(archive, username, password) |
109 | + except Fault as e: |
110 | + if e.faultCode == 410: # Unauthorized |
111 | + current_app.logger.info("%s: Password does not match.", log_prefix) |
112 | + else: |
113 | + # Interpret any other fault as NotFound (320). |
114 | + current_app.logger.info("%s: %s", log_prefix, e.faultString) |
115 | + basic = WWWAuthenticate() |
116 | + basic.set_basic() |
117 | + raise Unauthorized(www_authenticate=basic) |
118 | + else: |
119 | + current_app.logger.info("%s: Authorized.", log_prefix) |
120 | + |
121 | + |
122 | +# The exact details of the URLs used here should be regarded as a proof of |
123 | +# concept for now. |
124 | +@bp.route("/<archive:archive>/<path:path>") |
125 | +def translate(archive: str, path: str) -> tuple[str, int, dict[str, str]]: |
126 | + check_auth(archive) |
127 | + try: |
128 | + url = get_archive_proxy().translatePath(archive, path) |
129 | + except Fault as f: |
130 | + if f.faultCode == 320: # NotFound |
131 | + return "Not found", 404, {"Content-Type": "text/plain"} |
132 | + else: |
133 | + return "Internal server error", 500, {"Content-Type": "text/plain"} |
134 | + assert isinstance(url, str) |
135 | + return "", 307, {"Location": url} |
136 | + |
137 | + |
138 | +@bp.after_request |
139 | +def add_headers(response: Response) -> Response: |
140 | + response.headers["Vary"] = "Authorization" |
141 | + return response |
142 | diff --git a/lp_archive/routing.py b/lp_archive/routing.py |
143 | new file mode 100644 |
144 | index 0000000..a3be94d |
145 | --- /dev/null |
146 | +++ b/lp_archive/routing.py |
147 | @@ -0,0 +1,23 @@ |
148 | +# Copyright 2022 Canonical Ltd. This software is licensed under the |
149 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
150 | + |
151 | +"""Routing helpers.""" |
152 | + |
153 | +from werkzeug.routing import BaseConverter |
154 | + |
155 | + |
156 | +class ArchiveConverter(BaseConverter): |
157 | + """Match an archive reference. |
158 | + |
159 | + See `lp.soyuz.model.archive.Archive.reference` in Launchpad. |
160 | + """ |
161 | + |
162 | + # This doesn't currently support the partner/copy archive reference |
163 | + # syntax (distribution/archive), since it's hard to avoid that being |
164 | + # ambiguous when parsing URLs (compare with the primary archive |
165 | + # reference syntax). |
166 | + # |
167 | + # PPA: ~[^/]+/[^/]+/[^/]+ (~owner/distribution/archive) |
168 | + # Primary: [^~+][^/]* (distribution) |
169 | + regex = r"~[^/]+/[^/]+/[^/]+|[^~+][^/]*" |
170 | + part_isolating = False |
171 | diff --git a/requirements.in b/requirements.in |
172 | index e3e9a71..da656c4 100644 |
173 | --- a/requirements.in |
174 | +++ b/requirements.in |
175 | @@ -1 +1,2 @@ |
176 | Flask |
177 | +tomli; python_version < "3.11" |
178 | diff --git a/requirements.txt b/requirements.txt |
179 | index 27acd36..73d76c5 100644 |
180 | --- a/requirements.txt |
181 | +++ b/requirements.txt |
182 | @@ -16,5 +16,7 @@ markupsafe==2.1.1 |
183 | # via |
184 | # jinja2 |
185 | # werkzeug |
186 | +tomli==2.0.1 ; python_version < "3.11" |
187 | + # via -r requirements.in |
188 | werkzeug==2.2.2 |
189 | # via flask |
190 | diff --git a/setup.cfg b/setup.cfg |
191 | index 8410614..f54019b 100644 |
192 | --- a/setup.cfg |
193 | +++ b/setup.cfg |
194 | @@ -18,6 +18,7 @@ classifiers = |
195 | packages = find: |
196 | install_requires = |
197 | Flask |
198 | + tomli;python_version < "3.11" |
199 | python_requires = >=3.10 |
200 | |
201 | [options.extras_require] |
202 | diff --git a/tests/conftest.py b/tests/conftest.py |
203 | index d8c9780..2006629 100644 |
204 | --- a/tests/conftest.py |
205 | +++ b/tests/conftest.py |
206 | @@ -8,8 +8,7 @@ from lp_archive import create_app |
207 | |
208 | @pytest.fixture() |
209 | def app(): |
210 | - app = create_app() |
211 | - app.config.update({"TESTING": True}) |
212 | + app = create_app({"TESTING": True}) |
213 | yield app |
214 | |
215 | |
216 | diff --git a/tests/test_archive.py b/tests/test_archive.py |
217 | new file mode 100644 |
218 | index 0000000..22bd8e1 |
219 | --- /dev/null |
220 | +++ b/tests/test_archive.py |
221 | @@ -0,0 +1,121 @@ |
222 | +# Copyright 2022 Canonical Ltd. This software is licensed under the |
223 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
224 | + |
225 | +from threading import Thread |
226 | +from typing import Any |
227 | +from xmlrpc.client import Fault |
228 | +from xmlrpc.server import SimpleXMLRPCServer |
229 | + |
230 | +import pytest |
231 | + |
232 | + |
233 | +class ArchiveXMLRPCServer(SimpleXMLRPCServer): |
234 | + |
235 | + path_map = {"dists/focal/InRelease": "http://librarian.example.org/1"} |
236 | + |
237 | + def __init__(self, *args: Any, **kwargs: Any) -> None: |
238 | + super().__init__(*args, **kwargs) |
239 | + self.call_log = [] |
240 | + self.register_function( |
241 | + self.check_archive_auth_token, name="checkArchiveAuthToken" |
242 | + ) |
243 | + self.register_function(self.translate_path, name="translatePath") |
244 | + |
245 | + def check_archive_auth_token(self, archive, username, password): |
246 | + self.call_log.append( |
247 | + ("checkArchiveAuthToken", archive, username, password) |
248 | + ) |
249 | + if archive.endswith("/private"): |
250 | + raise Fault(410, "Authorization required") |
251 | + elif archive.endswith("/nonexistent"): |
252 | + raise Fault(320, "Not found") |
253 | + else: |
254 | + return True |
255 | + |
256 | + def translate_path(self, archive, path): |
257 | + # See `lp.xmlrpc.faults` in Launchpad for fault codes. |
258 | + self.call_log.append(("translatePath", archive, path)) |
259 | + if path == "oops": |
260 | + raise Fault(380, "Oops") |
261 | + elif path in self.path_map: |
262 | + return self.path_map[path] |
263 | + else: |
264 | + raise Fault(320, "Not found") |
265 | + |
266 | + |
267 | +@pytest.fixture |
268 | +def archive_proxy(app): |
269 | + with ArchiveXMLRPCServer(("127.0.0.1", 0)) as server: |
270 | + host, port = server.server_address |
271 | + app.config.update({"ARCHIVE_ENDPOINT": f"http://{host}:{port}"}) |
272 | + thread = Thread(target=server.serve_forever) |
273 | + thread.start() |
274 | + yield server |
275 | + server.shutdown() |
276 | + thread.join() |
277 | + |
278 | + |
279 | +def test_auth_failed(client, archive_proxy): |
280 | + response = client.get( |
281 | + "/~user/ubuntu/private/dists/focal/InRelease", auth=("user", "secret") |
282 | + ) |
283 | + assert response.status_code == 401 |
284 | + assert ( |
285 | + response.headers["WWW-Authenticate"] |
286 | + == 'Basic realm="authentication required"' |
287 | + ) |
288 | + assert response.headers["Vary"] == "Authorization" |
289 | + assert archive_proxy.call_log == [ |
290 | + ("checkArchiveAuthToken", "~user/ubuntu/private", "user", "secret") |
291 | + ] |
292 | + |
293 | + |
294 | +def test_auth_not_found(client, archive_proxy): |
295 | + response = client.get( |
296 | + "/~user/ubuntu/nonexistent/dists/focal/InRelease", |
297 | + auth=("user", "secret"), |
298 | + ) |
299 | + assert response.status_code == 401 |
300 | + assert ( |
301 | + response.headers["WWW-Authenticate"] |
302 | + == 'Basic realm="authentication required"' |
303 | + ) |
304 | + assert response.headers["Vary"] == "Authorization" |
305 | + assert archive_proxy.call_log == [ |
306 | + ("checkArchiveAuthToken", "~user/ubuntu/nonexistent", "user", "secret") |
307 | + ] |
308 | + |
309 | + |
310 | +def test_translate(client, archive_proxy): |
311 | + response = client.get("/ubuntu/dists/focal/InRelease") |
312 | + assert response.status_code == 307 |
313 | + assert response.headers["Location"] == "http://librarian.example.org/1" |
314 | + assert response.headers["Vary"] == "Authorization" |
315 | + assert archive_proxy.call_log == [ |
316 | + ("checkArchiveAuthToken", "ubuntu", None, None), |
317 | + ("translatePath", "ubuntu", "dists/focal/InRelease"), |
318 | + ] |
319 | + |
320 | + |
321 | +def test_translate_not_found(client, archive_proxy): |
322 | + response = client.get("/ubuntu/nonexistent") |
323 | + assert response.status_code == 404 |
324 | + assert response.headers["Content-Type"] == "text/plain" |
325 | + assert response.headers["Vary"] == "Authorization" |
326 | + assert response.data == b"Not found" |
327 | + assert archive_proxy.call_log == [ |
328 | + ("checkArchiveAuthToken", "ubuntu", None, None), |
329 | + ("translatePath", "ubuntu", "nonexistent"), |
330 | + ] |
331 | + |
332 | + |
333 | +def test_translate_oops(client, archive_proxy): |
334 | + response = client.get("/ubuntu/oops") |
335 | + assert response.status_code == 500 |
336 | + assert response.headers["Content-Type"] == "text/plain" |
337 | + assert response.headers["Vary"] == "Authorization" |
338 | + assert response.data == b"Internal server error" |
339 | + assert archive_proxy.call_log == [ |
340 | + ("checkArchiveAuthToken", "ubuntu", None, None), |
341 | + ("translatePath", "ubuntu", "oops"), |
342 | + ] |
343 | diff --git a/tests/test_factory.py b/tests/test_factory.py |
344 | index 85d71ca..54275b9 100644 |
345 | --- a/tests/test_factory.py |
346 | +++ b/tests/test_factory.py |
347 | @@ -1,9 +1,23 @@ |
348 | # Copyright 2022 Canonical Ltd. This software is licensed under the |
349 | # GNU Affero General Public License version 3 (see the file LICENSE). |
350 | |
351 | +import os |
352 | +from pathlib import Path |
353 | +from tempfile import TemporaryDirectory |
354 | + |
355 | from lp_archive import create_app |
356 | |
357 | |
358 | -def test_config(): |
359 | - assert not create_app().testing |
360 | +def test_config_from_file(): |
361 | + with TemporaryDirectory() as empty: |
362 | + old_cwd = os.getcwd() |
363 | + try: |
364 | + os.chdir(empty) |
365 | + Path("config.toml").touch() |
366 | + assert not create_app().testing |
367 | + finally: |
368 | + os.chdir(old_cwd) |
369 | + |
370 | + |
371 | +def test_config_for_testing(): |
372 | assert create_app({"TESTING": True}).testing |
373 | diff --git a/tests/test_routing.py b/tests/test_routing.py |
374 | new file mode 100644 |
375 | index 0000000..e11ae4e |
376 | --- /dev/null |
377 | +++ b/tests/test_routing.py |
378 | @@ -0,0 +1,41 @@ |
379 | +# Copyright 2022 Canonical Ltd. This software is licensed under the |
380 | +# GNU Affero General Public License version 3 (see the file LICENSE). |
381 | + |
382 | +from flask import url_for |
383 | + |
384 | + |
385 | +def test_primary(app, client): |
386 | + @app.route("/+test/<archive:archive>") |
387 | + def index(archive): |
388 | + return archive |
389 | + |
390 | + response = client.get("/+test/ubuntu") |
391 | + assert response.status_code == 200 |
392 | + assert response.data == b"ubuntu" |
393 | + |
394 | + with app.test_request_context(): |
395 | + assert url_for("index", archive="ubuntu") == "/+test/ubuntu" |
396 | + |
397 | + |
398 | +def test_ppa(app, client): |
399 | + @app.route("/+test/<archive:archive>") |
400 | + def index(archive): |
401 | + return archive |
402 | + |
403 | + response = client.get("/+test/~owner/ubuntu/ppa") |
404 | + assert response.status_code == 200 |
405 | + assert response.data == b"~owner/ubuntu/ppa" |
406 | + |
407 | + with app.test_request_context(): |
408 | + assert ( |
409 | + url_for("index", archive="~owner/ubuntu/ppa") |
410 | + == "/+test/~owner/ubuntu/ppa" |
411 | + ) |
412 | + |
413 | + |
414 | +def test_invalid_archive(app, client): |
415 | + @app.route("/+test/<archive:archive>") |
416 | + def index(archive): # pragma: no cover |
417 | + return archive |
418 | + |
419 | + assert client.get("/+test/~owner/ubuntu").status_code == 404 |