You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/09/16 16:51:54 UTC
(tooling-trusted-releases) branch sbp updated: Add a revision manifest API endpoint
This is an automated email from the ASF dual-hosted git repository.
sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
The following commit(s) were added to refs/heads/sbp by this push:
new 4ee92612 Add a revision manifest API endpoint
4ee92612 is described below
commit 4ee926128ed32d6b1ea4d3be7ee1956e336216d7
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Wed Sep 16 17:51:19 2026 +0100
Add a revision manifest API endpoint
---
atr/api/__init__.py | 37 ++++++
atr/get/manifest.py | 12 ++
atr/models/api.py | 16 +++
tests/unit/test_api_release_manifest.py | 224 ++++++++++++++++++++++++++++++++
tests/unit/test_get_manifest.py | 11 ++
5 files changed, 300 insertions(+)
diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index ea9be780..c13cda9c 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -33,6 +33,7 @@ import sqlalchemy
import sqlmodel
import werkzeug.exceptions as exceptions
+import atr.attestable as attestable
import atr.blueprints.api as api
import atr.blueprints.api_auth as api_auth
import atr.cle as cle
@@ -1440,6 +1441,42 @@ async def release_get(
).model_dump(mode="json"), 200
+@api.typed(
+ auth_scheme=api_auth.Auth.PUBLIC,
+ response=(models.api.ReleaseManifestResults, 200),
+)
+async def release_manifest(
+ _release_manifest: Literal["release/manifest"],
+ project_key: safe.ProjectKey,
+ version_key: safe.VersionKey,
+ revision: safe.RevisionNumber,
+) -> DictResponse:
+ async with db.session() as data:
+ await data.release(project_key=str(project_key), version=str(version_key)).demand(
+ exceptions.NotFound(errors.RELEASE_NOT_FOUND)
+ )
+ recorded = await attestable.load(project_key, version_key, revision)
+ if recorded is None:
+ raise exceptions.NotFound(errors.MANIFEST_NOT_FOUND)
+ files = []
+ for path, content_hash in sorted(attestable.path_hashes(recorded).items()):
+ entry = recorded.hashes.get(content_hash)
+ files.append(
+ models.api.ReleaseManifestFile(
+ path=path,
+ size=entry.size if (entry is not None) else None,
+ digest=content_hash,
+ )
+ )
+ return models.api.ReleaseManifestResults(
+ endpoint="/release/manifest",
+ project=project_key,
+ version=version_key,
+ revision=revision,
+ files=files,
+ ).model_dump(mode="json"), 200
+
+
@api.typed(
auth_scheme=api_auth.Auth.PUBLIC,
response=(models.api.ReleasePathsResults, 200),
diff --git a/atr/get/manifest.py b/atr/get/manifest.py
index e1f3bea6..9b8cdec7 100644
--- a/atr/get/manifest.py
+++ b/atr/get/manifest.py
@@ -20,6 +20,7 @@ from typing import Literal
import asfquart.base as base
+import atr.api as api
import atr.attestable as attestable
import atr.blueprints.get as get
import atr.db as db
@@ -98,6 +99,17 @@ def _render_page(
".",
]
page.p[f"{util.plural(len(path_hashes), 'file')} recorded."]
+ if not release.is_embargoed:
+ page.p[
+ htm.a(
+ href=util.as_url(
+ api.release_manifest,
+ project_key=release.project_key,
+ version_key=release.version,
+ revision=revision_number,
+ )
+ )["JSON manifest"]
+ ]
if not path_hashes:
page.p(".text-muted")["This revision contains no files."]
return page.collect()
diff --git a/atr/models/api.py b/atr/models/api.py
index f9db48b5..02068d41 100644
--- a/atr/models/api.py
+++ b/atr/models/api.py
@@ -802,6 +802,22 @@ class ReleaseGetResults(schema.Strict):
return v
+class ReleaseManifestFile(schema.Strict):
+ path: str
+ size: int | None = schema.description("File size in bytes, or null when unavailable in the retained manifest.")
+ digest: str = schema.description("Content digest including its algorithm prefix, such as blake3:.")
+
+
+class ReleaseManifestResults(schema.Strict):
+ endpoint: Literal["/release/manifest"] = schema.alias("endpoint")
+ project: safe.ProjectKey
+ version: safe.VersionKey
+ revision: safe.RevisionNumber
+ files: Sequence[ReleaseManifestFile] = schema.description(
+ "All files in the requested revision, without pagination."
+ )
+
+
@dataclasses.dataclass
class ReleasePathsQuery:
offset: int = 0
diff --git a/tests/unit/test_api_release_manifest.py b/tests/unit/test_api_release_manifest.py
new file mode 100644
index 00000000..48d35c78
--- /dev/null
+++ b/tests/unit/test_api_release_manifest.py
@@ -0,0 +1,224 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import datetime
+import json
+import unittest.mock as mock
+
+import pytest
+import quart
+import quart_schema
+import sqlalchemy.ext.asyncio
+import sqlmodel
+
+import atr.attestable as attestable
+import atr.blueprints.api
+import atr.db as db
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.paths as paths
+
+
+@pytest.fixture
+async def app(monkeypatch, tmp_path):
+ engine = sqlalchemy.ext.asyncio.create_async_engine("sqlite+aiosqlite:///:memory:")
+ async with engine.begin() as connection:
+ await connection.run_sync(sqlmodel.SQLModel.metadata.create_all)
+ maker = sqlalchemy.ext.asyncio.async_sessionmaker(bind=engine, class_=db.Session, expire_on_commit=False)
+ async with maker() as data:
+ committee = sql.Committee(key="example", name="Example", committee_members=["member"])
+ project = sql.Project(key="example", name="Apache Example", committee=committee)
+ data.add(
+ sql.Release(
+ project=project,
+ project_key="example",
+ version="1.0",
+ phase=sql.ReleasePhase.RELEASE,
+ created=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
+ )
+ )
+ await data.commit()
+ monkeypatch.setattr(db, "session", maker)
+ monkeypatch.setattr(paths, "get_attestable_dir", lambda: safe.StatePath(tmp_path))
+ app = quart.Quart(__name__)
+ app.config["TESTING"] = True
+ app.config["QUART_SCHEMA_PYDANTIC_DUMP_OPTIONS"] = {"mode": "json"}
+ quart_schema.QuartSchema(app, openapi_path="/api/openapi.json")
+ atr.blueprints.api.register(app)
+ yield app
+ await engine.dispose()
+
+
+@pytest.mark.parametrize("phase", list(sql.ReleasePhase))
+async def test_embargo_blocks_before_manifest_access(app, tmp_path, monkeypatch, phase):
+ _write_manifest(tmp_path)
+ async with db.session() as data:
+ release = await data.release(project_key="example", version="1.0").demand(RuntimeError("missing release"))
+ release.expedited = True
+ release.phase = phase
+ await data.commit()
+ loader = mock.AsyncMock(wraps=attestable.load)
+ monkeypatch.setattr(attestable, "load", loader)
+ response = await app.test_client().get("/api/release/manifest/example/1.0/00001")
+ assert response.status_code == (200 if phase == sql.ReleasePhase.RELEASE else 404)
+ if phase != sql.ReleasePhase.RELEASE:
+ loader.assert_not_awaited()
+ assert (await response.get_json())["error"] == "Release does not exist"
+
+
+async def test_exact_revision_with_no_rows_or_candidate_files(app, tmp_path):
+ _write_manifest(tmp_path)
+ _write_manifest(tmp_path, revision="00002", payload={"version": 2, "paths": {}, "hashes": {}})
+ async with db.session() as data:
+ release = await data.release(project_key="example", version="1.0").demand(RuntimeError("missing release"))
+ assert release.latest_revision_number is None
+ assert await data.revision(release_key=release.key).all() == []
+ response = await app.test_client().get("/api/release/manifest/example/1.0/00001")
+ assert response.status_code == 200
+ assert len((await response.get_json())["files"]) == 3
+ response = await app.test_client().get("/api/release/manifest/example/1.0/00002")
+ assert response.status_code == 200
+ assert await response.get_json() == {
+ "endpoint": "/release/manifest",
+ "project": "example",
+ "version": "1.0",
+ "revision": "00002",
+ "files": [],
+ }
+ for suffix in ("", "/00003", "/1", "/latest"):
+ response = await app.test_client().get(f"/api/release/manifest/example/1.0{suffix}")
+ assert response.status_code == (400 if suffix == "/latest" else 404)
+ assert not (tmp_path / "unfinished").exists()
+
+
+@pytest.mark.parametrize("version", [1, 2])
+@pytest.mark.parametrize("phase", list(sql.ReleasePhase))
+async def test_manifest_projects_only_public_file_identity(app, tmp_path, version, phase):
+ _write_manifest(tmp_path, version=version)
+ async with db.session() as data:
+ release = await data.release(project_key="example", version="1.0").demand(RuntimeError("missing release"))
+ release.phase = phase
+ release.is_archived = phase == sql.ReleasePhase.RELEASE
+ await data.commit()
+ response = await app.test_client().get("/api/release/manifest/example/1.0/00001")
+ assert response.status_code == 200
+ assert response.mimetype == "application/json"
+ assert await response.get_json() == {
+ "endpoint": "/release/manifest",
+ "project": "example",
+ "version": "1.0",
+ "revision": "00001",
+ "files": [
+ {"path": "bundle.zip", "size": 102, "digest": "blake3:" + "2" * 64},
+ {"path": "bundle.zip.asc", "size": 101, "digest": "blake3:" + "1" * 64},
+ {"path": "bundle.zip.sha512", "size": 100, "digest": "blake3:" + "0" * 64},
+ ],
+ }
+
+
+@pytest.mark.parametrize("payload", [None, "{bad", '{"version": 2, "paths": []}', "[]"])
+async def test_missing_or_malformed_manifest_is_json_404(app, tmp_path, payload):
+ _write_manifest(tmp_path, revision="00002")
+ if payload is not None:
+ (tmp_path / "example" / "1.0" / "00001.json").write_text(payload)
+ response = await app.test_client().get("/api/release/manifest/example/1.0/00001")
+ assert response.status_code == 404
+ assert (await response.get_json())["error"] == "No file manifest is available for this ATR revision"
+
+
+@pytest.mark.parametrize("query", ["", "?limit=1&offset=1000"])
+async def test_no_pagination_or_truncation(app, tmp_path, query):
+ digest = "blake3:" + "a" * 64
+ _write_manifest(
+ tmp_path,
+ payload={
+ "version": 1,
+ "paths": {f"file-{index:04}.txt": digest for index in reversed(range(1251))},
+ "hashes": {digest: {"size": 7, "uploaders": []}},
+ },
+ )
+ response = await app.test_client().get(f"/api/release/manifest/example/1.0/00001{query}")
+ assert response.status_code == 200
+ body = await response.get_json()
+ assert body["files"] == [{"path": f"file-{index:04}.txt", "size": 7, "digest": digest} for index in range(1251)]
+
+
+async def test_openapi_declares_required_revision_and_response(app):
+ response = await app.test_client().get("/api/openapi.json")
+ assert response.status_code == 200
+ document = await response.get_json()
+ operation = document["paths"]["/api/release/manifest/{project_key}/{version_key}/{revision}"]["get"]
+ assert {(param["name"], param["in"], param["required"]) for param in operation["parameters"]} == {
+ ("project_key", "path", True),
+ ("version_key", "path", True),
+ ("revision", "path", True),
+ }
+ assert not operation.get("security")
+ schema = operation["responses"]["200"]["content"]["application/json"]["schema"]
+ assert schema["title"] == "ReleaseManifestResults"
+ components = document["components"]["schemas"]
+ assert set(schema["properties"]) == {"endpoint", "project", "version", "revision", "files"}
+ assert schema["properties"]["files"]["items"]["$ref"].endswith("/ReleaseManifestFile")
+ assert set(components["ReleaseManifestFile"]["properties"]) == {"path", "size", "digest"}
+
+
+async def test_unknown_release_does_not_load_manifest(app, monkeypatch):
+ loader = mock.AsyncMock()
+ monkeypatch.setattr(attestable, "load", loader)
+ response = await app.test_client().get("/api/release/manifest/unknown/1.0/00001")
+ assert response.status_code == 404
+ assert (await response.get_json())["error"] == "Release does not exist"
+ loader.assert_not_awaited()
+
+
+async def test_unknown_size_and_unusual_paths_preserve_values(app, tmp_path):
+ path = "dir/Über <test> & source.zip"
+ digest = "sha256:" + "b" * 64
+ _write_manifest(tmp_path, payload={"version": 1, "paths": {path: digest}, "hashes": {}})
+ response = await app.test_client().get("/api/release/manifest/example/1.0/00001")
+ assert response.status_code == 200
+ assert (await response.get_json())["files"] == [{"path": path, "size": None, "digest": digest}]
+
+
+def _write_manifest(tmp_path, *, version=2, revision="00001", payload=None):
+ if payload is None:
+ path_hashes = {
+ name: "blake3:" + str(index) * 64
+ for index, name in enumerate(("bundle.zip.sha512", "bundle.zip.asc", "bundle.zip"))
+ }
+ payload = {
+ "version": version,
+ "paths": path_hashes
+ if version == 1
+ else {
+ name: {
+ "content_hash": digest,
+ "classification": "private-classification",
+ "provenance": {"generator": "SHA512_from_content", "metadata": {"private-provenance": True}},
+ }
+ for name, digest in path_hashes.items()
+ },
+ "hashes": {
+ digest: {"size": 100 + index, "uploaders": [["private-uploader", "00001"]]}
+ for index, digest in enumerate(path_hashes.values())
+ },
+ "policy": {"private-policy": True},
+ }
+ payload["hashes"]["blake3:" + "a" * 64] = {"size": 1, "uploaders": [], "basenames": ["obsolete.zip"]}
+ directory = tmp_path / "example" / "1.0"
+ directory.mkdir(parents=True, exist_ok=True)
+ (directory / f"{revision}.json").write_text(json.dumps(payload))
diff --git a/tests/unit/test_get_manifest.py b/tests/unit/test_get_manifest.py
index 326b6f42..4d7b67a0 100644
--- a/tests/unit/test_get_manifest.py
+++ b/tests/unit/test_get_manifest.py
@@ -30,6 +30,7 @@ import quart
import sqlalchemy.ext.asyncio
import sqlmodel
+import atr.api as api
import atr.attestable as attestable
import atr.blueprints.common as common
import atr.db as db
@@ -79,6 +80,11 @@ async def app(monkeypatch, tmp_path):
jinja2.FileSystemLoader(_TEMPLATES),
]
)
+ app.add_url_rule(
+ "/api/release/manifest/<project_key>/<version_key>/<revision>",
+ endpoint=api.release_manifest.endpoint,
+ view_func=api.release_manifest,
+ )
app.add_url_rule(
"/manifest/<project_key>/<version_key>/<revision_number>",
endpoint=manifest.selected.endpoint,
@@ -107,6 +113,9 @@ async def test_embargo_uses_existing_visibility_rules(app, tmp_path, monkeypatch
assert response.status_code == (200 if permitted else 404)
if not permitted:
loader.assert_not_awaited()
+ else:
+ body = await response.get_data(as_text=True)
+ assert ("JSON manifest" in body) == (phase == sql.ReleasePhase.RELEASE)
async def test_empty_manifest(app, tmp_path):
@@ -116,6 +125,7 @@ async def test_empty_manifest(app, tmp_path):
assert response.status_code == 200
assert "0 files recorded." in body
assert "This revision contains no files." in body
+ assert '<a href="/api/release/manifest/example/1.0/00001">JSON manifest</a>' in body
assert "File manifest pages" not in body
@@ -236,6 +246,7 @@ async def test_pagination(app, tmp_path, query, limit, offset, shown, previous_o
body = html.unescape(await response.get_data(as_text=True))
assert response.status_code == 200
assert "513 files recorded." in body
+ assert '<a href="/api/release/manifest/example/1.0/00001">JSON manifest</a>' in body
assert [int(index) for index in re.findall(r">file-(\d+)\.txt</code>", body)] == list(range(offset, offset + shown))
if shown:
assert f"Showing files {offset + 1} to {offset + shown} of 513." in body
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org