← Back to team overview

launchpad-reviewers team mailing list archive

[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
+    )