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/21 13:49:17 UTC
(tooling-trusted-releases) branch sbp updated (4b960ea0 -> 97295e25)
This is an automated email from the ASF dual-hosted git repository.
sbp pushed a change to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git
from 4b960ea0 Bump anchore/sbom-action from 0.24.0 to 0.24.2
add 3ba11e78 Add getting started docs
new 97295e25 Add an admin tab showing committees allowed to make automated releases
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
atr/admin/__init__.py | 62 +++++++++++
atr/db/interaction.py | 54 +++++++---
atr/docs/getting-started.md | 21 ++++
tests/unit/test_admin_reproducible.py | 196 ++++++++++++++++++++++++++++++++++
4 files changed, 318 insertions(+), 15 deletions(-)
create mode 100644 tests/unit/test_admin_reproducible.py
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org
(tooling-trusted-releases) 01/01: Add an admin tab showing committees allowed to make automated releases
Posted by sb...@apache.org.
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
commit 97295e25939b4a1611aa71f5cc32b582a8ccb432
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Mon Sep 21 14:48:11 2026 +0100
Add an admin tab showing committees allowed to make automated releases
---
atr/admin/__init__.py | 62 +++++++++++
atr/db/interaction.py | 54 +++++++---
tests/unit/test_admin_reproducible.py | 196 ++++++++++++++++++++++++++++++++++
3 files changed, 297 insertions(+), 15 deletions(-)
diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 7a42b36a..b56d16ae 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -2000,6 +2000,7 @@ async def _catalog_page(active_tab: str, query_args: web.PageQuery) -> str:
htm.Tab("update-projects", "Update", _catalog_projects_update_tab),
htm.Tab("releases", "Current releases", lambda: _catalog_releases_tab(query_args)),
htm.Tab("admin", "Catalog admin", _catalog_admin_tab),
+ htm.Tab("reproducible-builds", "Reproducible builds", _catalog_reproducible_builds_tab),
htm.Tab("rebuild", "Rebuild catalog", _catalog_rebuild_tab),
]
page = htm.Block()
@@ -2074,6 +2075,67 @@ async def _catalog_releases_tab(query_args: web.PageQuery) -> htm.Element:
return htm.div[markupsafe.Markup(content)]
+async def _catalog_reproducible_builds_tab() -> htm.Element:
+ keys = await interaction.automated_release_signing_keys()
+ keys.sort(
+ key=lambda key: (
+ -key.created.timestamp() if key.created else float("inf"),
+ key.committee.display_name.casefold(),
+ key.committee.key,
+ key.fingerprint,
+ )
+ )
+ committees: dict[str, list[interaction.AutomatedReleaseSigningKey]] = {}
+ for key in keys:
+ committees.setdefault(key.committee.key, []).append(key)
+ bodies = [_catalog_reproducible_committee_rows(group) for group in committees.values()]
+ if not bodies:
+ bodies.append(htpy.tbody[htpy.tr[htpy.td(colspan="3")["No reproducible build signing keys recorded."]]])
+ return htpy.div(".table-responsive")[
+ htpy.table(".table.align-middle")[
+ htpy.thead[
+ htpy.tr[
+ htpy.th(scope="col")["Committee"],
+ htpy.th(scope="col")["Signing key"],
+ htpy.th(scope="col")["Primary key created (UTC)"],
+ ]
+ ],
+ bodies,
+ ]
+ ]
+
+
+def _catalog_reproducible_committee_rows(keys: list[interaction.AutomatedReleaseSigningKey]) -> htm.Element:
+ rows = []
+ for index, key in enumerate(keys):
+ cells = []
+ if index == 0:
+ cells.append(
+ htpy.th(scope="rowgroup", rowspan=str(len(keys)))[
+ htpy.a(href=util.as_url(get.committees.view, name=key.committee.key))[key.committee.display_name]
+ ]
+ )
+ cells.extend(
+ [
+ htpy.td(".font-monospace.text-break")[
+ htpy.a(
+ href=util.as_url(get.keys.details, fingerprint=key.fingerprint),
+ title=key.fingerprint.upper(),
+ )[key.fingerprint[-16:].upper()]
+ ],
+ htpy.td(".text-nowrap")[
+ htpy.time(datetime=key.created.isoformat(), title=key.created.isoformat())[
+ key.created.astimezone(datetime.UTC).strftime("%Y-%m-%d")
+ ]
+ if key.created
+ else "Not recorded"
+ ],
+ ]
+ )
+ rows.append(htpy.tr[cells])
+ return htpy.tbody[rows]
+
+
async def _catalog_site_rebuild(session: web.Committer) -> web.WerkzeugResponse:
try:
task = await tasks.catalog_site_generate_all(session.asf_uid)
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 1f6ef139..c0f427a3 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -64,6 +64,12 @@ class ApacheUserMissingError(RuntimeError):
self.primary_uid = primary_uid
+class AutomatedReleaseSigningKey(NamedTuple):
+ committee: sql.Committee
+ fingerprint: str
+ created: datetime.datetime | None
+
+
class InteractionError(RuntimeError):
pass
@@ -211,12 +217,37 @@ async def certificate_artifact_counts(data: db.Session, fingerprints: list[str])
async def automated_release_signing_committees(caller_data: db.Session | None = None) -> frozenset[str]:
"""Get all automated release signing committees."""
- committees = []
+ keys = await automated_release_signing_keys(caller_data)
+ committees = [key.committee.key for key in keys]
+
+ # Committees allowed to make automated releases for testing
+ committees.append("test")
+ committees.append("tooling")
+
+ return frozenset(committees)
+
+
+async def automated_release_signing_keys(caller_data: db.Session | None = None) -> list[AutomatedReleaseSigningKey]:
async with db.ensure_session(caller_data) as data:
via = sql.validate_instrumented_attribute
query = (
- sqlmodel.select(sql.SigningCertificate)
- .options(orm.selectinload(via(sql.SigningCertificate.committees)))
+ sqlmodel.select(
+ sql.Committee,
+ sql.SigningCertificate.fingerprint,
+ sql.SigningCertificate.primary_declared_uid,
+ sql.SigningKey.created,
+ )
+ .options(orm.load_only(via(sql.Committee.key), via(sql.Committee.name), via(sql.Committee.is_podling)))
+ .select_from(sql.SigningCertificate)
+ .join(sql.KeyLink, via(sql.KeyLink.key_fingerprint) == via(sql.SigningCertificate.fingerprint))
+ .join(sql.Committee, via(sql.Committee.key) == via(sql.KeyLink.committee_key))
+ .outerjoin(
+ sql.SigningKey,
+ sqlalchemy.and_(
+ via(sql.SigningKey.fingerprint) == via(sql.SigningCertificate.fingerprint),
+ via(sql.SigningKey.is_primary).is_(True),
+ ),
+ )
.where(
sqlalchemy.and_(
sqlalchemy.or_(
@@ -231,18 +262,11 @@ async def automated_release_signing_committees(caller_data: db.Session | None =
)
)
result = await data.execute(query)
- keys = result.scalars().all()
-
- for key in keys:
- for committee in key.committees:
- if util.is_automated_release_signing_uid(key.primary_declared_uid, committee.key):
- committees.append(committee.key)
-
- # Committees allowed to make automated releases for testing
- committees.append("test")
- committees.append("tooling")
-
- return frozenset(committees)
+ return [
+ AutomatedReleaseSigningKey(committee, fingerprint, created)
+ for committee, fingerprint, uid, created in result.all()
+ if util.is_automated_release_signing_uid(uid, committee.key)
+ ]
async def ballot_receipt_message_ids(
diff --git a/tests/unit/test_admin_reproducible.py b/tests/unit/test_admin_reproducible.py
new file mode 100644
index 00000000..09d73617
--- /dev/null
+++ b/tests/unit/test_admin_reproducible.py
@@ -0,0 +1,196 @@
+# 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 unittest.mock as mock
+
+import asfquart.base as base
+import pytest
+import quart
+import quart_schema
+import sqlalchemy
+import sqlalchemy.ext.asyncio
+import sqlmodel
+
+import atr.admin as admin
+import atr.blueprints.admin
+import atr.blueprints.common as common
+import atr.blueprints.get
+import atr.db as db
+import atr.db.interaction as interaction
+import atr.errors as errors
+import atr.get as get
+import atr.models.sql as sql
+import atr.sessions as sessions
+import atr.user as user
+
+
+async def _add_key(data, committee, fingerprint, created, *, uid=None, deleted=False):
+ data.add(
+ sql.SigningCertificate(
+ fingerprint=fingerprint,
+ primary_declared_uid=uid or f"Automated Release Signing <private@{committee}.apache.org>",
+ ascii_armored_key="",
+ deleted=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC) if deleted else None,
+ )
+ )
+ await data.flush()
+ data.add(sql.KeyLink(committee_key=committee, key_fingerprint=fingerprint))
+ if created is not None:
+ data.add(
+ sql.SigningKey(
+ fingerprint=fingerprint,
+ certificate_fingerprint=fingerprint,
+ is_primary=True,
+ key_id=fingerprint[-16:],
+ algorithm=1,
+ length=4096,
+ created=datetime.datetime(created, 1, 1, tzinfo=datetime.UTC),
+ )
+ )
+
+
+async def _render(_name, **context):
+ return str(context["content"])
+
+
+@pytest.fixture
+def app(database, monkeypatch):
+ application = quart.Quart(__name__)
+ application.config["TESTING"] = True
+ quart_schema.QuartSchema(application)
+ application.register_error_handler(base.ASFQuartException, errors.action_error_response)
+ application.register_blueprint(atr.blueprints.admin._BLUEPRINT)
+ application.register_blueprint(atr.blueprints.get._BLUEPRINT)
+ monkeypatch.setattr(sessions, "read", mock.AsyncMock(return_value=sql.UserSession(uid="admin")))
+ monkeypatch.setattr(user, "is_admin", lambda uid: uid == "admin")
+ monkeypatch.setattr(common, "authenticate", mock.AsyncMock())
+ monkeypatch.setattr(admin.template, "render", _render)
+ return application
+
+
+@pytest.fixture
+async def database(monkeypatch):
+ engine = sqlalchemy.ext.asyncio.create_async_engine("sqlite+aiosqlite:///:memory:")
+ async with engine.begin() as connection:
+ await connection.execute(sqlalchemy.text("PRAGMA foreign_keys=ON"))
+ await connection.run_sync(sqlmodel.SQLModel.metadata.create_all)
+ maker = sqlalchemy.ext.asyncio.async_sessionmaker(bind=engine, class_=db.Session, expire_on_commit=False)
+ monkeypatch.setattr(db, "session", maker)
+ yield maker
+ await engine.dispose()
+
+
+async def test_keys_preserve_recognition_and_primary_dates(database):
+ async with database() as data:
+ data.add_all([sql.Committee(key=key) for key in ("good", "missing", "other", "deleted", "secondary")])
+ await data.flush()
+ await _add_key(data, "good", "a" * 40, 2020, uid="Services RM <pr...@good.apache.org>")
+ await _add_key(data, "missing", "b" * 40, None)
+ await _add_key(data, "other", "c" * 40, 2026, uid="Automated Release Signing <pr...@good.apache.org>")
+ await _add_key(data, "deleted", "d" * 40, 2026, deleted=True)
+ await _add_key(data, "secondary", "e" * 40, 2026, uid="Ordinary <pr...@secondary.apache.org>")
+ secondary = await data.signing_certificate(fingerprint="e" * 40).get()
+ assert secondary is not None
+ secondary.secondary_declared_uids = ["Automated Release Signing <pr...@secondary.apache.org>"]
+ good = await data.signing_certificate(fingerprint="a" * 40).get()
+ assert good is not None
+ good.secondary_declared_uids = [
+ "Services RM <pr...@good.apache.org>",
+ "Automated Release Signing <pr...@good.apache.org>",
+ ]
+ data.add(sql.KeyLink(committee_key="other", key_fingerprint="a" * 40))
+ data.add(
+ sql.SigningKey(
+ fingerprint="f" * 40,
+ certificate_fingerprint="a" * 40,
+ is_primary=False,
+ key_id="f" * 16,
+ algorithm=1,
+ length=4096,
+ created=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
+ )
+ )
+ primary = await data.get(sql.SigningKey, "a" * 40)
+ assert primary is not None
+ primary.revoked = True
+ primary.expires = datetime.datetime(2021, 1, 1, tzinfo=datetime.UTC)
+ await data.commit()
+
+ keys = await interaction.automated_release_signing_keys()
+ assert len(keys) == 2
+ assert all(key.created.tzinfo is datetime.UTC for key in keys if key.created)
+ assert {(key.committee.key, key.fingerprint, key.created.year if key.created else None) for key in keys} == {
+ ("good", "a" * 40, 2020),
+ ("missing", "b" * 40, None),
+ }
+ assert await interaction.automated_release_signing_committees() == {"good", "missing", "test", "tooling"}
+
+
+async def test_reproducible_tab_empty_and_lazy(app, monkeypatch):
+ keys = mock.AsyncMock(return_value=[])
+ monkeypatch.setattr(interaction, "automated_release_signing_keys", keys)
+ response = await app.test_client().get("/admin/catalog?tab=admin")
+ assert response.status_code == 200
+ keys.assert_not_awaited()
+ response = await app.test_client().get("/admin/catalog?tab=reproducible-builds")
+ assert response.status_code == 200
+ assert 'colspan="3">No reproducible build signing keys recorded.' in await response.get_data(as_text=True)
+ keys.assert_awaited_once()
+
+
+async def test_reproducible_tab_groups_keys_newest_first_and_links(app, database):
+ async with database() as data:
+ data.add_all(
+ [sql.Committee(key=key, name=name) for key, name in (("a", "A <PMC>"), ("z", "Z"), ("b", "B"))]
+ + [sql.Committee(key="missing"), sql.Committee(key="test"), sql.Committee(key="tooling")]
+ )
+ await data.flush()
+ for committee, digit, year in (("a", "1", 2024), ("z", "2", 2026), ("z", "3", 2020), ("b", "4", 2024)):
+ await _add_key(data, committee, digit * 40, year)
+ await _add_key(data, "missing", "5" * 40, None)
+ await data.commit()
+
+ response = await app.test_client().get("/admin/catalog?tab=reproducible-builds&limit=1")
+ assert response.status_code == 200
+ body = await response.get_data(as_text=True)
+ assert body.index("2" * 40) < body.index("3" * 40) < body.index("1" * 40) < body.index("4" * 40)
+ assert body.index("4" * 40) < body.index("5" * 40)
+ assert 'rowspan="2"' in body
+ assert body.count('href="/committees/z"') == 1
+ assert "A <PMC>" in body
+ assert ">2026-01-01</time>" in body
+ assert 'datetime="2026-01-01T00:00:00+00:00"' in body
+ assert 'title="' + "2" * 40 + '">' + "2" * 16 + "</a>" in body
+ assert "Not recorded" in body
+ assert 'href="/committees/test"' not in body
+ assert 'href="/committees/tooling"' not in body
+ assert 'href="/keys/details/' + "2" * 40 + '"' in body
+ routes = app.url_map.bind("localhost")
+ assert routes.match("/committees/z")[0] == get.committees.view.endpoint
+ assert routes.match("/keys/details/" + "2" * 40)[0] == get.keys.details.endpoint
+
+
+@pytest.mark.parametrize(("uid", "status"), [(None, 401), ("ordinary", 403)])
+async def test_reproducible_tab_requires_admin(app, monkeypatch, uid, status):
+ session = sql.UserSession(uid=uid) if uid else None
+ monkeypatch.setattr(sessions, "read", mock.AsyncMock(return_value=session))
+ keys = mock.AsyncMock()
+ monkeypatch.setattr(interaction, "automated_release_signing_keys", keys)
+ response = await app.test_client().get("/admin/catalog?tab=reproducible-builds")
+ assert response.status_code == status
+ keys.assert_not_awaited()
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org