This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-release.git


The following commit(s) were added to refs/heads/main by this push:
     new eef82fb  Migrate the add key form handler to the storage interface
eef82fb is described below

commit eef82fb74787c8ba7f3b0558e11b1566d051305f
Author: Sean B. Palmer <[email protected]>
AuthorDate: Mon Jul 21 10:43:41 2025 +0100

    Migrate the add key form handler to the storage interface
---
 atr/blueprints/api/api.py   |   2 -
 atr/routes/keys.py          |  41 +++++------
 atr/storage/__init__.py     |   1 +
 atr/storage/types.py        |   6 ++
 atr/storage/writers/keys.py | 176 +++++++++++++++++++++++++++++++++++++++++---
 5 files changed, 192 insertions(+), 34 deletions(-)

diff --git a/atr/blueprints/api/api.py b/atr/blueprints/api/api.py
index 0c1b3f8..4a6e743 100644
--- a/atr/blueprints/api/api.py
+++ b/atr/blueprints/api/api.py
@@ -308,12 +308,10 @@ async def keys_add(data: models.api.KeysAddArgs) -> 
DictResponse:
         ocr: types.KeyOutcome = await wafm.keys.ensure_stored_one(data.key)
         key = ocr.result_or_raise()
 
-        # outcomes = types.LinkedCommitteeOutcomes()
         for selected_committee_name in selected_committee_names:
             wacm = 
write.as_committee_member(selected_committee_name).writer_or_raise()
             outcome: types.LinkedCommitteeOutcome = await 
wacm.keys.associate_fingerprint(key.key_model.fingerprint)
             outcome.result_or_raise()
