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 f24a7399 Improve key deletion methods in the storage interface
f24a7399 is described below

commit f24a739904fb6ea6be33cfefd5844a75ec77b157
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Apr 2 20:12:53 2026 +0100

    Improve key deletion methods in the storage interface
---
 atr/storage/writers/keys.py    |  87 +++++++++++++--------
 tests/unit/test_keys_writer.py | 170 +++++++++++++++++++++++++++++++++++++++++
 2 files changed, 224 insertions(+), 33 deletions(-)

diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index cebad86c..c8d8c627 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -84,13 +84,11 @@ class FoundationCommitter(GeneralPublic):
                 apache_uid=self.__asf_uid,
                 _committees=True,
             ).demand(storage.AccessError(f"Key not found: {fingerprint}"))
+            affected_committee_keys = {committee.key for committee in 
key.committees}
             await self.__data.delete(key)
             await self.__data.commit()
-            for committee in key.committees:
-                wacm = 
self.__write.as_committee_member_outcome(committee.key).result_or_none()
-                if wacm is None:
-                    continue
-                await wacm.keys.autogenerate_keys_file()
+            for committee_key in sorted(affected_committee_keys):
+                await self._sync_committee_keys_file(committee_key)
             return outcome.Result(key)
         except Exception as e:
             return outcome.Error(e)
@@ -144,8 +142,11 @@ class FoundationCommitter(GeneralPublic):
         committee = await self.__data.committee(key=committee_key, 
_public_signing_keys=True).demand(
             storage.AccessError(f"Committee not found: {committee_key}")
         )
+        return await self._keys_file_text(committee)
+
+    async def _keys_file_text(self, committee: sql.Committee) -> str:
         if not committee.public_signing_keys:
