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-releases.git
The following commit(s) were added to refs/heads/main by this push:
new 89c5155 Use separate forms for the two ways to upload KEYS files
89c5155 is described below
commit 89c5155f1d70abc47b282bf230504b7f43c7d497
Author: Sean B. Palmer <[email protected]>
AuthorDate: Tue Dec 16 19:53:08 2025 +0000
Use separate forms for the two ways to upload KEYS files
---
atr/post/keys.py | 117 +++++++++++++++++++++---------
atr/shared/keys.py | 204 ++++++++++++++++++++++-------------------------------
2 files changed, 165 insertions(+), 156 deletions(-)
diff --git a/atr/post/keys.py b/atr/post/keys.py
index 9a1728f..2ba25a4 100644
--- a/atr/post/keys.py
+++ b/atr/post/keys.py
@@ -17,6 +17,7 @@
import asyncio
+from typing import Final
import aiohttp
import asfquart.base as base
@@ -35,6 +36,8 @@ import atr.storage.types as types
import atr.util as util
import atr.web as web
+_KEYS_BASE_URL: Final[str] = "https://downloads.apache.org"
+
@post.committer("/keys/add")
@post.form(shared.keys.AddOpenPGPKeyForm)
@@ -179,41 +182,18 @@ async def ssh_add(session: web.Committer,
add_ssh_key_form: shared.keys.AddSSHKe
@post.committer("/keys/upload")
@post.form(shared.keys.UploadKeysForm)
async def upload(session: web.Committer, upload_form:
shared.keys.UploadKeysForm) -> str:
- """Upload a KEYS file containing multiple OpenPGP keys."""
- keys_text = ""
-
- try:
- if upload_form.key:
- keys_content = await asyncio.to_thread(upload_form.key.read)
- keys_text = keys_content.decode("utf-8", errors="replace")
- elif upload_form.keys_url:
- keys_text = await _fetch_keys_from_url(str(upload_form.keys_url))
-
- if not keys_text:
- await quart.flash("No KEYS data found", "error")
- return await shared.keys.render_upload_page(error=True)
-
- selected_committee = upload_form.selected_committee
-
- async with storage.write() as write:
- wacp = write.as_committee_participant(selected_committee)
- outcomes = await wacp.keys.ensure_associated(keys_text)
-
- success_count = outcomes.result_count
- error_count = outcomes.error_count
- total_count = success_count + error_count
-
- message = f"Processed {total_count} keys: {success_count} successful"
- if error_count > 0:
- message += f", {error_count} failed"
+ """Upload or fetch a KEYS file containing multiple OpenPGP keys."""
+ match upload_form:
+ case shared.keys.UploadFileForm() as upload_file_form:
+ return await _upload_file_keys(upload_file_form)
+ case shared.keys.UploadRemoteForm() as upload_remote_form:
+ return await _upload_remote_keys(upload_remote_form)
- await quart.flash(message, "success" if (success_count > 0) else
"error")
- return await shared.keys.render_upload_page(results=outcomes,
submitted_committees=[selected_committee])
- except Exception as e:
- log.exception("Error uploading KEYS file:")
- await quart.flash(f"Error processing KEYS file: {e!s}", "error")
- return await shared.keys.render_upload_page(error=True)
+def _construct_keys_url(committee_name: str, *, is_podling: bool) -> str:
+ if is_podling:
+ return f"{_KEYS_BASE_URL}/incubator/{committee_name}/KEYS"
+ return f"{_KEYS_BASE_URL}/{committee_name}/KEYS"
async def _delete_openpgp_key(
@@ -247,10 +227,36 @@ async def _delete_ssh_key(session: web.Committer,
delete_form: shared.keys.Delet
return await session.redirect(get.keys.keys, success="SSH key deleted
successfully")
+async def _upload_remote_keys(upload_remote_form:
shared.keys.UploadRemoteForm) -> str:
+ """Fetch KEYS file from ASF downloads."""
+ try:
+ selected_committee = upload_remote_form.committee
+ async with db.session() as data:
+ committee = await data.committee(name=selected_committee).get()
+ if not committee:
+ await quart.flash(f"Committee '{selected_committee}' not
found", "error")
+ return await shared.keys.render_upload_page(error=True)
+ is_podling = committee.is_podling
+
+ keys_url = _construct_keys_url(selected_committee,
is_podling=is_podling)
+ keys_text = await _fetch_keys_from_url(keys_url)
+
+ if not keys_text:
+ await quart.flash("No KEYS data found at ASF downloads", "error")
+ return await shared.keys.render_upload_page(error=True)
+
+ return await _process_keys(keys_text, selected_committee)
+ except Exception as e:
+ log.exception("Error fetching KEYS file from ASF:")
+ await quart.flash(f"Error fetching KEYS file: {e!s}", "error")
+ return await shared.keys.render_upload_page(error=True)
+
+
async def _fetch_keys_from_url(keys_url: str) -> str:
- """Fetch KEYS file content from a URL."""
+ """Fetch KEYS file from ASF downloads."""
try:
- async with aiohttp.ClientSession() as session:
+ timeout = aiohttp.ClientTimeout(total=30)
+ async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(keys_url, allow_redirects=True) as response:
response.raise_for_status()
return await response.text()
@@ -260,6 +266,25 @@ async def _fetch_keys_from_url(keys_url: str) -> str:
raise base.ASFQuartException(f"Network error while fetching keys:
{e}", errorcode=503)
+async def _process_keys(keys_text: str, selected_committee: str) -> str:
+ """Process keys text and associate with committee."""
+ async with storage.write() as write:
+ wacp = write.as_committee_participant(selected_committee)
+ outcomes = await wacp.keys.ensure_associated(keys_text)
+
+ success_count = outcomes.result_count
+ error_count = outcomes.error_count
+ total_count = success_count + error_count
+
+ message = f"Processed {total_count} keys: {success_count} successful"
+ if error_count > 0:
+ message += f", {error_count} failed"
+
+ await quart.flash(message, "success" if (success_count > 0) else "error")
+
+ return await shared.keys.render_upload_page(results=outcomes,
submitted_committees=[selected_committee])
+
+
async def _update_committee_keys(
session: web.Committer, update_form: shared.keys.UpdateCommitteeKeysForm
) -> web.WerkzeugResponse:
@@ -277,3 +302,25 @@ async def _update_committee_keys(
await quart.flash(f"Error regenerating the KEYS file for the
{committee_name} committee.", "error")
return await session.redirect(get.keys.keys)
+
+
+async def _upload_file_keys(upload_file_form: shared.keys.UploadFileForm) ->
str:
+ """Handle file upload."""
+ try:
+ if upload_file_form.key is None:
+ await quart.flash("No KEYS file uploaded", "error")
+ return await shared.keys.render_upload_page(error=True)
+
+ keys_content = await asyncio.to_thread(upload_file_form.key.read)
+ keys_text = keys_content.decode("utf-8", errors="replace")
+
+ if not keys_text:
+ await quart.flash("No KEYS data found", "error")
+ return await shared.keys.render_upload_page(error=True)
+
+ selected_committee = upload_file_form.selected_committee
+ return await _process_keys(keys_text, selected_committee)
+ except Exception as e:
+ log.exception("Error uploading KEYS file:")
+ await quart.flash(f"Error processing KEYS file: {e!s}", "error")
+ return await shared.keys.render_upload_page(error=True)
diff --git a/atr/shared/keys.py b/atr/shared/keys.py
index 65889a2..4b66d67 100644
--- a/atr/shared/keys.py
+++ b/atr/shared/keys.py
@@ -19,7 +19,6 @@
from typing import Annotated, Literal
-import htpy
import markupsafe
import pydantic
@@ -33,7 +32,9 @@ import atr.util as util
type DELETE_OPENPGP_KEY = Literal["delete_openpgp_key"]
type DELETE_SSH_KEY = Literal["delete_ssh_key"]
+type UPLOAD_REMOTE_KEYS = Literal["upload_remote_keys"]
type UPDATE_COMMITTEE_KEYS = Literal["update_committee_keys"]
+type UPLOAD_FILE_KEYS = Literal["upload_file_keys"]
class AddOpenPGPKeyForm(form.Form):
@@ -91,18 +92,13 @@ class UpdateKeyCommitteesForm(form.Form):
)
-class UploadKeysForm(form.Form):
+class UploadFileForm(form.Form):
+ variant: UPLOAD_FILE_KEYS = form.value(UPLOAD_FILE_KEYS)
key: form.File = form.label(
"KEYS file",
"Upload a KEYS file containing multiple PGP public keys."
" The file should contain keys in ASCII-armored format, starting with"
' "-----BEGIN PGP PUBLIC KEY BLOCK-----".',
- widget=form.Widget.CUSTOM,
- )
- keys_url: form.OptionalURL = form.label(
- "KEYS file URL",
- "Enter a URL to a KEYS file. This will be fetched by the server.",
- widget=form.Widget.CUSTOM,
)
selected_committee: str = form.label(
"Associate keys with committee",
@@ -111,14 +107,88 @@ class UploadKeysForm(form.Form):
)
@pydantic.model_validator(mode="after")
- def validate_key_source(self) -> "UploadKeysForm":
- if (not self.key) and (not self.keys_url):
- raise ValueError("Either a file or a URL is required")
- if self.key and self.keys_url:
- raise ValueError("Provide either a file or a URL, not both")
+ def validate_key_required(self) -> "UploadFileForm":
+ if not self.key:
+ raise ValueError("A KEYS file is required")
return self
+class UploadRemoteForm(form.Form):
+ variant: UPLOAD_REMOTE_KEYS = form.value(UPLOAD_REMOTE_KEYS)
+ committee: str = form.label(
+ "Committee",
+ "Select the committee whose KEYS file to fetch from ASF downloads.",
+ widget=form.Widget.RADIO,
+ )
+
+
+type UploadKeysForm = Annotated[
+ UploadFileForm | UploadRemoteForm,
+ form.DISCRIMINATOR,
+]
+
+
+async def render_upload_page(
+ results: storage.outcome.List | None = None,
+ submitted_committees: list[str] | None = None,
+ error: bool = False,
+) -> str:
+ """Render the upload page with optional results."""
+ import atr.get as get
+ import atr.post as post
+
+ async with storage.write() as write:
+ participant_of_committees = await write.participant_of_committees()
+
+ eligible_committees = [
+ c for c in participant_of_committees if (not
util.committee_is_standing(c.name)) or (c.name == "tooling")
+ ]
+
+ committee_choices = [(c.name, c.display_name) for c in eligible_committees]
+ committee_map = {c.name: c.display_name for c in eligible_committees}
+
+ page = htm.Block()
+ page.p[htm.a(".atr-back-link", href=util.as_url(get.keys.keys))["← Back to
Manage keys"]]
+ page.h1["Import KEYS"]
+ page.p["Import OpenPGP public signing keys from a KEYS file."]
+
+ if results and submitted_committees:
+ page.append(_get_results_table_css())
+ _render_results_table(page, results, submitted_committees,
committee_map)
+
+ page.h2["Upload a file"]
+ page.p["Upload a KEYS file from your computer."]
+
+ form.render_block(
+ page,
+ model_cls=shared.keys.UploadFileForm,
+ action=util.as_url(post.keys.upload),
+ submit_label="Upload KEYS file",
+ defaults={"selected_committee": committee_choices},
+ border=True,
+ wider_widgets=True,
+ )
+
+ page.h2(".mt-5")["Fetch existing KEYS file"]
+ page.p["Fetch the KEYS file from the ASF downloads server for the selected
committee."]
+
+ form.render_block(
+ page,
+ model_cls=shared.keys.UploadRemoteForm,
+ action=util.as_url(post.keys.upload),
+ submit_label="Fetch KEYS file",
+ defaults={"committee": committee_choices},
+ border=True,
+ wider_widgets=True,
+ )
+
+ return await template.blank(
+ "Import KEYS",
+ content=page.collect(),
+ description="Import OpenPGP public signing keys from a KEYS file.",
+ )
+
+
def _get_results_table_css() -> htm.Element:
return htm.style[
markupsafe.Markup(
@@ -246,111 +316,3 @@ def _render_results_table(
for outcome in processing_errors:
err = outcome.error_or_none()
page.div(".alert.alert-danger.p-2.mb-3")[str(err)]
-
-
-async def render_upload_page(
- results: storage.outcome.List | None = None,
- submitted_committees: list[str] | None = None,
- error: bool = False,
-) -> str:
- """Render the upload page with optional results."""
- import atr.get as get
- import atr.post as post
-
- async with storage.write() as write:
- participant_of_committees = await write.participant_of_committees()
-
- eligible_committees = [
- c for c in participant_of_committees if (not
util.committee_is_standing(c.name)) or (c.name == "tooling")
- ]
-
- committee_choices = [(c.name, c.display_name) for c in eligible_committees]
- committee_map = {c.name: c.display_name for c in eligible_committees}
-
- page = htm.Block()
- page.p[htm.a(".atr-back-link", href=util.as_url(get.keys.keys))["← Back to
Manage keys"]]
- page.h1["Upload a KEYS file"]
- page.p["Upload a KEYS file containing multiple OpenPGP public signing
keys."]
-
- if results and submitted_committees:
- page.append(_get_results_table_css())
- _render_results_table(page, results, submitted_committees,
committee_map)
-
- custom_tabs_widget = _render_upload_tabs()
-
- form.render_block(
- page,
- model_cls=shared.keys.UploadKeysForm,
- action=util.as_url(post.keys.upload),
- submit_label="Upload KEYS file",
- cancel_url=util.as_url(get.keys.keys),
- defaults={"selected_committee": committee_choices},
- custom={"key": custom_tabs_widget},
- skip=["keys_url"],
- border=True,
- wider_widgets=True,
- )
-
- return await template.blank(
- "Upload a KEYS file",
- content=page.collect(),
- description="Upload a KEYS file containing multiple OpenPGP public
signing keys.",
- )
-
-
-def _render_upload_tabs() -> htm.Element:
- """Render the tabbed interface for file upload or URL input."""
- tabs_ul = htm.ul(".nav.nav-tabs", id="keysUploadTab", role="tablist")[
- htm.li(".nav-item", role="presentation")[
- htpy.button(
- class_="nav-link active",
- id="file-upload-tab",
- data_bs_toggle="tab",
- data_bs_target="#file-upload-pane",
- type="button",
- role="tab",
- aria_controls="file-upload-pane",
- aria_selected="true",
- )["Upload from file"]
- ],
- htm.li(".nav-item", role="presentation")[
- htpy.button(
- class_="nav-link",
- id="url-upload-tab",
- data_bs_toggle="tab",
- data_bs_target="#url-upload-pane",
- type="button",
- role="tab",
- aria_controls="url-upload-pane",
- aria_selected="false",
- )["Upload from URL"]
- ],
- ]
-
- file_pane = htm.div(".tab-pane.fade.show.active", id="file-upload-pane",
role="tabpanel")[
- htm.div(".pt-3")[
- htpy.input(class_="form-control", id="key", name="key",
type="file"),
- htm.div(".form-text.text-muted.mt-2")[
- "Upload a KEYS file containing multiple PGP public keys. The
file should contain keys in "
- 'ASCII-armored format, starting with "-----BEGIN PGP PUBLIC
KEY BLOCK-----".'
- ],
- ]
- ]
-
- url_pane = htm.div(".tab-pane.fade", id="url-upload-pane",
role="tabpanel")[
- htm.div(".pt-3")[
- htpy.input(
- class_="form-control",
- id="keys_url",
- name="keys_url",
- placeholder="Enter URL to KEYS file",
- type="url",
- value="",
- ),
- htm.div(".form-text.text-muted.mt-2")["Enter a URL to a KEYS file.
This will be fetched by the server."],
- ]
- ]
-
- tab_content = htm.div(".tab-content",
id="keysUploadTabContent")[file_pane, url_pane]
-
- return htm.div[tabs_ul, tab_content]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]