-            # outcomes.append(outcome)
 
     return models.api.KeysAddResults(
         endpoint="/keys/add",
diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index ba859e8..6d52b70 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -137,7 +137,6 @@ async def add(session: routes.CommitterSession) -> str:
         project_list = session.committees + session.projects
         user_committees = await data.committee(name_in=project_list).all()
 
-    committee_is_podling = {c.name: c.is_podling for c in user_committees}
     committee_choices = [(c.name, c.display_name or c.name) for c in 
user_committees]
 
     class AddOpenPGPKeyForm(util.QuartFormTyped):
@@ -165,28 +164,23 @@ async def add(session: routes.CommitterSession) -> str:
 
     if await form.validate_on_submit():
         try:
-            public_key_data: str = util.unwrap(form.public_key.data)
-            selected_committees_data: list[str] = 
util.unwrap(form.selected_committees.data)
-
-            invalid_committees = [
-                committee
-                for committee in selected_committees_data
-                if (committee not in session.committees) and (committee not in 
session.projects)
-            ]
-            if invalid_committees:
-                raise routes.FlashError(f"Invalid PMC selection: {', 
'.join(invalid_committees)}")
-
-            added_keys = await interaction.key_user_add(session.uid, 
public_key_data, selected_committees_data)
-            for key_info in added_keys:
-                if key_info:
-                    await quart.flash(
-                        f"OpenPGP key {key_info.get('fingerprint', 
'').upper()} added successfully.", "success"
+            asf_uid = session.uid
+            key_text: str = util.unwrap(form.public_key.data)
+            selected_committee_names: list[str] = 
util.unwrap(form.selected_committees.data)
+
+            async with storage.write(asf_uid) as write:
+                wafm = write.as_foundation_member().writer_or_raise()
+                ocr: types.KeyOutcome = await 
wafm.keys.ensure_stored_one(key_text)
+                key = ocr.result_or_raise()
+
+                for selected_committee_name in selected_committee_names:
+                    wacm = 
write.as_committee_member(selected_committee_name).writer_or_raise()
+                    outcome: types.LinkedCommitteeOutcome = await 
wacm.keys.associate_fingerprint(
+                        key.key_model.fingerprint
                     )
-                    for committee_name in selected_committees_data:
-                        is_podling = committee_is_podling[committee_name]
-                        await autogenerate_keys_file(committee_name, 
is_podling)
-            if not added_keys:
-                await quart.flash("No keys were added.", "error")
+                    outcome.result_or_raise()
+
+                await quart.flash(f"OpenPGP key 
{key.key_model.fingerprint.upper()} added successfully.", "success")
             # Clear form data on success by creating a new empty form instance
             form = await AddOpenPGPKeyForm.create_form()
 
@@ -194,7 +188,7 @@ async def add(session: routes.CommitterSession) -> str:
             logging.warning("FlashError adding OpenPGP key: %s", e)
             await quart.flash(str(e), "error")
         except Exception as e:
-            logging.exception("Exception adding OpenPGP key:")
+            logging.exception("Error adding OpenPGP key:")
             await quart.flash(f"An unexpected error occurred: {e!s}", "error")
 
     return await template.render(
@@ -207,6 +201,7 @@ async def add(session: routes.CommitterSession) -> str:
     )
 
 
+# TODO: Check callers, and migrate to storage
 async def autogenerate_keys_file(
     committee_name: str, is_podling: bool, caller_data: db.Session | None = 
None
 ) -> str | None:
diff --git a/atr/storage/__init__.py b/atr/storage/__init__.py
index 74392d1..4c7c704 100644
--- a/atr/storage/__init__.py
+++ b/atr/storage/__init__.py
@@ -392,6 +392,7 @@ class Outcomes[T]:
                     self.__outcomes[i] = OutcomeResult(result, outcome.name)
 
 
+# TODO: Could use + and += instead, or in addition
 def outcomes_merge[T](*outcomes: Outcomes[T]) -> Outcomes[T]:
     return Outcomes(*[outcome for outcome in outcomes for outcome in 
outcome.outcomes()])
 
diff --git a/atr/storage/types.py b/atr/storage/types.py
index afd90ae..3237e74 100644
--- a/atr/storage/types.py
+++ b/atr/storage/types.py
@@ -17,6 +17,7 @@
 
 import dataclasses
 import enum
+from typing import Any
 
 import atr.models.schema as schema
 import atr.models.sql as sql
@@ -35,9 +36,14 @@ class Key(schema.Strict):
     key_model: sql.PublicSigningKey
 
 
+# OutcomeStr = storage.OutcomeResult[str] | storage.OutcomeException[str, 
Exception]
+
+
 @dataclasses.dataclass
 class LinkedCommittee:
     name: str
+    # TODO: Move Outcome here
+    autogenerated_keys_file: Any
 
 
 class PublicKeyError(Exception):
diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index 6949f77..c9acbf5 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -15,12 +15,17 @@
 # specific language governing permissions and limitations
 # under the License.
 
+# TODO: Add auditing
+# TODO: Always raise and catch AccessError
+
 # Removing this will cause circular imports
 from __future__ import annotations
 
 import asyncio
+import datetime
 import logging
 import tempfile
+import textwrap
 import time
 from typing import TYPE_CHECKING, Any, Final, NoReturn
 
@@ -84,14 +89,14 @@ class FoundationMember:
         return await self.__ensure_one(key_file_text, associate=False)
 
     @performance
-    def __block_model(self, key_block: str, ldap_data: dict[str, str]) -> 
types.Key | Exception:
+    def __block_model(self, key_block: str, ldap_data: dict[str, str]) -> 
types.Key:
         # This cache is only held for the session
         if key_block in self.__key_block_models_cache:
             cached_key_models = self.__key_block_models_cache[key_block]
             if len(cached_key_models) == 1:
                 return cached_key_models[0]
             else:
-                return ValueError("Expected one key block, got none or 
multiple")
+                raise ValueError("Expected one key block, got none or 
multiple")
 
         with tempfile.NamedTemporaryFile(delete=True) as tmpfile:
             tmpfile.write(key_block.encode())
@@ -106,15 +111,40 @@ class FoundationMember:
                         # Was not a primary key, so skip it
                         continue
                     if key is not None:
-                        return ValueError("Expected one key block, got 
multiple")
+                        raise ValueError("Expected one key block, got 
multiple")
                     key = types.Key(status=types.KeyStatus.PARSED, 
key_model=key_model)
                 except Exception as e:
-                    return e
+                    raise e
         if key is None:
-            return ValueError("Expected a key, got none")
+            raise ValueError("Expected a key, got none")
         self.__key_block_models_cache[key_block] = [key]
         return key
 
+    @performance_async
+    async def __database_add_model(
+        self,
+        key: types.Key,
+    ) -> storage.OutcomeResult[types.Key]:
+        via = sql.validate_instrumented_attribute
+
+        await self.__data.begin_immediate()
+
+        key_values = [key.key_model.model_dump(exclude={"committees"})]
+        key_insert_result = await self.__data.execute(
+            sqlite.insert(sql.PublicSigningKey)
+            .values(key_values)
+            .on_conflict_do_nothing(index_elements=["fingerprint"])
+            .returning(via(sql.PublicSigningKey.fingerprint))
+        )
+        if key_insert_result.one_or_none() is None:
+            # raise storage.AccessError(f"Key not inserted: 
{key.key_model.fingerprint}")
+            pass
+        logging.info(f"Inserted key {key.key_model.fingerprint}")
+
+        await self.__data.commit()
+        # TODO: PARSED now acts as "ALREADY_ADDED"
+        return 
storage.OutcomeResult(types.Key(status=types.KeyStatus.INSERTED, 
key_model=key.key_model))
+
     @performance_async
     async def __ensure_one(self, key_file_text: str, associate: bool = True) 
-> types.KeyOutcome:
         try:
@@ -126,10 +156,11 @@ class FoundationMember:
         key_block = key_blocks[0]
         try:
             ldap_data = await util.email_to_uid_map()
-            key_model = await asyncio.to_thread(self.__block_model, key_block, 
ldap_data)
-            return storage.OutcomeResult(key_model)
+            key = await asyncio.to_thread(self.__block_model, key_block, 
ldap_data)
         except Exception as e:
             return storage.OutcomeException(e)
+        outcome = await self.__database_add_model(key)
+        return outcome
 
     @performance
     def __keyring_fingerprint_model(
@@ -208,7 +239,9 @@ class CommitteeMember(CommitteeParticipant):
     def __init__(
         self, credentials: storage.WriteAsCommitteeMember, data: db.Session, 
asf_uid: str, committee_name: str
     ):
-        super().__init__(credentials, data, asf_uid, committee_name)
+        self.__data = data
+        self.__credentials = credentials
+        self.__asf_uid = asf_uid
         self.__committee_name = committee_name
 
     @performance_async
@@ -223,12 +256,20 @@ class CommitteeMember(CommitteeParticipant):
                 .returning(via(sql.KeyLink.key_fingerprint))
             )
             if link_insert_result.one_or_none() is None:
-                return storage.OutcomeException(storage.AccessError(f"Key not 
found: {fingerprint}"))
+                # e = storage.AccessError(f"Key not found: {fingerprint}")
+                # return storage.OutcomeException(e)
+                pass
+            await self.__data.commit()
+        except Exception as e:
+            return storage.OutcomeException(e)
+        try:
+            autogenerated_outcome = await self.__autogenerate_keys_file()
         except Exception as e:
             return storage.OutcomeException(e)
         return storage.OutcomeResult(
             types.LinkedCommittee(
                 name=self.__committee_name,
+                autogenerated_keys_file=autogenerated_outcome,
             )
         )
 
@@ -247,6 +288,34 @@ class CommitteeMember(CommitteeParticipant):
     async def ensure_stored(self, keys_file_text: str) -> 
storage.Outcomes[types.Key]:
         return await self.__ensure(keys_file_text, associate=False)
 
+    # TODO: Outcome[str]
+    async def __autogenerate_keys_file(
+        self,
+    ) -> storage.OutcomeResult[str] | storage.OutcomeException[str, Exception]:
+        base_downloads_dir = util.get_downloads_dir()
+
+        committee = await self.committee()
+        is_podling = committee.is_podling
+
+        full_keys_file_content = await self.__keys_formatter()
+        if is_podling:
+            committee_keys_dir = base_downloads_dir / "incubator" / 
self.__committee_name
+        else:
+            committee_keys_dir = base_downloads_dir / self.__committee_name
+        committee_keys_path = committee_keys_dir / "KEYS"
+        try:
+            await asyncio.to_thread(committee_keys_dir.mkdir, parents=True, 
exist_ok=True)
+            await asyncio.to_thread(util.chmod_directories, 
committee_keys_dir, permissions=0o755)
+            await asyncio.to_thread(committee_keys_path.write_text, 
full_keys_file_content, encoding="utf-8")
+        except OSError as e:
+            error_msg = f"Failed to write KEYS file for committee 
{self.__committee_name}: {e}"
+            return storage.OutcomeException(storage.AccessError(error_msg))
+        except Exception as e:
+            error_msg = f"An unexpected error occurred writing KEYS for 
committee {self.__committee_name}: {e}"
+            logging.exception(e)
+            return storage.OutcomeException(storage.AccessError(error_msg))
+        return storage.OutcomeResult(str(committee_keys_path))
+
     @performance
     def __block_models(self, key_block: str, ldap_data: dict[str, str]) -> 
list[types.Key | Exception]:
         # This cache is only held for the session
@@ -377,3 +446,92 @@ class CommitteeMember(CommitteeParticipant):
             for key, value in PERFORMANCES.items():
                 logging.info(f"{key}: {value}")
         return outcomes
+
+    async def __keys_file_format(
+        self,
+        key_count_for_header: int,
+        key_blocks_str: str,
+    ) -> str:
+        timestamp_str = datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%d 
%H:%M:%S")
+        purpose_text = f"""\
+This file contains the {key_count_for_header} OpenPGP public keys used by \
+committers of the Apache {self.__committee_name} projects to sign official \
+release artifacts. Verifying the signature on a downloaded artifact using one \
+of the keys in this file provides confidence that the artifact is authentic \
+and was published by the committee.\
+"""
+        wrapped_purpose = "\n".join(
+            textwrap.wrap(
+                purpose_text,
+                width=62,
+                initial_indent="# ",
+                subsequent_indent="# ",
+                break_long_words=False,
+                replace_whitespace=False,
+            )
+        )
+
+        header_content = f"""\
+# Apache Software Foundation (ASF)
+# Signing keys for the {self.__committee_name} committee
+# Generated on {timestamp_str} UTC
+#
+{wrapped_purpose}
+#
+# 1. Import these keys into your GPG keyring:
+#    gpg --import KEYS
+#
+# 2. Verify the signature file against the release artifact:
+#    gpg --verify "${{ARTIFACT}}.asc" "${{ARTIFACT}}"
+#
+# For details on Apache release signing and verification, see:
+# https://infra.apache.org/release-signing.html
+
+
+"""
+
+        full_keys_file_content = header_content + key_blocks_str
+        return full_keys_file_content
+
+    async def __keys_formatter(self) -> str:
+        committee = await self.committee()
+        if not committee.public_signing_keys:
+            raise storage.AccessError(f"No keys found for committee 
{self.__committee_name} to generate KEYS file.")
+
+        # if (not committee.projects) and (committee.name != "incubator"):
+        #     raise storage.AccessError(f"No projects found associated with 
committee {self.__committee_name}.")
+
+        sorted_keys = sorted(committee.public_signing_keys, key=lambda k: 
k.fingerprint)
+
+        keys_content_list = []
+        for key in sorted_keys:
+            apache_uid = key.apache_uid.lower() if key.apache_uid else None
+            # TODO: What if there is no email?
+            email = util.email_from_uid(key.primary_declared_uid or "") or ""
+            comments = []
+            comments.append(f"Comment: {key.fingerprint.upper()}")
+            if (apache_uid is None) or (email == f"{apache_uid}@apache.org"):
+                comments.append(f"Comment: {email}")
+            else:
+                comments.append(f"Comment: {email} ({apache_uid})")
+            comment_lines = "\n".join(comments)
+            armored_key = key.ascii_armored_key
+            # Use the Sequoia format
+            # -----BEGIN PGP PUBLIC KEY BLOCK-----
+            # Comment: C46D 6658 489D DE09 CE93  8AF8 7B6A 6401 BF99 B4A3
+            # Comment: Redacted Name (CODE SIGNING KEY) <[email protected]>
+            #
+            # [...]
+            if isinstance(armored_key, bytes):
+                # TODO: This should not happen, but it does
+                armored_key = armored_key.decode("utf-8", errors="replace")
+            armored_key = armored_key.replace("BLOCK-----", "BLOCK-----\n" + 
comment_lines, 1)
+            keys_content_list.append(armored_key)
+
+        key_blocks_str = "\n\n\n".join(keys_content_list) + "\n"
+        key_count_for_header = len(committee.public_signing_keys)
+
+        return await self.__keys_file_format(
+            key_count_for_header=key_count_for_header,
+            key_blocks_str=key_blocks_str,
+        )


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to