Merge lp:~racb/ubuntu-archive-scripts/sru-autosubscribe into lp:ubuntu-archive-scripts

Proposed by Robie Basak
Status: Merged
Merged at revision: 351
Proposed branch: lp:~racb/ubuntu-archive-scripts/sru-autosubscribe
Merge into: lp:ubuntu-archive-scripts
Diff against target: 282 lines (+278/-0)
1 file modified
sru_autosubscribe.py (+278/-0)
To merge this branch: bzr merge lp:~racb/ubuntu-archive-scripts/sru-autosubscribe
Reviewer Review Type Date Requested Status
Steve Langasek Approve
Review via email: mp+441467@code.launchpad.net

Description of the change

Previous MP: https://code.launchpad.net/~racb/ubuntu-archive-tools/+git/ubuntu-archive-tools/+merge/429629

I couldn't use the "Resubmit" function as it's moving from git to bzr.

ubuntu-archive-tools seems like a reasonable place to put this script. Can we run it from a cronjob the same as some of the other tooling?

Test with --debug --dry-run. I've tested on Focal. Currently the Python on Focal or later is required because I used f-strings. It's easy enough to make it work with older Pythons if needed, although it'd need some testing to ensure gpg will work as expected since the way this script operates gpg is a little hacky.

To post a comment you must log in.
Revision history for this message
Robie Basak (racb) wrote :

Could an archive admin mark the previous MP at https://code.launchpad.net/~racb/ubuntu-archive-tools/+git/ubuntu-archive-tools/+merge/429629 as Rejected please? I don't have permission to do that myself.

Revision history for this message
Steve Langasek (vorlon) :
review: Approve
Revision history for this message
Steve Langasek (vorlon) wrote :

On what interval do you think this cronjob should run?

Revision history for this message
Robie Basak (racb) wrote :

At the moment the script is really inefficient. So how about we start off with daily, and hopefully we'll see a significant initial benefit even though there's a race? In time I could add some caching to get it going faster.

Revision history for this message
Steve Langasek (vorlon) wrote :