-            raise storage.AccessError(f"No keys found for committee 
{committee_key} to generate KEYS file.")
+            raise storage.AccessError(f"No keys found for committee 
{committee.key} to generate KEYS file.")
 
         sorted_keys = sorted(committee.public_signing_keys, key=lambda k: 
k.fingerprint)
 
@@ -178,11 +179,46 @@ class FoundationCommitter(GeneralPublic):
         key_count_for_header = len(committee.public_signing_keys)
 
         return await self.__keys_file_format(
-            committee_key=committee_key,
+            committee_key=committee.key,
             key_count_for_header=key_count_for_header,
             key_blocks_str=key_blocks_str,
         )
 
+    def _committee_keys_path(self, committee: sql.Committee) -> pathlib.Path:
+        base_downloads_dir = paths.get_downloads_dir()
+        if committee.is_podling:
+            return base_downloads_dir / "incubator" / committee.key / "KEYS"
+        return base_downloads_dir / committee.key / "KEYS"
+
+    async def _sync_committee_keys_file(self, committee_key: str) -> str | 
None:
+        committee = await self.__data.committee(key=committee_key, 
_public_signing_keys=True).demand(
+            storage.AccessError(f"Committee not found: {committee_key}")
+        )
+        committee_keys_path = self._committee_keys_path(committee)
+        committee_keys_dir = committee_keys_path.parent
+
+        if not committee.public_signing_keys:
+            try:
+                if await aiofiles.os.path.exists(committee_keys_path):
+                    await aiofiles.os.remove(committee_keys_path)
+            except OSError as e:
+                raise storage.AccessError(f"Failed to remove KEYS file for 
committee {committee_key}: {e}") from e
+            return None
+
+        full_keys_file_content = await self._keys_file_text(committee)
+        try:
+            await aiofiles.os.makedirs(committee_keys_dir, 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:
+            raise storage.AccessError(f"Failed to write KEYS file for 
committee {committee_key}: {e}") from e
+        except Exception as e:
+            log.exception(f"An unexpected error occurred writing KEYS for 
committee {committee_key}: {e}")
+            raise storage.AccessError(
+                f"An unexpected error occurred writing KEYS for committee 
{committee_key}: {e}"
+            ) from e
+        return str(committee_keys_path)
+
     async def test_user_delete_all(self, test_uid: str) -> 
outcome.Outcome[int]:
         """Delete all OpenPGP keys and their links for a test user."""
         if not config.is_test_mode():
@@ -259,9 +295,7 @@ class FoundationCommitter(GeneralPublic):
         await self.__data.commit()
 
         for committee_key in sorted(affected):
-            wacp = 
self.__write.as_committee_participant_outcome(committee_key).result_or_none()
-            if wacp is not None:
-                await wacp.keys.autogenerate_keys_file()
+            await self._sync_committee_keys_file(committee_key)
 
         return affected
 
@@ -472,32 +506,19 @@ class CommitteeParticipant(FoundationCommitter):
         self,
     ) -> outcome.Outcome[str]:
         try:
-            base_downloads_dir = paths.get_downloads_dir()
-
             committee = await self.committee()
-            is_podling = committee.is_podling
-
-            full_keys_file_content = await 
self.keys_file_text(self.__committee_key)
-            if is_podling:
-                committee_keys_dir = base_downloads_dir / "incubator" / 
self.__committee_key
-            else:
-                committee_keys_dir = base_downloads_dir / self.__committee_key
-            committee_keys_path = committee_keys_dir / "KEYS"
+            if not committee.public_signing_keys:
+                return outcome.Error(
+                    storage.AccessError(f"No keys found for committee 
{self.__committee_key} to generate KEYS file.")
+                )
+            synced_path = await 
self._sync_committee_keys_file(self.__committee_key)
         except Exception as e:
             return outcome.Error(e)
-
-        try:
-            await aiofiles.os.makedirs(committee_keys_dir, 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_key}: {e}"
-            return outcome.Error(storage.AccessError(error_msg))
-        except Exception as e:
-            error_msg = f"An unexpected error occurred writing KEYS for 
committee {self.__committee_key}: {e}"
-            log.exception(f"An unexpected error occurred writing KEYS for 
committee {self.__committee_key}: {e}")
-            return outcome.Error(storage.AccessError(error_msg))
-        return outcome.Result(str(committee_keys_path))
+        if synced_path is None:
+            return outcome.Error(
+                storage.AccessError(f"No keys found for committee 
{self.__committee_key} to generate KEYS file.")
+            )
+        return outcome.Result(synced_path)
 
     async def committee(self) -> sql.Committee:
         return await self.__data.committee(key=self.__committee_key, 
_public_signing_keys=True).demand(
diff --git a/tests/unit/test_keys_writer.py b/tests/unit/test_keys_writer.py
new file mode 100644
index 00000000..1c1da45d
--- /dev/null
+++ b/tests/unit/test_keys_writer.py
@@ -0,0 +1,170 @@
+# 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 unittest.mock as mock
+from types import SimpleNamespace
+
+import pytest
+
+import atr.storage.outcome as outcome
+import atr.storage.writers.keys as keys_writer
+
+
+class Query:
+    def __init__(self, value):
+        self._value = value
+
+    async def get(self):
+        return self._value
+
+    async def demand(self, error: Exception):
+        if self._value is None:
+            raise error
+        return self._value
+
+
+class MockData:
+    def __init__(self, key, committees_after_commit: dict[str, object]):
+        self._key = key
+        self._committees_after_commit = committees_after_commit
+        self.begin_immediate = mock.AsyncMock()
+        self.commit = mock.AsyncMock()
+        self.delete = mock.AsyncMock()
+        self.execute = mock.AsyncMock()
+
+    def public_signing_key(self, **_kwargs):
+        return Query(self._key)
+
+    def committee(self, *, key: str, _public_signing_keys: bool = False):
+        assert _public_signing_keys is True
+        return Query(self._committees_after_commit[key])
+
+
[email protected]
+async def test_delete_key_removal_deletes_empty_keys_file(tmp_path):
+    owned_key = SimpleNamespace(
+        fingerprint="fp1",
+        committees=[SimpleNamespace(key="alpha")],
+    )
+    data = MockData(
+        owned_key,
+        committees_after_commit={"alpha": _committee("alpha", [])},
+    )
+    writer, _write = _make_foundation_committer(data)
+
+    keys_path = tmp_path / "alpha" / "KEYS"
+    keys_path.parent.mkdir(parents=True)
+    keys_path.write_text("stale content", encoding="utf-8")
+
+    with (
+        mock.patch.object(keys_writer.paths, "get_downloads_dir", 
return_value=tmp_path),
+        mock.patch.object(keys_writer.util, "chmod_directories"),
+    ):
+        result = await writer.delete_key("fp1")
+
+    assert isinstance(result, outcome.Result)
+    assert not keys_path.exists()
+    data.delete.assert_awaited_once_with(owned_key)
+    data.commit.assert_awaited_once()
+
+
[email protected]
+async def 
test_update_committee_associations_removal_deletes_empty_keys_file(tmp_path):
+    owned_key = SimpleNamespace(fingerprint="fp1", 
committees=[SimpleNamespace(key="alpha")])
+    data = MockData(
+        owned_key,
+        committees_after_commit={"alpha": _committee("alpha", [])},
+    )
+    writer, write = _make_foundation_committer(data)
+
+    keys_path = tmp_path / "alpha" / "KEYS"
+    keys_path.parent.mkdir(parents=True)
+    keys_path.write_text("stale content", encoding="utf-8")
+
+    with (
+        mock.patch.object(keys_writer.paths, "get_downloads_dir", 
return_value=tmp_path),
+        mock.patch.object(keys_writer.util, "chmod_directories"),
+    ):
+        affected = await writer.update_committee_associations("fp1", [])
+
+    assert affected == {"alpha"}
+    assert not keys_path.exists()
+    assert write.as_committee_participant.call_count == 0
+    data.begin_immediate.assert_awaited_once()
+    data.commit.assert_awaited_once()
+
+
[email protected]
+async def 
test_update_committee_associations_removal_rewrites_keys_file_with_remaining_keys(tmp_path):
+    owned_key = SimpleNamespace(fingerprint="fp1", 
committees=[SimpleNamespace(key="alpha")])
+    remaining_key = _public_key("bbbbccccdddd1111")
+    data = MockData(
+        owned_key,
+        committees_after_commit={"alpha": _committee("alpha", 
[remaining_key])},
+    )
+    writer, write = _make_foundation_committer(data)
+
+    keys_path = tmp_path / "alpha" / "KEYS"
+    keys_path.parent.mkdir(parents=True)
+    keys_path.write_text("stale content", encoding="utf-8")
+
+    with (
+        mock.patch.object(keys_writer.paths, "get_downloads_dir", 
return_value=tmp_path),
+        mock.patch.object(keys_writer.util, "chmod_directories"),
+    ):
+        affected = await writer.update_committee_associations("fp1", [])
+
+    assert affected == {"alpha"}
+    assert keys_path.exists()
+    content = keys_path.read_text(encoding="utf-8")
+    assert "stale content" not in content
+    assert remaining_key.fingerprint.upper() in content
+    assert "Signing keys for the alpha committee" in content
+    assert write.as_committee_participant.call_count == 0
+
+
+def _committee(key: str, public_signing_keys: list[object], *, is_podling: 
bool = False):
+    return SimpleNamespace(
+        key=key,
+        is_podling=is_podling,
+        public_signing_keys=public_signing_keys,
+    )
+
+
+def _make_foundation_committer(data: MockData):
+    write = mock.MagicMock()
+    write.authorisation.asf_uid = "alice"
+    write.as_committee_participant = mock.MagicMock()
+    write_as = mock.MagicMock()
+    return keys_writer.FoundationCommitter(write, write_as, data), write
+
+
+def _public_key(
+    fingerprint: str,
+    *,
+    apache_uid: str = "bob",
+    primary_declared_uid: str = "Bob <[email protected]>",
+    ascii_armored_key: str | None = None,
+):
+    if ascii_armored_key is None:
+        ascii_armored_key = "-----BEGIN PGP PUBLIC KEY 
BLOCK-----\nbody\n-----END PGP PUBLIC KEY BLOCK-----\n"
+    return SimpleNamespace(
+        fingerprint=fingerprint,
+        apache_uid=apache_uid,
+        primary_declared_uid=primary_declared_uid,
+        ascii_armored_key=ascii_armored_key,
+    )


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

Reply via email to