~ubuntu-archive/ubuntu-archive-scripts/trunk

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