Have completed a test run and it's now set to run at 3:42 daily.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'sru_autosubscribe.py'
2--- sru_autosubscribe.py 1970-01-01 00:00:00 +0000
3+++ sru_autosubscribe.py 2023-04-19 14:29:52 +0000
4@@ -0,0 +1,278 @@
5+#!/usr/bin/python3
6+
7+"""Subscribe uploaders to the unapproved queue to their SRU bugs
8+
9+Author: Robie Basak <robie.basak@canonical.com>
10+
11+Subscriptions are made on the principle that if you signed an upload, you need
12+to follow up on review comments on the SRU team. The SRU team have been having
13+trouble getting response to comments in bugs, noticed that often the sponsor
14+isn't subscribed, and hopes this will help.
15+
16+This is intended to be run from a cronjob or timer. Run with --debug to see
17+what is going on, and additionally with --dry-run to see what would happen.
18+
19+Principle of operation: get all key fingerprints of everyone in
20+~ubuntu-uploaders. Then download the .dsc file from everything in Unapproved
21+targetted at the proposed pockets of stable series and see who signed them.
22+Then subscribe this person to all bugs referenced in Launchpad-Bugs-Fixed in
23+the changes file of those uploads if they aren't subscribed at "Discussion"
24+level already.
25+
26+To stop this script from acting on a particular bug, tag it "bot-stop-nagging".
27+
28+It takes a while to get going; hitting the Launchpad API for all uploaders'
29+keys takes about a minute, then a further minute to fetch all the keys to get
30+their subkey fingerprints from keyserver.ubuntu.com.
31+
32+All Ubuntu uploaders are supposed to be in ~ubuntu-uploaders but this isn't
33+enforced except by the correct action of the DMB. If somebody has uploaded but
34+is not a member of the team, this script will not find their key and therefore
35+will not subscribe them to their bugs.
36+
37+The key 6309259455EFCAA8 also signs uploads and is ~ci-train-bot; it isn't a
38+member of ~ubuntu-uploaders and is therefore also ignored. We don't have a way
39+of identifying the actual sponsor in this case.
40+"""
41+
42+import argparse
43+import itertools
44+import logging
45+import re
46+import subprocess
47+import tempfile
48+import urllib
49+
50+import debian.deb822
51+import launchpadlib.launchpad
52+
53+GPG_OUTPUT_PATTERN = re.compile(
54+ r"""gpg: Signature made.*
55+gpg: +using (?:RSA|DSA) key (?P<fingerprint>[0-9A-F]+)
56+"""
57+)
58+
59+
60+def find_specific_url(urls, suffix):
61+ for url in urls:
62+ if url.endswith(suffix):
63+ return url
64+ raise ValueError
65+
66+
67+def get_upload_source_urls(upload):
68+ if upload.contains_source:
69+ return upload.sourceFileUrls()
70+ elif upload.contains_copy:
71+ try:
72+ copy_source_archive = upload.copy_source_archive
73+ # Trigger a problem ValueError exception now rather than later
74+ # This is magic launchpadlib behaviour: accessing an attribute of
75+ # copy_source_archive may fail later on an access permission issue
76+ # due to lazy loading.
77+ getattr(copy_source_archive, "self_link")
78+ except ValueError as e:
79+ raise RuntimeError(
80+ f"Cannot access {upload} copy_source_archive attribute: no permission?"
81+ ) from e
82+ return next(
83+ iter(
84+ upload.copy_source_archive.getPublishedSources(
85+ source_name=upload.package_name,
86+ version=upload.package_version,
87+ exact_match=True,
88+ order_by_date=True,
89+ )
90+ )
91+ ).sourceFileUrls()
92+ else:
93+ raise RuntimeError(f"Cannot find source for {upload}")
94+
95+
96+def find_dsc_signing_fingerprint(dsc_url):
97+ with urllib.request.urlopen(
98+ dsc_url
99+ ) as dsc_fobj, tempfile.TemporaryDirectory() as tmpdir:
100+ result = subprocess.run(
101+ ["gpg", "--verify"],
102+ input=dsc_fobj.read(),
103+ capture_output=True,
104+ env={"GNUPGHOME": tmpdir},
105+ )
106+ if result.returncode != 2:
107+ raise RuntimeError("Unknown exit status from gpg")
108+ m = GPG_OUTPUT_PATTERN.search(result.stderr.decode())
109+ if not m:
110+ raise ValueError("Signing key fingerprint not found")
111+ return m.group("fingerprint")
112+
113+
114+def find_changes_bugs(changes_url):
115+ with urllib.request.urlopen(changes_url) as changes_fobj:
116+ changes = debian.deb822.Changes(changes_fobj)
117+ try:
118+ bugs_str = changes["Launchpad-Bugs-Fixed"]
119+ except KeyError:
120+ return []
121+ return bugs_str.split()
122+
123+
124+def ensure_subscribed(person, bug, dry_run):
125+ for subscription in bug.subscriptions:
126+ if (
127+ subscription.person_link == person.self_link
128+ and subscription.bug_notification_level == "Discussion"
129+ ):
130+ logging.debug(f"{person.name} is already subscribed to {bug.id}")
131+ return
132+ logging.debug(f"Subscribing {person.name} to {bug.id}")
133+ if "bot-stop-nagging" in bug.tags:
134+ logging.debug("bot-stop-nagging detected; not subscribing")
135+ return
136+ if not dry_run:
137+ bug.subscribe(level="Discussion", person=person)
138+
139+
140+def parse_fingerprints(output):
141+ for line in output.decode().splitlines():
142+ fields = line.split(":")
143+ if fields[0] == "fpr":
144+ yield fields[9]
145+
146+
147+def fetch_subkey_fingerprints(primary_fingerprints):
148+ with tempfile.TemporaryDirectory() as tmpdir:
149+ for primary_fingerprint in primary_fingerprints:
150+ logging.debug(f"Fetching key for {primary_fingerprint}")
151+ result = subprocess.run(
152+ [
153+ "gpg",
154+ "--keyserver",
155+ "keyserver.ubuntu.com",
156+ "--recv-key",
157+ primary_fingerprint,
158+ ],
159+ env={"GNUPGHOME": tmpdir},
160+ capture_output=True,
161+ )
162+ if (
163+ result.returncode == 2
164+ and result.stderr == "gpg: keyserver receive failed: No data\n".encode()
165+ ):
166+ # Some keys cannot be fetched. See: https://irclogs.ubuntu.com/2022/09/07/%23launchpad.html#t14:03
167+ continue
168+ result.check_returncode()
169+ result = subprocess.run(
170+ [
171+ "gpg",
172+ "--list-keys",
173+ "--with-colons",
174+ "--with-fingerprint",
175+ "--with-fingerprint", # duplicate to also give subkey fingerprints
176+ primary_fingerprint,
177+ ],
178+ env={"GNUPGHOME": tmpdir},
179+ capture_output=True,
180+ check=True,
181+ )
182+ yield primary_fingerprint, (
183+ fpr
184+ for fpr in parse_fingerprints(result.stdout)
185+ if fpr != primary_fingerprint
186+ )
187+
188+
189+def determine_fingerprint_to_person_map(lp):
190+ logging.debug("Fetching uploader key fingerprints")
191+ fingerprint_to_person = {
192+ gpg_key.fingerprint: person
193+ for person, gpg_key in itertools.chain.from_iterable(
194+ ((person, gpg_key) for gpg_key in person.gpg_keys)
195+ for person in lp.people["ubuntu-uploaders"].participants
196+ )
197+ }
198+ logging.debug(f"{len(fingerprint_to_person)} fingerprints fetched")
199+
200+ # Add subkey fingerprints
201+ logging.debug(f"Retrieving subkey fingerprints from keyserver.ubuntu.com")
202+ fingerprint_to_person.update(
203+ {
204+ subkey_fingerprint: fingerprint_to_person[primary_fingerprint]
205+ for primary_fingerprint, subkey_fingerprint in itertools.chain.from_iterable(
206+ (
207+ (primary_fingerprint, subkey_fingerprint)
208+ for subkey_fingerprint in subkey_fingerprints
209+ )
210+ for primary_fingerprint, subkey_fingerprints in fetch_subkey_fingerprints(
211+ fingerprint_to_person.keys()
212+ )
213+ )
214+ }
215+ )
216+ logging.debug("Subkey fingerprints fetched")
217+
218+ # Some signers are only embedding 16 nibble keyids, so identify using these
219+ # as well. It's easier to arrange collisions, but I think we can trust
220+ # Ubuntu uploaders not to do this; and in the worst case scenario we'll
221+ # only misidentify the uploader and give the wrong person the subscription
222+ # anyway.
223+ fingerprint_to_person.update(
224+ {
225+ fingerprint[-16:]: person
226+ for fingerprint, person in fingerprint_to_person.items()
227+ }
228+ )
229+ return fingerprint_to_person
230+
231+
232+if __name__ == "__main__":
233+ parser = argparse.ArgumentParser()
234+ parser.add_argument("--dry-run", action="store_true")
235+ parser.add_argument("--debug", action="store_true")
236+ args = parser.parse_args()
237+ if args.debug:
238+ logging.basicConfig(level=logging.DEBUG)
239+
240+ lp = launchpadlib.launchpad.Launchpad.login_with(
241+ "~racb sru_autosubscribe", "production", version="devel"
242+ )
243+
244+ fingerprint_to_person = determine_fingerprint_to_person_map(lp)
245+
246+ distro_seriess = [
247+ series
248+ for series in lp.distributions["ubuntu"].series
249+ if series.status in {"Current Stable Release", "Supported"}
250+ ]
251+ uploads = itertools.chain.from_iterable(
252+ distro_series.getPackageUploads(pocket="Proposed", status="Unapproved")
253+ for distro_series in distro_seriess
254+ )
255+ for upload in uploads:
256+ logging.debug(f"Considering {upload}")
257+ try:
258+ urls = get_upload_source_urls(upload)
259+ except RuntimeError:
260+ logging.debug(f"Could not get source URLs for {upload}")
261+ continue
262+ try:
263+ dsc_url = find_specific_url(urls, ".dsc")
264+ except ValueError:
265+ logging.debug(f"Could not find .dsc for {upload}")
266+ continue
267+ if not upload.changes_file_url:
268+ logging.debug(f"Could not find changes file for {upload}")
269+ bug_numbers = find_changes_bugs(upload.changes_file_url)
270+ fingerprint = find_dsc_signing_fingerprint(dsc_url)
271+ try:
272+ signer = fingerprint_to_person[fingerprint]
273+ except KeyError:
274+ logging.debug(f"Could not find signer with fingerprint {fingerprint}")
275+ continue
276+ for bug_number in bug_numbers:
277+ try:
278+ bug = lp.bugs[bug_number]
279+ except KeyError:
280+ logging.debug(f"Could not find bug {bug_number}")
281+ continue
282+ ensure_subscribed(person=signer, bug=bug, dry_run=args.dry_run)

Subscribers

People subscribed via source and target branches