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 f33786c Migrate KEYS export to the storage interface, and remove old code f33786c is described below commit f33786cbef309830aa4e79b5aaeb6241d971b31e Author: Sean B. Palmer <s...@miscoranda.com> AuthorDate: Tue Jul 22 18:40:37 2025 +0100 Migrate KEYS export to the storage interface, and remove old code --- atr/routes/keys.py | 148 ++--------------------------------- atr/storage/writers/keys.py | 183 ++++++++++++++++++++++---------------------- 2 files changed, 100 insertions(+), 231 deletions(-) diff --git a/atr/routes/keys.py b/atr/routes/keys.py index b1d9aff..e361989 100644 --- a/atr/routes/keys.py +++ b/atr/routes/keys.py @@ -24,8 +24,6 @@ import datetime import hashlib import logging import logging.handlers -import pathlib -import textwrap from collections.abc import Awaitable, Callable, Sequence import aiofiles.os @@ -307,13 +305,11 @@ async def details(session: routes.CommitterSession, fingerprint: str) -> str | r @routes.committer("/keys/export/<committee_name>") async def export(session: routes.CommitterSession, committee_name: str) -> quart.Response: """Export a KEYS file for a specific committee.""" - if committee_name not in (session.committees + session.projects): - quart.abort(403, description=f"You are not authorised to update the KEYS file for {committee_name}") - - async with db.session() as data: - full_keys_file_content = await _keys_formatter(committee_name, data) + async with storage.write(session.uid) as write: + wafm = write.as_foundation_member().result_or_raise() + keys_file_text = await wafm.keys.keys_file_text(committee_name) - return quart.Response(full_keys_file_content, mimetype="text/plain") + return quart.Response(keys_file_text, mimetype="text/plain") @routes.committer("/keys/import/<project_name>/<version_name>", methods=["POST"]) @@ -551,7 +547,9 @@ async def upload(session: routes.CommitterSession) -> str: if not selected_committee: return await render(error="You must select at least one committee") - outcomes = await _upload_keys(session.uid, keys_text, selected_committee) + async with storage.write(session.uid) as write: + wacm = write.as_committee_member(selected_committee).result_or_raise() + outcomes = await wacm.keys.ensure_associated(keys_text) results = outcomes success_count = outcomes.result_count error_count = outcomes.exception_count @@ -569,54 +567,6 @@ async def upload(session: routes.CommitterSession) -> str: return await render() -async def _format_keys_file( - committee_name_for_header: str, - 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 " - f"committers of the Apache {committee_name_for_header} projects to sign official " - f"release artifacts. Verifying the signature on a downloaded artifact using one " - f"of the keys in this file provides confidence that the artifact is authentic " - f"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 {committee_name_for_header} 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 -""" - + "\n\n" - ) - - full_keys_file_content = header_content + key_blocks_str - return full_keys_file_content - - async def _get_keys_text(keys_url: str, render: Callable[[str], Awaitable[str]]) -> str: try: async with aiohttp.ClientSession() as session: @@ -656,87 +606,3 @@ async def _key_and_is_owner( quart.abort(403, description="You are not authorised to view this key") return key, is_owner - - -async def _keys_formatter(committee_name: str, data: db.Session) -> str: - committee = await data.committee(name=committee_name, _public_signing_keys=True, _projects=True).demand( - base.ASFQuartException(f"Committee {committee_name} not found", errorcode=404) - ) - - if not committee.public_signing_keys: - raise base.ASFQuartException( - f"No keys found for committee {committee_name} to generate KEYS file.", errorcode=404 - ) - - if (not committee.projects) and (committee.name != "incubator"): - raise base.ASFQuartException(f"No projects found associated with committee {committee_name}.", errorcode=404) - - 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) <redac...@apache.org> - # - # [...] - 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" - - committee_name_for_header = committee.display_name or committee.name - key_count_for_header = len(committee.public_signing_keys) - - return await _format_keys_file( - committee_name_for_header=committee_name_for_header, - key_count_for_header=key_count_for_header, - key_blocks_str=key_blocks_str, - ) - - -async def _upload_keys( - asf_uid: str, - filetext: str, - selected_committee: str, -) -> types.Outcomes[types.Key]: - async with storage.write(asf_uid) as write: - wacm = write.as_committee_member(selected_committee).result_or_raise() - outcomes: types.Outcomes[types.Key] = await wacm.keys.ensure_associated(filetext) - return outcomes - - -async def _write_keys_file( - committee_keys_dir: pathlib.Path, - full_keys_file_content: str, - committee_keys_path: pathlib.Path, - committee_name: str, -) -> str | None: - 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 {committee_name}: {e}" - logging.exception(error_msg) - return error_msg - except Exception as e: - error_msg = f"An unexpected error occurred writing KEYS for committee {committee_name}: {e}" - logging.exception(error_msg) - return error_msg - return None diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py index 945c281..57eeb0f 100644 --- a/atr/storage/writers/keys.py +++ b/atr/storage/writers/keys.py @@ -110,6 +110,50 @@ class FoundationMember: async def ensure_stored_one(self, key_file_text: str) -> types.Outcome[types.Key]: return await self.__ensure_one(key_file_text, associate=False) + @performance_async + async def keys_file_text(self, committee_name: str) -> str: + committee = await self.__data.committee(name=committee_name, _public_signing_keys=True).demand( + storage.AccessError(f"Committee not found: {committee_name}") + ) + if not committee.public_signing_keys: + raise storage.AccessError(f"No keys found for committee {committee_name} to generate KEYS file.") + + 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) <redac...@apache.org> + # + # [...] + 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( + committee_name=committee_name, + key_count_for_header=key_count_for_header, + key_blocks_str=key_blocks_str, + ) + @performance def __block_model(self, key_block: str, ldap_data: dict[str, str]) -> types.Key: # This cache is only held for the session @@ -184,6 +228,54 @@ class FoundationMember: outcome = await self.__database_add_model(key) return outcome + @performance_async + async def __keys_file_format( + self, + committee_name: str, + 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 {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 {committee_name} committee +# Generated at {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 + @performance def __keyring_fingerprint_model( self, keyring: pgpy.PGPKeyring, fingerprint: str, ldap_data: dict[str, str] @@ -316,7 +408,7 @@ class CommitteeMember(CommitteeParticipant): committee = await self.committee() is_podling = committee.is_podling - full_keys_file_content = await self.__keys_formatter() + full_keys_file_content = await self.keys_file_text() if is_podling: committee_keys_dir = base_downloads_dir / "incubator" / self.__committee_name else: @@ -489,95 +581,6 @@ class CommitteeMember(CommitteeParticipant): 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) <redac...@apache.org> - # - # [...] - 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, - ) - # class FoundationAdmin(FoundationMember): # def __init__( --------------------------------------------------------------------- To unsubscribe, e-mail: commits-unsubscr...@tooling.apache.org For additional commands, e-mail: commits-h...@tooling.apache.org