Merge ~cjwatson/lp-archive:translate-paths into lp-archive: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)
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
1diff --git a/.gitignore b/.gitignore
2index 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
13diff --git a/.mypy.ini b/.mypy.ini
14new file mode 100644
15index 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
24diff --git a/lp_archive/__init__.py b/lp_archive/__init__.py
25index 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
56diff --git a/lp_archive/archive.py b/lp_archive/archive.py
57new file mode 100644
58index 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
142diff --git a/lp_archive/routing.py b/lp_archive/routing.py
143new file mode 100644
144index 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
171diff --git a/requirements.in b/requirements.in
172index e3e9a71..da656c4 100644
173--- a/requirements.in
174+++ b/requirements.in
175@@ -1 +1,2 @@
176 Flask
177+tomli; python_version < "3.11"
178diff --git a/requirements.txt b/requirements.txt
179index 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
190diff --git a/setup.cfg b/setup.cfg
191index 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]
202diff --git a/tests/conftest.py b/tests/conftest.py
203index 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
216diff --git a/tests/test_archive.py b/tests/test_archive.py
217new file mode 100644
218index 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+ ]
343diff --git a/tests/test_factory.py b/tests/test_factory.py
344index 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
373diff --git a/tests/test_routing.py b/tests/test_routing.py
374new file mode 100644
375index 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

Subscribers

People subscribed via source and target branches