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/07/17 13:55:22 UTC
(tooling-trusted-releases) branch sbp updated: Report KEYS publication failures more widely
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 9ac741ca Report KEYS publication failures more widely
9ac741ca is described below
commit 9ac741caa53cbf26009e6498b7e0e370ca4616a6
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Fri Jul 17 14:54:45 2026 +0100
Report KEYS publication failures more widely
---
atr/admin/__init__.py | 2 +
atr/api/__init__.py | 15 ++++---
atr/post/keys.py | 19 +++++---
atr/shared/keys.py | 11 +++++
atr/storage/writers/keys.py | 94 +++++++++++++++++++++++----------------
atr/templates/committee-view.html | 30 ++++++++-----
scripts/keys_import.py | 7 +--
tests/unit/test_keys_delete.py | 3 +-
tests/unit/test_keys_shared.py | 5 ++-
tests/unit/test_keys_writer.py | 49 +++++++++++++++-----
10 files changed, 159 insertions(+), 76 deletions(-)
diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 7725de7c..09a64224 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -1074,6 +1074,8 @@ async def delete_committee_keys_post(
" because automated publication is disabled.",
"warning",
)
+ if failure := shared.keys.publication_failed_warning(publications):
+ await quart.flash(failure, "error")
return await session.redirect(delete_committee_keys_get)
diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 464d1b5b..5f51ce24 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -719,13 +719,12 @@ async def key_add(
async with storage.write(asf_uid) as write:
wafc = write.as_foundation_committer()
- ocr: outcome.Outcome[datatypes.Key] = await wafc.keys.ensure_stored_one(data.key)
+ ocr, publications = await wafc.keys.ensure_stored_one(data.key)
try:
key = ocr.result_or_raise()
except datatypes.UnknownApacheUidError as e:
raise exceptions.BadRequest(str(e)) from e
- publications: dict[str, outcome.Outcome[datatypes.KeysPublish]] = {}
for selected_committee_key in selected_committee_keys:
wacm = write.as_committee_member(selected_committee_key)
oc: outcome.Outcome[datatypes.LinkedCommittee] = await wacm.keys.associate_fingerprint(
@@ -735,10 +734,11 @@ async def key_add(
publications[linked.name] = linked.publication
notice = shared.keys.publication_added_notice(publications)
+ failure = shared.keys.publication_failed_warning(publications)
return models.api.KeyAddResults(
endpoint="/key/add",
fingerprint=key.key_model.fingerprint.upper(),
- warnings=[notice] if notice else [],
+ warnings=[w for w in (notice, failure) if w is not None],
).model_dump(mode="json"), 200
@@ -768,10 +768,11 @@ async def key_delete(
deletion = oc.result_or_raise()
warning = shared.keys.publication_removed_warning(deletion.publications)
+ failure = shared.keys.publication_failed_warning(deletion.publications)
return models.api.KeyDeleteResults(
endpoint="/key/delete",
success=True,
- warnings=[warning] if warning else [],
+ warnings=[w for w in (warning, failure) if w is not None],
).model_dump(mode="json"), 200
@@ -825,7 +826,7 @@ async def keys_upload(
selected_committee_key = safe.CommitteeKey(data.committee)
async with storage.write(asf_uid) as write:
wacm = write.as_committee_member(str(selected_committee_key))
- outcomes, publication = await wacm.keys.ensure_associated(filetext)
+ outcomes, publications = await wacm.keys.ensure_associated(filetext)
# TODO: It would be nice to serialise the actual outcomes
# Or, perhaps better yet, to have a standard datatype mapping
@@ -859,15 +860,15 @@ async def keys_upload(
)
# Type checker is sure that it can no longer be None
api_outcomes.append(api_outcome)
- publications = {str(selected_committee_key): publication} if (publication is not None) else {}
notice = shared.keys.publication_added_notice(publications)
+ failure = shared.keys.publication_failed_warning(publications)
return models.api.KeysUploadResults(
endpoint="/keys/upload",
results=api_outcomes,
success_count=outcomes.result_count,
error_count=outcomes.error_count,
submitted_committee=selected_committee_key,
- warnings=[notice] if notice else [],
+ warnings=[w for w in (notice, failure) if w is not None],
).model_dump(mode="json"), 200
diff --git a/atr/post/keys.py b/atr/post/keys.py
index 15102152..10475e9e 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -70,10 +70,9 @@ async def add(
async with storage.write() as write:
wafc = write.as_foundation_committer()
- ocr: outcome.Outcome[datatypes.Key] = await wafc.keys.ensure_stored_one(key_text)
+ ocr, publications = await wafc.keys.ensure_stored_one(key_text)
key = ocr.result_or_raise()
- publications: dict[str, outcome.Outcome[datatypes.KeysPublish]] = {}
for selected_committee_key in selected_committee_keys:
wacp = write.as_committee_participant(selected_committee_key)
oc: outcome.Outcome[datatypes.LinkedCommittee] = await wacp.keys.associate_fingerprint(
@@ -96,6 +95,8 @@ async def add(
await _flash_openpgp_key_uid_warning(key.key_model, session.asf_uid)
if notice := shared.keys.publication_added_notice(publications):
await quart.flash(notice, "info")
+ if failure := shared.keys.publication_failed_warning(publications):
+ await quart.flash(failure, "error")
except PrivateKeyUploadError:
await quart.flash(util.PRIVATE_KEY_UPLOAD_WARNING, "error")
@@ -139,6 +140,8 @@ async def details(
await quart.flash(notice, "info")
if warning := shared.keys.publication_removed_warning(removed):
await quart.flash(warning, "warning")
+ if failure := shared.keys.publication_failed_warning(update.publications):
+ await quart.flash(failure, "error")
except storage.AccessError as e:
await quart.flash(str(e), "error")
except Exception as e:
@@ -162,18 +165,19 @@ async def import_selected_revision(
await session.release(project_key, version_key, with_committee=False, with_project=False)
async with storage.write() as write:
wacm = await write.as_project_committee_member(project_key)
- outcomes, publication = await wacm.keys.import_keys_file(project_key, version_key)
+ outcomes, publications = await wacm.keys.import_keys_file(project_key, version_key)
message = f"Uploaded {util.plural(outcomes.result_count, 'key')}"
if outcomes.error_count > 0:
message += f", failed to upload {util.plural(outcomes.error_count, 'key')} for {wacm.committee_key}"
- publications = {wacm.committee_key: publication} if (publication is not None) else {}
if notice := shared.keys.publication_added_notice(publications):
await quart.flash(
f"{notice} Make sure that the published KEYS file contains the imported keys before the release"
" is announced.",
"warning",
)
+ if failure := shared.keys.publication_failed_warning(publications):
+ await quart.flash(failure, "error")
return await session.redirect(
get.compose.selected,
success=message,
@@ -302,6 +306,8 @@ async def _delete_openpgp_key(
case outcome.Result(deletion):
if warning := shared.keys.publication_removed_warning(deletion.publications):
await quart.flash(warning, "warning")
+ if failure := shared.keys.publication_failed_warning(deletion.publications):
+ await quart.flash(failure, "error")
return await session.redirect(get.keys.keys, success="OpenPGP key deleted successfully")
case outcome.Error(error):
return await session.redirect(get.keys.keys, error=f"Error deleting OpenPGP key: {error}")
@@ -387,7 +393,7 @@ async def _process_keys(keys_text: str, selected_committee: str) -> str:
async with storage.write() as write:
wacp = write.as_committee_participant(selected_committee)
- outcomes, publication = await wacp.keys.ensure_associated(keys_text)
+ outcomes, publications = await wacp.keys.ensure_associated(keys_text)
success_count = outcomes.result_count
error_count = outcomes.error_count
@@ -398,9 +404,10 @@ async def _process_keys(keys_text: str, selected_committee: str) -> str:
message += f", {error_count} failed"
await quart.flash(message, "success" if (success_count > 0) else "error")
- publications = {selected_committee: publication} if (publication is not None) else {}
if notice := shared.keys.publication_added_notice(publications):
await quart.flash(notice, "info")
+ if failure := shared.keys.publication_failed_warning(publications):
+ await quart.flash(failure, "error")
return await shared.keys.render_upload_page(results=outcomes, submitted_committees=[selected_committee])
diff --git a/atr/shared/keys.py b/atr/shared/keys.py
index 23ba50b0..6b3aa337 100644
--- a/atr/shared/keys.py
+++ b/atr/shared/keys.py
@@ -173,6 +173,17 @@ def publication_disabled(publications: dict[str, storage.outcome.Outcome[datatyp
]
+def publication_failed_warning(publications: dict[str, storage.outcome.Outcome[datatypes.KeysPublish]]) -> str | None:
+ failures = [
+ f"{committee} ({error})"
+ for committee, publication in sorted(publications.items())
+ if (error := publication.error_or_none()) is not None
+ ]
+ if not failures:
+ return None
+ return f"KEYS publication to SVN failed for {util.conjunction(failures)}."
+
+
def publication_removed_warning(publications: dict[str, storage.outcome.Outcome[datatypes.KeysPublish]]) -> str | None:
committees = publication_disabled(publications)
if not committees:
diff --git a/atr/storage/writers/keys.py b/atr/storage/writers/keys.py
index adec39e6..51feb1f3 100644
--- a/atr/storage/writers/keys.py
+++ b/atr/storage/writers/keys.py
@@ -246,7 +246,9 @@ class FoundationCommitter(GeneralPublic):
except Exception as e:
return outcome.Error(e)
- async def ensure_stored_one(self, key_file_text: str) -> outcome.Outcome[datatypes.Key]:
+ async def ensure_stored_one(
+ self, key_file_text: str
+ ) -> tuple[outcome.Outcome[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
return await self.__ensure_one(key_file_text, associate=False)
def public_key_model(
@@ -532,7 +534,7 @@ class FoundationCommitter(GeneralPublic):
async def __database_add_model(
self,
key: datatypes.Key,
- ) -> outcome.Outcome[datatypes.Key]:
+ ) -> tuple[outcome.Outcome[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
via = sql.validate_instrumented_attribute
await self.__data.begin_immediate()
@@ -564,13 +566,14 @@ class FoundationCommitter(GeneralPublic):
asf_uid=self.__asf_uid,
fingerprints=[f for f in undeleted],
)
- await self.__sync_committees_for_keys(undeleted)
+ publications = await self.__sync_committees_for_keys(undeleted)
log.info(f"Undeleted key {key.key_model.fingerprint}")
- return outcome.Result(datatypes.Key(status=datatypes.KeyStatus.INSERTED, key_model=key.key_model))
+ undeleted_key = datatypes.Key(status=datatypes.KeyStatus.INSERTED, key_model=key.key_model)
+ return outcome.Result(undeleted_key), publications
if not inserted:
log.info(f"Key {key.key_model.fingerprint} already exists in database")
- return outcome.Result(datatypes.Key(status=datatypes.KeyStatus.PARSED, key_model=key.key_model))
+ return outcome.Result(datatypes.Key(status=datatypes.KeyStatus.PARSED, key_model=key.key_model)), {}
log.info(f"Inserted key {key.key_model.fingerprint}")
self.__write_as.append_to_audit_log(
@@ -581,15 +584,17 @@ class FoundationCommitter(GeneralPublic):
)
# TODO: PARSED now acts as "ALREADY_ADDED"
- return outcome.Result(datatypes.Key(status=datatypes.KeyStatus.INSERTED, key_model=key.key_model))
+ return outcome.Result(datatypes.Key(status=datatypes.KeyStatus.INSERTED, key_model=key.key_model)), {}
- async def __ensure_one(self, key_file_text: str, associate: bool = True) -> outcome.Outcome[datatypes.Key]:
+ async def __ensure_one(
+ self, key_file_text: str, associate: bool = True
+ ) -> tuple[outcome.Outcome[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
try:
key_blocks = util.parse_key_blocks(key_file_text)
except Exception as e:
- return outcome.Error(e)
+ return outcome.Error(e), {}
if len(key_blocks) != 1:
- return outcome.Error(ValueError("Expected one key block, got none or multiple"))
+ return outcome.Error(ValueError("Expected one key block, got none or multiple")), {}
key_block = key_blocks[0]
try:
key = await asyncio.to_thread(self.__block_model_create, key_block, cache.EmailUidLookup({}))
@@ -599,15 +604,14 @@ class FoundationCommitter(GeneralPublic):
else:
self.__key_block_models_cache[key_block] = [key]
except Exception as e:
- return outcome.Error(e)
+ return outcome.Error(e), {}
if key.key_model.apache_uid is None:
return outcome.Error(
datatypes.UnknownApacheUidError(
"OpenPGP key could not be associated with an ASF UID. Import it through a KEYS file instead."
)
- )
- oc = await self.__database_add_model(key)
- return oc
+ ), {}
+ return await self.__database_add_model(key)
async def __keys_file_format(
self,
@@ -679,9 +683,11 @@ and was published by the committee.\
)
return flagged
- async def __sync_committees_for_keys(self, fingerprints: list[str]) -> None:
+ async def __sync_committees_for_keys(
+ self, fingerprints: list[str]
+ ) -> dict[str, outcome.Outcome[datatypes.KeysPublish]]:
if not fingerprints:
- return
+ return {}
via = sql.validate_instrumented_attribute
link_rows = await self.__data.execute(
sqlmodel.select(via(sql.KeyLink.committee_key))
@@ -689,9 +695,12 @@ and was published by the committee.\
.distinct()
)
committee_keys = sorted(set(link_rows.scalars().all()))
+ publications: dict[str, outcome.Outcome[datatypes.KeysPublish]] = {}
for committee_key in committee_keys:
- await self._sync_committee_keys_file(committee_key)
+ _, publication = await self._sync_committee_keys_file(committee_key)
+ publications[committee_key] = publication
await self._recheck_committee_drafts(*committee_keys)
+ return publications
def __uids_asf_uid(self, uids: list[str], ldap_data: cache.EmailUidLookup) -> str | None:
# Test data
@@ -827,25 +836,27 @@ class CommitteeParticipant(FoundationCommitter):
async def ensure_associated(
self, keys_file_text: str
- ) -> tuple[outcome.List[datatypes.Key], outcome.Outcome[datatypes.KeysPublish] | None]:
- outcomes: outcome.List[datatypes.Key] = await self.__ensure(keys_file_text, associate=True)
+ ) -> tuple[outcome.List[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
+ outcomes, publications = await self.__ensure(keys_file_text, associate=True)
if not outcomes.any_result:
- return outcomes, None
+ return outcomes, publications
_, publication = await self.autogenerate_keys_file()
- return outcomes, publication
+ publications[self.__committee_key] = publication
+ return outcomes, publications
async def ensure_stored(
self, keys_file_text: str
- ) -> tuple[outcome.List[datatypes.Key], outcome.Outcome[datatypes.KeysPublish] | None]:
- outcomes: outcome.List[datatypes.Key] = await self.__ensure(keys_file_text, associate=False)
+ ) -> tuple[outcome.List[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
+ outcomes, publications = await self.__ensure(keys_file_text, associate=False)
if not outcomes.any_result:
- return outcomes, None
+ return outcomes, publications
_, publication = await self.autogenerate_keys_file()
- return outcomes, publication
+ publications[self.__committee_key] = publication
+ return outcomes, publications
async def import_keys_file(
self, project_key: safe.ProjectKey, version_key: safe.VersionKey
- ) -> tuple[outcome.List[datatypes.Key], outcome.Outcome[datatypes.KeysPublish] | None]:
+ ) -> tuple[outcome.List[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
release = await self.__data.release(
project_key=str(project_key),
version=str(version_key),
@@ -862,7 +873,7 @@ class CommitteeParticipant(FoundationCommitter):
status=403,
)
- outcomes, publication = await self.ensure_associated(keys_file_text)
+ outcomes, publications = await self.ensure_associated(keys_file_text)
release_keys_removed = False
# Remove the KEYS file if 100% imported
if (outcomes.result_count > 0) and (outcomes.error_count == 0):
@@ -891,7 +902,7 @@ class CommitteeParticipant(FoundationCommitter):
failed_keys=outcomes.error_count,
release_keys_removed=release_keys_removed,
)
- return outcomes, publication
+ return outcomes, publications
def __block_models(self, key_block: str, ldap_data: cache.EmailUidLookup) -> list[datatypes.Key | Exception]:
try:
@@ -914,10 +925,11 @@ class CommitteeParticipant(FoundationCommitter):
async def __database_add_models(
self, outcomes: outcome.List[datatypes.Key], associate: bool = True
- ) -> outcome.List[datatypes.Key]:
+ ) -> tuple[outcome.List[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
# Try to upsert all models and link to the committee in one transaction
+ publications: dict[str, outcome.Outcome[datatypes.KeysPublish]] = {}
try:
- outcomes = await self.__database_add_models_core(outcomes, associate=associate)
+ outcomes, publications = await self.__database_add_models_core(outcomes, associate=associate)
except Exception as e:
# This logging is just so that ruff does not erase e
log.info(f"Post-parse error: {e}")
@@ -929,13 +941,13 @@ class CommitteeParticipant(FoundationCommitter):
raise datatypes.PublicKeyError(key, e)
outcomes.update_roes(Exception, raise_post_parse_error)
- return outcomes
+ return outcomes, publications
async def __database_add_models_core( # noqa: C901
self,
outcomes: outcome.List[datatypes.Key],
associate: bool = True,
- ) -> outcome.List[datatypes.Key]:
+ ) -> tuple[outcome.List[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
via = sql.validate_instrumented_attribute
key_list = outcomes.results()
@@ -1009,29 +1021,30 @@ class CommitteeParticipant(FoundationCommitter):
inserted_fingerprints=sorted(key_inserts),
linked_fingerprints=sorted(link_inserts),
)
+ publications: dict[str, outcome.Outcome[datatypes.KeysPublish]] = {}
if undeleted:
self.__write_as.append_to_audit_log(
action="key_undelete",
asf_uid=self.__asf_uid,
fingerprints=[f for f in undeleted],
)
- await self.__sync_committees_for_keys(undeleted)
+ publications = await self.__sync_committees_for_keys(undeleted)
if link_inserts:
await self._recheck_committee_drafts(self.__committee_key)
- return outcomes
+ return outcomes, publications
async def __ensure(
self,
keys_file_text: str,
associate: bool = True,
- ) -> outcome.List[datatypes.Key]:
+ ) -> tuple[outcome.List[datatypes.Key], dict[str, outcome.Outcome[datatypes.KeysPublish]]]:
outcomes = outcome.List[datatypes.Key]()
try:
ldap_data = await cache.email_uid_view_or_live()
key_blocks = util.parse_key_blocks(keys_file_text)
except Exception as e:
outcomes.append_error(e)
- return outcomes
+ return outcomes, {}
# TODO: Change self.__block_models to return outcomes
tasks = [
asyncio.create_task(asyncio.to_thread(self.__block_models, key_block, ldap_data))
@@ -1072,9 +1085,11 @@ class CommitteeParticipant(FoundationCommitter):
)
return flagged
- async def __sync_committees_for_keys(self, fingerprints: list[str]) -> None:
+ async def __sync_committees_for_keys(
+ self, fingerprints: list[str]
+ ) -> dict[str, outcome.Outcome[datatypes.KeysPublish]]:
if not fingerprints:
- return
+ return {}
via = sql.validate_instrumented_attribute
link_rows = await self.__data.execute(
sqlmodel.select(via(sql.KeyLink.committee_key))
@@ -1082,9 +1097,12 @@ class CommitteeParticipant(FoundationCommitter):
.distinct()
)
committee_keys = sorted(set(link_rows.scalars().all()))
+ publications: dict[str, outcome.Outcome[datatypes.KeysPublish]] = {}
for committee_key in committee_keys:
- await self._sync_committee_keys_file(committee_key)
+ _, publication = await self._sync_committee_keys_file(committee_key)
+ publications[committee_key] = publication
await self._recheck_committee_drafts(*committee_keys)
+ return publications
async def __undelete_keys(self, fingerprints: list[str]) -> list[str]:
if not fingerprints:
diff --git a/atr/templates/committee-view.html b/atr/templates/committee-view.html
index 9879f705..3dc7d034 100644
--- a/atr/templates/committee-view.html
+++ b/atr/templates/committee-view.html
@@ -232,22 +232,32 @@
The <code>KEYS</code> file is automatically generated when you add or remove a key, but you can also use the form below to manually regenerate it.
</p>
{{ update_committee_keys_form }}
- {% if committee.automated_keys_file %}
- <p class="text-muted mt-3">
- Automated publication of the <code>KEYS</code> file to SVN is <strong>enabled</strong> for this committee.
- </p>
- {% else %}
- <p class="text-muted mt-3">
- Automated publication of the <code>KEYS</code> file to SVN is <strong>disabled</strong> for this committee. Key changes are not published until it is enabled or the file is updated manually.
- </p>
- {% endif %}
- {{ automated_keys_file_form }}
{% endif %}
{% else %}
<p class="mb-4">No keys uploaded for this committee yet.</p>
{% endif %}
</div>
</div>
+
+ {% if is_committee_member %}
+ <div class="card mb-4">
+ <div class="card-header bg-light">
+ <h3 class="mb-0">KEYS publication</h3>
+ </div>
+ <div class="card-body">
+ {% if committee.automated_keys_file %}
+ <p class="text-muted">
+ Automated publication of the <code>KEYS</code> file to SVN is <strong>enabled</strong> for this committee.
+ </p>
+ {% else %}
+ <p class="text-muted">
+ Automated publication of the <code>KEYS</code> file to SVN is <strong>disabled</strong> for this committee. Key changes are not published until it is enabled or the file is updated manually.
+ </p>
+ {% endif %}
+ {{ automated_keys_file_form }}
+ </div>
+ </div>
+ {% endif %}
{% endif %}
{% endblock content %}
diff --git a/scripts/keys_import.py b/scripts/keys_import.py
index bad130cc..ceb80957 100755
--- a/scripts/keys_import.py
+++ b/scripts/keys_import.py
@@ -184,11 +184,12 @@ async def keys_import(conf: config.AppConfig, asf_uid: str) -> None:
async with storage.write(asf_uid) as write:
waca = write.as_committee_admin(committee_key)
keys_file_text = content.decode("utf-8", errors="replace")
- outcomes, publication = await waca.keys.ensure_associated(keys_file_text)
+ outcomes, publications = await waca.keys.ensure_associated(keys_file_text)
log_outcome_errors(outcomes, committee_key)
disabled = datatypes.KeysPublish.AUTOMATION_DISABLED
- if (publication is not None) and (publication.result_or_none() is disabled):
- print_and_flush(f"{committee_key} KEYS file not published: automated publication disabled")
+ for name, publication in sorted(publications.items()):
+ if publication.result_or_none() is disabled:
+ print_and_flush(f"{name} KEYS file not published: automated publication disabled")
yes = outcomes.result_count
no = outcomes.error_count
diff --git a/tests/unit/test_keys_delete.py b/tests/unit/test_keys_delete.py
index 55edcad6..a002fedd 100644
--- a/tests/unit/test_keys_delete.py
+++ b/tests/unit/test_keys_delete.py
@@ -176,9 +176,10 @@ async def test_reupload_undeletes_and_restores_associations(sqlite_sessionmaker)
key_model=_key(ALPHA_FINGERPRINT, "alice", "armored alpha"),
)
- oc = await writer._FoundationCommitter__database_add_model(key)
+ oc, publications = await writer._FoundationCommitter__database_add_model(key)
assert oc.result_or_raise().status == datatypes.KeyStatus.INSERTED
+ assert set(publications) == {"tooling"}
row = await data.public_signing_key(fingerprint=ALPHA_FINGERPRINT).get()
assert row is not None
assert row.deleted is None
diff --git a/tests/unit/test_keys_shared.py b/tests/unit/test_keys_shared.py
index 5d4ff19d..d6670534 100644
--- a/tests/unit/test_keys_shared.py
+++ b/tests/unit/test_keys_shared.py
@@ -20,7 +20,7 @@ import atr.storage.datatypes as datatypes
import atr.storage.outcome as outcome
-def test_publication_notices_report_only_disabled_committees() -> None:
+def test_publication_notices_report_disabled_and_failed_committees() -> None:
publications = {
"delta": outcome.Result(datatypes.KeysPublish.AUTOMATION_DISABLED),
"alpha": outcome.Result(datatypes.KeysPublish.AUTOMATION_DISABLED),
@@ -31,9 +31,12 @@ def test_publication_notices_report_only_disabled_committees() -> None:
notice = keys.publication_added_notice(publications)
warning = keys.publication_removed_warning(publications)
+ failure = keys.publication_failed_warning(publications)
assert keys.publication_disabled(publications) == ["alpha", "delta"]
assert (notice is not None) and ("alpha and delta" in notice)
assert (warning is not None) and ("alpha and delta" in warning)
+ assert (failure is not None) and ("epsilon (publish failed)" in failure)
assert keys.publication_added_notice({"beta": outcome.Result(datatypes.KeysPublish.PUBLISHED)}) is None
assert keys.publication_removed_warning({}) is None
+ assert keys.publication_failed_warning({"beta": outcome.Result(datatypes.KeysPublish.PUBLISHED)}) is None
diff --git a/tests/unit/test_keys_writer.py b/tests/unit/test_keys_writer.py
index cb75c0aa..d99f7cde 100644
--- a/tests/unit/test_keys_writer.py
+++ b/tests/unit/test_keys_writer.py
@@ -125,9 +125,10 @@ async def test_database_add_model_audits_inserted_key():
)
key = SimpleNamespace(key_model=key_model, member_ids=[])
- result = await writer._FoundationCommitter__database_add_model(key)
+ result, publications = await writer._FoundationCommitter__database_add_model(key)
assert isinstance(result, outcome.Result)
+ assert publications == {}
data.begin_immediate.assert_awaited_once()
data.commit.assert_awaited_once()
write_as.append_to_audit_log.assert_called_once()
@@ -271,12 +272,13 @@ async def test_ensure_allows_key_without_apache_uid_for_bulk_import() -> None:
mock.patch.object(
writer,
"_CommitteeParticipant__database_add_models",
- new=mock.AsyncMock(return_value=database_outcomes),
+ new=mock.AsyncMock(return_value=(database_outcomes, {})),
) as database_add_models,
):
- result = await writer._CommitteeParticipant__ensure("keys text")
+ result, publications = await writer._CommitteeParticipant__ensure("keys text")
assert result is database_outcomes
+ assert publications == {}
email_uid_view.assert_awaited_once()
block_models.assert_called_once_with("block-one", lookup)
database_add_models.assert_awaited_once()
@@ -302,12 +304,13 @@ async def test_ensure_stored_one_accepts_key_with_apache_uid() -> None:
mock.patch.object(
writer,
"_FoundationCommitter__database_add_model",
- new=mock.AsyncMock(return_value=database_outcome),
+ new=mock.AsyncMock(return_value=(database_outcome, {})),
) as database_add_model,
):
- result = await writer.ensure_stored_one("keys text")
+ result, publications = await writer.ensure_stored_one("keys text")
assert result is database_outcome
+ assert publications == {}
email_uid_view.assert_not_awaited()
block_model_create.assert_called_once()
assert block_model_create.call_args.args[0] == "block-one"
@@ -332,10 +335,10 @@ async def test_ensure_stored_one_accepts_test_key_without_email_cache() -> None:
mock.patch.object(
writer,
"_FoundationCommitter__database_add_model",
- new=mock.AsyncMock(side_effect=lambda key: outcome.Result(key)),
+ new=mock.AsyncMock(side_effect=lambda key: (outcome.Result(key), {})),
) as database_add_model,
):
- result = await writer.ensure_stored_one(key_text)
+ result, _publications = await writer.ensure_stored_one(key_text)
key = result.result_or_raise()
assert key.key_model.apache_uid == "test"
@@ -368,9 +371,10 @@ async def test_ensure_stored_one_rejects_key_without_apache_uid() -> None:
new=mock.AsyncMock(),
) as database_add_model,
):
- result = await writer.ensure_stored_one("keys text")
+ result, publications = await writer.ensure_stored_one("keys text")
assert isinstance(result, outcome.Error)
+ assert publications == {}
error = result.error_or_none()
assert isinstance(error, keys_writer.datatypes.UnknownApacheUidError)
assert str(error) == "OpenPGP key could not be associated with an ASF UID. Import it through a KEYS file instead."
@@ -405,10 +409,10 @@ async def test_ensure_uses_cached_email_lookup_for_bulk_import() -> None:
mock.patch.object(
writer,
"_CommitteeParticipant__database_add_models",
- new=mock.AsyncMock(return_value=database_outcomes),
+ new=mock.AsyncMock(return_value=(database_outcomes, {})),
) as database_add_models,
):
- result = await writer._CommitteeParticipant__ensure("keys text")
+ result, _publications = await writer._CommitteeParticipant__ensure("keys text")
assert result is database_outcomes
email_uid_view.assert_awaited_once()
@@ -550,6 +554,31 @@ def test_set_automated_keys_file_requires_membership() -> None:
assert error.status == 403
+@pytest.mark.asyncio
+async def test_sync_committees_for_keys_returns_publications():
+ data = MockData(None, committees_after_commit={})
+ writer, _write, _write_as = _make_foundation_committer_with_audit(data)
+ link_rows = mock.MagicMock()
+ link_rows.scalars.return_value.all.return_value = ["beta", "alpha"]
+ data.execute.return_value = link_rows
+ disabled = outcome.Result(keys_writer.datatypes.KeysPublish.AUTOMATION_DISABLED)
+ published = outcome.Result(keys_writer.datatypes.KeysPublish.PUBLISHED)
+
+ with (
+ mock.patch.object(
+ writer,
+ "_sync_committee_keys_file",
+ new=mock.AsyncMock(side_effect=[(None, disabled), ("alpha/KEYS", published)]),
+ ) as sync_file,
+ mock.patch.object(writer, "_recheck_committee_drafts", new=mock.AsyncMock()) as recheck,
+ ):
+ publications = await writer._FoundationCommitter__sync_committees_for_keys(["fp1"])
+
+ assert publications == {"alpha": disabled, "beta": published}
+ assert sync_file.await_args_list == [mock.call("alpha"), mock.call("beta")]
+ recheck.assert_awaited_once_with("alpha", "beta")
+
+
@pytest.mark.asyncio
async def test_update_committee_associations_removal_deletes_empty_keys_file(tmp_path):
owned_key = SimpleNamespace(fingerprint="fp1", committees=[SimpleNamespace(key="alpha")])
---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org