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 1dbe129  Avoid failing a KEYS upload when any key is already associated
1dbe129 is described below

commit 1dbe129ceca8c6d1cdbcbe5f9c8cf3c32236fd50
Author: Sean B. Palmer <[email protected]>
AuthorDate: Thu Apr 24 16:51:16 2025 +0100

    Avoid failing a KEYS upload when any key is already associated
---
 atr/routes/keys.py             | 153 +++++++++++++++++++++++++++--------
 atr/templates/keys-upload.html | 177 ++++++++++++++++++++++++++++-------------
 2 files changed, 244 insertions(+), 86 deletions(-)

diff --git a/atr/routes/keys.py b/atr/routes/keys.py
index 6f51c44..97e4173 100644
--- a/atr/routes/keys.py
+++ b/atr/routes/keys.py
@@ -32,6 +32,7 @@ from collections.abc import AsyncGenerator, Sequence
 import asfquart as asfquart
 import gnupg
 import quart
+import sqlmodel
 import werkzeug.datastructures as datastructures
 import werkzeug.wrappers.response as response
 import wtforms
@@ -256,41 +257,80 @@ async def key_user_session_add(
         raise routes.FlashError("Invalid key fingerprint")
     fingerprint = fingerprint.lower()
     uids = key.get("uids")
+    key_record: models.PublicSigningKey | None = None
+
     async with data.begin():
-        if existing := await data.public_signing_key(fingerprint=fingerprint, 
apache_uid=asf_uid).get():
-            raise routes.FlashError(f"Key already exists: 
{existing.fingerprint}")
-        created = datetime.datetime.fromtimestamp(int(key["date"]), 
tz=datetime.UTC)
-        expires = datetime.datetime.fromtimestamp(int(key["expires"]), 
tz=datetime.UTC) if key.get("expires") else None
-        # Create new key record
-        key_record = models.PublicSigningKey(
-            fingerprint=fingerprint,
-            algorithm=int(key["algo"]),
-            length=int(key.get("length", "0")),
-            created=created,
-            expires=expires,
-            declared_uid=uids[0] if uids else None,
-            apache_uid=asf_uid,
-            ascii_armored_key=public_key,
-        )
-        data.add(key_record)
+        existing = await data.public_signing_key(fingerprint=fingerprint, 
apache_uid=asf_uid).get()
+
+        if existing:
+            logging.info(f"Found existing key {fingerprint}, updating 
associations")
+            key_record = existing
+        else:
+            # Key doesn't exist, create it
+            logging.info(f"Adding new key {fingerprint}")
+            created = datetime.datetime.fromtimestamp(int(key["date"]), 
tz=datetime.UTC)
+            expires = (
+                datetime.datetime.fromtimestamp(int(key["expires"]), 
tz=datetime.UTC) if key.get("expires") else None
+            )
+
+            key_record = models.PublicSigningKey(
+                fingerprint=fingerprint,
+                algorithm=int(key["algo"]),
+                length=int(key.get("length", "0")),
+                created=created,
+                expires=expires,
+                declared_uid=uids[0] if uids else None,
+                apache_uid=asf_uid,
+                ascii_armored_key=public_key,
+            )
+            data.add(key_record)
+            await data.flush()
+            await data.refresh(key_record)
+
+        # Safety check, in case of strange flushes
+        if not key_record:
+            raise RuntimeError(f"Failed to obtain valid key record for 
fingerprint {fingerprint}")
 
-        # Link key to selected PMCs
+        # Link key to selected PMCs and track status for each
+        committee_statuses: dict[str, str] = {}
         for committee_name in selected_committees:
             committee = await data.committee(name=committee_name).get()
             if committee and committee.id:
-                link = models.KeyLink(committee_id=committee.id, 
key_fingerprint=key_record.fingerprint)
-                data.add(link)
+                # Check whether the link already exists
+                link_exists = await data.execute(
+                    sqlmodel.select(models.KeyLink).where(
+                        models.KeyLink.committee_id == committee.id,
+                        models.KeyLink.key_fingerprint == 
key_record.fingerprint,
+                    )
+                )
+                if link_exists.scalar_one_or_none() is None:
+                    committee_statuses[committee_name] = "newly_linked"
+                    # Link doesn't exist, create it
+                    logging.debug(f"Linking key {fingerprint} to committee 
{committee_name}")
+                    link = models.KeyLink(committee_id=committee.id, 
key_fingerprint=key_record.fingerprint)
+                    data.add(link)
+                else:
+                    committee_statuses[committee_name] = "already_linked"
+                    logging.debug(f"Link already exists for key {fingerprint} 
and committee {committee_name}")
             else:
-                # TODO: Log? Add to "error"?
+                logging.warning(f"Could not find committee {committee_name} to 
link key {fingerprint}")
                 continue
 
+    # Extract email for sorting
+    user_id_str = key_record.declared_uid or ""
+    email_match = re.search(r"<([^>]+)>", user_id_str)
+    email = email_match.group(1) if email_match else user_id_str
+
     return {
-        "key_id": key["keyid"],
-        "fingerprint": key["fingerprint"].lower() if key.get("fingerprint") 
else "Unknown",
-        "user_id": key["uids"][0] if key.get("uids") else "Unknown",
-        "creation_date": created,
-        "expiration_date": expires,
+        "key_id": key_record.fingerprint[:16],
+        "fingerprint": key_record.fingerprint,
+        "user_id": user_id_str,
+        "email": email,
+        "creation_date": key_record.created,
+        "expiration_date": key_record.expires,
         "data": pprint.pformat(key),
+        "committee_statuses": committee_statuses,
+        "status": "success",
     }
 
 
@@ -368,18 +408,30 @@ async def upload(session: routes.CommitterSession) -> str:
 
     form = await UploadKeyForm.create_form()
     results: list[dict] = []
+    submitted_committees: list[str] | None = None
 
-    async def render(error: str | None = None) -> str:
+    async def render(
+        error: str | None = None,
+        submitted_committees_list: list[str] | None = None,
+        all_user_committees: Sequence[models.Committee] | None = None,
+    ) -> str:
         # For easier happy pathing
         if error is not None:
             await quart.flash(error, "error")
+
+        # Determine which committee list to use
+        current_committees = all_user_committees if (all_user_committees is 
not None) else user_committees
+        committee_map = {c.name: c.display_name for c in current_committees}
+
         return await quart.render_template(
             "keys-upload.html",
             asf_id=session.uid,
-            user_committees=user_committees,
+            user_committees=current_committees,
+            committee_map=committee_map,
             form=form,
             results=results,
             algorithms=routes.algorithms,
+            submitted_committees=submitted_committees_list,
         )
 
     if await form.validate_on_submit():
@@ -405,6 +457,9 @@ async def upload(session: routes.CommitterSession) -> str:
         if invalid_committees:
             return await render(error=f"Invalid committee selection: {', 
'.join(invalid_committees)}")
 
+        # TODO: Do we modify this? Store a copy just in case, for the template 
to use
+        submitted_committees = selected_committees[:]
+
         # Process each key block
         results = await _upload_process_key_blocks(key_blocks, 
selected_committees)
         if not results:
@@ -416,6 +471,8 @@ async def upload(session: routes.CommitterSession) -> str:
             f"Processed {len(results)} keys: {success_count} successful, 
{error_count} failed",
             "success" if success_count > 0 else "error",
         )
+        return await render(submitted_committees_list=submitted_committees, 
all_user_committees=user_committees)
+
     return await render()
 
 
@@ -495,19 +552,51 @@ async def _upload_process_key_blocks(key_blocks: 
list[str], selected_committees:
         try:
             key_info = await key_user_add(None, key_block, selected_committees)
             if key_info:
-                key_info["status"] = "success"
-                key_info["message"] = "Key added successfully"
+                key_info["status"] = key_info.get("status", "success")
+                key_info["email"] = key_info.get("email", "Unknown")
+                key_info["committee_statuses"] = 
key_info.get("committee_statuses", {})
                 results.append(key_info)
+            else:
+                # Handle case where key_user_add might return None
+                results.append(
+                    {
+                        "status": "error",
+                        "message": "Failed to process key (key_user_add 
returned None)",
+                        "key_id": f"Key #{i + 1}",
+                        "fingerprint": "Unknown",
+                        "user_id": "Unknown",
+                        "email": "Unknown",
+                        "committee_statuses": {},
+                    }
+                )
+        except routes.FlashError as e:
+            logging.warning(f"FlashError processing key #{i + 1}: {e}")
+            results.append(
+                {
+                    "status": "error",
+                    "message": f"Validation Error: {e}",
+                    "key_id": f"Key #{i + 1}",
+                    "fingerprint": "Invalid",
+                    "user_id": "Unknown",
+                    "email": "Unknown",
+                    "committee_statuses": {},
+                }
+            )
         except Exception as e:
-            logging.exception("Exception adding key:")
+            logging.exception(f"Exception processing key #{i + 1}:")
             results.append(
                 {
                     "status": "error",
-                    "message": f"Exception: {e}",
+                    "message": f"Internal Exception: {e}",
                     "key_id": f"Key #{i + 1}",
                     "fingerprint": "Error",
                     "user_id": "Unknown",
+                    "email": "Unknown",
+                    "committee_statuses": {},
                 }
             )
 
-    return results
+    # Primary key is email, secondary key is fingerprint
+    results_sorted = sorted(results, key=lambda x: (x.get("email", 
"").lower(), x.get("fingerprint", "")))
+
+    return results_sorted
diff --git a/atr/templates/keys-upload.html b/atr/templates/keys-upload.html
index 1a10a47..6a579a6 100644
--- a/atr/templates/keys-upload.html
+++ b/atr/templates/keys-upload.html
@@ -8,6 +8,74 @@
   Upload a KEYS file containing multiple GPG public keys.
 {% endblock description %}
 
+{% block stylesheets %}
+  {{ super() }}
+  <style>
+      .page-rotated-header {
+          height: 180px;
+          position: relative;
+          vertical-align: bottom;
+          padding-bottom: 5px;
+          width: 40px;
+      }
+
+      .page-rotated-header>div {
+          transform-origin: bottom left;
+          transform: translateX(25px) rotate(-90deg);
+          position: absolute;
+          bottom: 12px;
+          left: 6px;
+          white-space: nowrap;
+          text-align: left;
+      }
+
+      .table th,
+      .table td {
+          text-align: center;
+          vertical-align: middle;
+      }
+
+      .table td.page-key-details {
+          text-align: left;
+          font-family: ui-monospace, "SFMono-Regular", "Menlo", "Monaco", 
"Consolas", monospace;
+          font-size: 0.9em;
+          word-break: break-all;
+      }
+
+      .page-status-cell-new {
+          background-color: #197a4e !important;
+      }
+
+      .page-status-cell-existing {
+          background-color: #868686 !important;
+      }
+
+      .page-status-cell-unknown {
+          background-color: #ffecb5 !important;
+      }
+
+      .page-status-cell-error {
+          background-color: #dc3545 !important;
+      }
+
+      .page-status-square {
+          display: inline-block;
+          width: 36px;
+          height: 36px;
+          vertical-align: middle;
+      }
+
+      .page-table-bordered th,
+      .page-table-bordered td {
+          border: 1px solid #dee2e6;
+      }
+
+      tbody tr {
+          height: 40px;
+      }
+  </style>
+{% endblock stylesheets %}
+
 {% block content %}
   <h1>Upload KEYS file</h1>
   <p>Upload a KEYS file containing multiple GPG public keys.</p>
@@ -21,60 +89,61 @@
     </div>
   {% endif %}
 
-  {% if results %}
+  {% if results and submitted_committees %}
     <h2>KEYS processing results</h2>
-    <p>The following keys were found in your KEYS file:</p>
+    <p>
+      The following keys were found in your KEYS file and processed against 
the selected committees. Green squares indicate that a key was added, grey 
squares indicate that a key already existed, and red squares indicate an error.
+    </p>
+    <div class="table-responsive">
+      <table class="table table-striped page-table-bordered table-sm mt-3">
+        <thead>
+          <tr>
+            <th scope="col">Fingerprint</th>
+            <th scope="col">User ID</th>
+            {% for committee_name in submitted_committees %}
+              <th scope="col" class="page-rotated-header">
+                <div>{{ committee_map.get(committee_name, committee_name) 
}}</div>
+              </th>
+            {% endfor %}
+          </tr>
+        </thead>
+        <tbody>
+          {% for key_info in results %}
+            <tr>
+              <td class="page-key-details px-2">
+                <code>{{ key_info.fingerprint[:16]|upper }}</code>
+              </td>
+              <td class="page-key-details px-2">{{ key_info.email }}</td>
+              {% for committee_name in submitted_committees %}
+                {% set status = 
key_info.committee_statuses.get(committee_name) %}
+                {% set cell_class = 'page-status-cell-error' if 
key_info.status == 'error'
+                                  else 'page-status-cell-new' if status == 
'newly_linked'
+                                  else 'page-status-cell-existing' if status 
== 'already_linked'
+                                else 'page-status-cell-unknown' %}
+                {% set title_text = 'Error processing key' if key_info.status 
== 'error'
+                                  else 'Newly linked' if status == 
'newly_linked'
+                                  else 'Already linked' if status == 
'already_linked'
+                                else 'Unknown status' %}
+                <td class="text-center align-middle 
page-status-cell-container">
+                  <span class="page-status-square {{ cell_class }}" title="{{ 
title_text }}"></span>
+                </td>
+              {% endfor %}
+            </tr>
+          {% endfor %}
+        </tbody>
+      </table>
+    </div>
+
+    {% set processing_errors = results | selectattr('status', 'equalto', 
'error') | list %}
+    {% if processing_errors %}
+      <h3 class="text-danger mt-4">Processing errors</h3>
+      {% for error_info in processing_errors %}
+        <div class="alert alert-danger p-2 mb-3">
+          <strong>{{ error_info.key_id }} / {{ error_info.fingerprint 
}}</strong>: {{ error_info.message }}
+        </div>
+      {% endfor %}
+    {% endif %}
 
-    {% for key_info in results %}
-      {% if key_info.status == "success" %}
-        {% set bg_class = "bg-light" %}
-      {% else %}
-        {% set bg_class = "bg-danger-subtle" %}
-      {% endif %}
-      <div class="my-3 p-3 rounded {{ bg_class }}">
-        <h3 class="mt-0">
-          {% if key_info.status == "success" %}
-            Success: Added Key
-          {% else %}
-            Error: Failed to add key
-          {% endif %}
-        </h3>
-        <dl class="row mb-0">
-          <dt class="col-sm-3 fw-bold">Key ID</dt>
-          <dd class="col-sm-9 mb-2">
-            {{ key_info.key_id }}
-          </dd>
-          <dt class="col-sm-3 fw-bold">Fingerprint</dt>
-          <dd class="col-sm-9 mb-2">
-            {{ key_info.fingerprint }}
-          </dd>
-          <dt class="col-sm-3 fw-bold">User ID</dt>
-          <dd class="col-sm-9 mb-2">
-            {{ key_info.user_id }}
-          </dd>
-          {% if key_info.status == 'success' %}
-            <dt class="col-sm-3 fw-bold">Created</dt>
-            <dd class="col-sm-9 mb-2">
-              {{ key_info.creation_date }}
-            </dd>
-            <dt class="col-sm-3 fw-bold">Expires</dt>
-            <dd class="col-sm-9 mb-2">
-              {{ key_info.expiration_date or 'Never' }}
-            </dd>
-          {% endif %}
-          <dt class="col-sm-3 fw-bold">Status</dt>
-          <dd class="col-sm-9 mb-2">
-            {{ key_info.message }}
-          </dd>
-          {% if key_info.status == 'success' and key_info.data %}
-            <details>
-              <summary>Key Data</summary>
-              <pre class="mb-0">{{ key_info.data }}</pre>
-            </details>
-          {% endif %}
-        </dl>
-      </div>
-    {% endfor %}
   {% endif %}
 
   <form method="post"
@@ -101,7 +170,7 @@
 
       {% if user_committees %}
         <div class="row mb-3 pb-3 border-bottom">
-          <div class="col-md-2 text-md-end fw-medium pt-2">Associate keys with 
committee</div>
+          <div class="col-md-2 text-md-end fw-medium pt-2">Associate keys with 
committees</div>
           <div class="col-md-9">
             <div class="row">
               {% for value, label in form.selected_committees.choices %}
@@ -125,7 +194,7 @@
                       class="btn btn-sm btn-outline-secondary">Select 
all</button>
             </div>
             <small class="form-text text-muted mt-2">
-              Select the committee with which to associate these keys. You 
must be a member of the selected committee.
+              Select the committees with which to associate these keys. You 
must be a member of the selected committees.
             </small>
             {% if form.selected_committees.errors %}
               <div class="invalid-feedback d-block">


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

Reply via email to