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]