launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #29627
[Merge] ~cjwatson/lp-archive:snapshot-urls into lp-archive:main
Colin Watson has proposed merging ~cjwatson/lp-archive:snapshot-urls into lp-archive:main.
Commit message:
Add optional timestamp URL segments
Requested reviews:
Launchpad code reviewers (launchpad-reviewers)
For more details, see:
https://code.launchpad.net/~cjwatson/lp-archive/+git/lp-archive/+merge/436649
These are compatible with the layout used by snapshot.debian.org.
This branch shouldn't land until https://code.launchpad.net/~cjwatson/launchpad/+git/launchpad/+merge/436591 is on production, since XML-RPC methods don't support named arguments and so the new third argument to `translatePath` would otherwise break.
--
Your team Launchpad code reviewers is requested to review the proposed merge of ~cjwatson/lp-archive:snapshot-urls into lp-archive:main.
diff --git a/lp_archive/__init__.py b/lp_archive/__init__.py
index dc1b052..f212cc2 100644
--- a/lp_archive/__init__.py
+++ b/lp_archive/__init__.py
@@ -25,6 +25,7 @@ def create_app(test_config: dict[str, Any] | None = None) -> Flask:
app.config.from_mapping(test_config)
app.url_map.converters["primary"] = routing.PrimaryArchiveConverter
app.url_map.converters["ppa"] = routing.PPAConverter
+ app.url_map.converters["timestamp"] = routing.TimestampConverter
root.init_app(app)
archive.init_app(app)
cache.init_app(app)
diff --git a/lp_archive/archive.py b/lp_archive/archive.py
index ab310d7..27de11f 100644
--- a/lp_archive/archive.py
+++ b/lp_archive/archive.py
@@ -3,6 +3,7 @@
"""The main archive view."""
+from datetime import datetime
from xmlrpc.client import Fault, ServerProxy
from flask import Flask, current_app, g, request
@@ -73,10 +74,12 @@ def check_auth(archive: str) -> None:
current_app.logger.info("%s: Authorized.", log_prefix)
-def translate(archive: str, path: str) -> tuple[str, int, dict[str, str]]:
+def translate(
+ archive: str, path: str, live_at: datetime | None = None
+) -> tuple[str, int, dict[str, str]]:
check_auth(archive)
try:
- url = get_archive_proxy().translatePath(archive, path)
+ url = get_archive_proxy().translatePath(archive, path, live_at)
except Fault as f:
if f.faultCode == 320: # NotFound
return "Not found", 404, {"Content-Type": "text/plain"}
@@ -94,6 +97,11 @@ def add_headers(response: Response) -> Response:
def init_app(app: Flask) -> None:
for layout in app.config.get("LAYOUTS", []):
app.add_url_rule(
+ f"/<{layout['purpose']}:archive>/<timestamp:live_at>/<path:path>",
+ host=layout["host"],
+ view_func=translate,
+ )
+ app.add_url_rule(
f"/<{layout['purpose']}:archive>/<path:path>",
host=layout["host"],
view_func=translate,
diff --git a/lp_archive/routing.py b/lp_archive/routing.py
index 0b140ce..9cf5caf 100644
--- a/lp_archive/routing.py
+++ b/lp_archive/routing.py
@@ -3,6 +3,8 @@
"""Routing helpers."""
+from datetime import datetime, timezone
+
from werkzeug.routing import BaseConverter
@@ -24,3 +26,20 @@ class PPAConverter(BaseConverter):
# ~owner/distribution/archive
regex = r"~[^/]+/[^/]+/[^/]+"
part_isolating = False
+
+
+class TimestampConverter(BaseConverter):
+ """Match a timestamp.
+
+ This must accept at least the syntax used by snapshot.debian.org.
+ """
+
+ regex = r"[0-9]{8}T[0-9]{6}Z"
+
+ def to_python(self, value: str) -> datetime:
+ return datetime.strptime(value, "%Y%m%dT%H%M%SZ").replace(
+ tzinfo=timezone.utc
+ )
+
+ def to_url(self, value: datetime) -> str:
+ return value.strftime("%Y%m%dT%H%M%SZ")
diff --git a/tests/test_archive.py b/tests/test_archive.py
index 0ac6439..e5ef1dc 100644
--- a/tests/test_archive.py
+++ b/tests/test_archive.py
@@ -1,6 +1,7 @@
# Copyright 2022 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
+from datetime import datetime, timezone
from threading import Thread
from typing import Any
from xmlrpc.client import Fault
@@ -34,9 +35,11 @@ class ArchiveXMLRPCServer(SimpleXMLRPCServer):
else:
return True
- def translate_path(self, archive, path):
+ def translate_path(
+ self, archive: str, path: str, live_at: datetime | None = None
+ ) -> str:
# See `lp.xmlrpc.faults` in Launchpad for fault codes.
- self.call_log.append(("translatePath", archive, path))
+ self.call_log.append(("translatePath", archive, path, live_at))
if path == "oops":
raise Fault(380, "Oops")
elif path in self.path_map:
@@ -101,7 +104,12 @@ def test_auth_positive_cached(client, archive_proxy):
assert response.status_code == 307
assert archive_proxy.call_log == [
("checkArchiveAuthToken", "~user/ubuntu/authorized", "user", "secret"),
- ("translatePath", "~user/ubuntu/authorized", "dists/focal/InRelease"),
+ (
+ "translatePath",
+ "~user/ubuntu/authorized",
+ "dists/focal/InRelease",
+ None,
+ ),
]
archive_proxy.call_log = []
response = client.get(
@@ -111,7 +119,12 @@ def test_auth_positive_cached(client, archive_proxy):
)
assert response.status_code == 307
assert archive_proxy.call_log == [
- ("translatePath", "~user/ubuntu/authorized", "dists/focal/InRelease"),
+ (
+ "translatePath",
+ "~user/ubuntu/authorized",
+ "dists/focal/InRelease",
+ None,
+ ),
]
@@ -147,7 +160,7 @@ def test_translate(client, archive_proxy):
assert response.headers["Vary"] == "Authorization"
assert archive_proxy.call_log == [
("checkArchiveAuthToken", "ubuntu", None, None),
- ("translatePath", "ubuntu", "dists/focal/InRelease"),
+ ("translatePath", "ubuntu", "dists/focal/InRelease", None),
]
@@ -161,7 +174,26 @@ def test_translate_not_found(client, archive_proxy):
assert response.data == b"Not found"
assert archive_proxy.call_log == [
("checkArchiveAuthToken", "ubuntu", None, None),
- ("translatePath", "ubuntu", "nonexistent"),
+ ("translatePath", "ubuntu", "nonexistent", None),
+ ]
+
+
+def test_translate_at_timestamp(client, archive_proxy):
+ response = client.get(
+ "/ubuntu/20220101T120000Z/dists/focal/InRelease",
+ headers=[("Host", "snapshot.ubuntu.test")],
+ )
+ assert response.status_code == 307
+ assert response.headers["Location"] == "http://librarian.example.org/1"
+ assert response.headers["Vary"] == "Authorization"
+ assert archive_proxy.call_log == [
+ ("checkArchiveAuthToken", "ubuntu", None, None),
+ (
+ "translatePath",
+ "ubuntu",
+ "dists/focal/InRelease",
+ datetime(2022, 1, 1, 12, 0, 0, tzinfo=timezone.utc),
+ ),
]
@@ -175,5 +207,5 @@ def test_translate_oops(client, archive_proxy):
assert response.data == b"Internal server error"
assert archive_proxy.call_log == [
("checkArchiveAuthToken", "ubuntu", None, None),
- ("translatePath", "ubuntu", "oops"),
+ ("translatePath", "ubuntu", "oops", None),
]
diff --git a/tests/test_routing.py b/tests/test_routing.py
index a97c789..11f9c9a 100644
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -1,6 +1,8 @@
# Copyright 2022 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
+from datetime import datetime, timezone
+
from flask import url_for
@@ -77,3 +79,50 @@ def test_ppa_invalid(app, client):
).status_code
== 404
)
+
+
+def test_timestamp(app, client):
+ @app.route("/+test/<timestamp:timestamp>", host="snapshot.ubuntu.test")
+ def index(timestamp):
+ return timestamp.isoformat()
+
+ response = client.get(
+ "/+test/20220101T123000Z", headers=[("Host", "snapshot.ubuntu.test")]
+ )
+ assert response.status_code == 200
+ assert response.data == b"2022-01-01T12:30:00+00:00"
+
+ with app.test_request_context():
+ assert (
+ url_for(
+ "index",
+ timestamp=datetime(2022, 1, 1, 12, 30, 0, tzinfo=timezone.utc),
+ )
+ == "http://snapshot.ubuntu.test/+test/20220101T123000Z"
+ )
+
+
+def test_timestamp_invalid(app, client):
+ @app.route("/+test/<timestamp:timestamp>", host="snapshot.ubuntu.test")
+ def index(timestamp): # pragma: no cover
+ return timestamp
+
+ assert (
+ client.get(
+ "/+test/nonsense", headers=[("Host", "snapshot.ubuntu.test")]
+ ).status_code
+ == 404
+ )
+ assert (
+ client.get(
+ "/+test/20220101", headers=[("Host", "snapshot.ubuntu.test")]
+ ).status_code
+ == 404
+ )
+ assert (
+ client.get(
+ "/+test/20220101T120000",
+ headers=[("Host", "snapshot.ubuntu.test")],
+ ).status_code
+ == 404
+ )