266 lines
10 KiB
Python
Raw Normal View History

import os
import re
import json
import math
2017-03-09 09:51:06 -08:00
import socket
import smtplib
from urllib.error import HTTPError
from urllib.parse import unquote
from collections import defaultdict
from britney2.policies.rest import Rest
from britney2.policies.policy import BasePolicy, PolicyVerdict
2017-03-17 14:42:06 -07:00
# Recurring emails should never be more than this many days apart
MAX_INTERVAL = 30
2017-03-17 14:42:06 -07:00
API_PREFIX = 'https://api.launchpad.net/1.0/'
USER = API_PREFIX + '~'
# Don't send emails to these bots
BOTS = {
USER + 'ci-train-bot',
USER + 'bileto-bot',
USER + 'ubuntu-archive-robot',
USER + 'katie',
}
2017-03-09 09:51:06 -08:00
MESSAGE = """From: Ubuntu Release Team <noreply@canonical.com>
To: {recipients}
X-Proposed-Migration: notice
Subject: [proposed-migration] {source_name} {version} stuck in {series}-proposed for {age} day{plural}.
2017-03-09 09:51:06 -08:00
Hi,
{source_name} {version} needs attention.
It has been stuck in {series}-proposed for {age} day{plural}.
You either sponsored or uploaded this package, please investigate why it hasn't been approved for migration.
http://people.canonical.com/~ubuntu-archive/proposed-migration/{series}/update_excuses.html#{source_name}
https://wiki.ubuntu.com/ProposedMigration
If you have any questions about this email, please ask them in #ubuntu-release channel on Freenode IRC.
Regards, Ubuntu Release Team.
"""
def person_chooser(source):
"""Assign blame for the current source package."""
people = {
source['package_signer_link'],
source['sponsor_link'],
source['creator_link'],
} - {None} - BOTS
# some bots (e.g. bileto) generate uploads that are otherwise manual. We
# want to email the people that the bot was acting on behalf of.
bot = source['package_signer_link'] in BOTS
# direct uploads
regular = not source['creator_link'] and not source['sponsor_link']
if bot or regular:
people.add(source['package_creator_link'])
return people
def address_chooser(addresses):
"""Prefer @ubuntu and @canonical addresses."""
2017-03-09 10:39:19 -08:00
first = ''
canonical = ''
for address in addresses:
if address.endswith('@ubuntu.com'):
return address
if address.endswith('@canonical.com'):
canonical = address
2017-03-09 10:39:19 -08:00
if not first:
first = address
return canonical or first
class EmailPolicy(BasePolicy, Rest):
"""Send an email when a package has been rejected."""
def __init__(self, options, suite_info, dry_run=False):
super().__init__('email', options, suite_info, {'unstable'})
self.filename = os.path.join(options.unstable, 'EmailCache')
2017-03-17 14:42:06 -07:00
# Maps lp username -> email address
self.addresses = {}
# Dict of dicts; maps pkg name -> pkg version -> boolean
self.emails_by_pkg = defaultdict(dict)
# self.cache contains self.emails_by_pkg from previous run
self.cache = {}
self.dry_run = dry_run
self.email_host = getattr(self.options, 'email_host', 'localhost')
def initialise(self, britney):
"""Load cached source ppa data"""
super().initialise(britney)
if os.path.exists(self.filename):
with open(self.filename, encoding='utf-8') as data:
self.cache = json.load(data)
self.log("Loaded cached email data from %s" % self.filename)
tmp = self.filename + '.new'
if os.path.exists(tmp):
# if we find a record on disk of emails sent from an incomplete
# britney run, merge them in now.
with open(tmp, encoding='utf-8') as data:
self.cache.update(json.load(data))
self._save_progress(self.cache)
self.save_state()
def _scrape_gpg_emails(self, person):
"""Find email addresses from one person's GPG keys."""
2017-03-17 14:42:06 -07:00
if person in self.addresses:
return self.addresses[person]
addresses = []
try:
gpg = self.query_lp_rest_api(person + '/gpg_keys', {})
for key in gpg['entries']:
details = self.query_rest_api('http://keyserver.ubuntu.com/pks/lookup', {
'op': 'index',
'search': '0x' + key['fingerprint'],
'exact': 'on',
'options': 'mr',
})
for line in details.splitlines():
parts = line.split(':')
if parts[0] == 'info':
if int(parts[1]) != 1 or int(parts[2]) > 1:
break
if parts[0] == 'uid':
flags = parts[4]
if 'e' in flags or 'r' in flags:
continue
uid = unquote(parts[1])
match = re.match(r'^.*<(.+@.+)>$', uid)
if match:
addresses.append(match.group(1))
address = self.addresses[person] = address_chooser(addresses)
return address
except HTTPError as e:
if e.code != 410: # suspended user
raise
self.log('Ignoring person %s as suspended in Launchpad' % person)
return None
def scrape_gpg_emails(self, people):
"""Find email addresses from GPG keys."""
emails = [self._scrape_gpg_emails(person) for person in (people or [])]
return [email for email in emails if email is not None]
def lp_get_emails(self, pkg, version):
"""Ask LP who uploaded this package."""
data = self.query_lp_rest_api('%s/+archive/primary' % self.options.distribution, {
'ws.op': 'getPublishedSources',
'distro_series': '/%s/%s' % (self.options.distribution, self.options.series),
'exact_match': 'true',
'order_by_date': 'true',
'pocket': 'Proposed',
'source_name': pkg,
'version': version,
})
try:
source = next(reversed(data['entries']))
# IndexError means no packages in -proposed matched this name/version,
# which is expected to happen when bileto runs britney.
except StopIteration:
self.log('Email getPublishedSources IndexError (%s %s)' % (pkg, version))
2017-03-09 10:39:19 -08:00
return []
return self.scrape_gpg_emails(person_chooser(source))
def apply_policy_impl(self, email_info, suite, source_name, source_data_tdist, source_data_srcdist, excuse):
"""Send email if package is rejected."""
2017-03-23 10:05:21 -07:00
max_age = 5 if excuse.is_valid else 1
series = self.options.series
version = source_data_srcdist.version
age = int(excuse.daysold) or 0
plural = '' if age == 1 else 's'
# an item is stuck if it's
# - old enough
# - not blocked
# - not temporarily rejected (e.g. by the autopkgtest policy when tests
# are still running)
2017-03-23 10:05:21 -07:00
stuck = age >= max_age and 'block' not in excuse.reason and \
excuse.current_policy_verdict != PolicyVerdict.REJECTED_TEMPORARILY
if self.dry_run:
self.log("[email dry run] Considering: %s/%s: %s" %
(source_name, version, "stuck" if stuck else "not stuck"))
if not stuck:
return PolicyVerdict.PASS
2017-03-17 14:42:06 -07:00
cached = self.cache.get(source_name, {}).get(version)
try:
emails, last_sent = cached
# migration of older data
last_sent = int(last_sent)
2017-07-18 18:36:04 +02:00
# Find out whether we are due to send another email by calculating
# the most recent age at which we should have sent one. A
# sequence of doubling intervals (0 + 1 = 1, 1 + 2 = 3, 3 + 4 = 7)
# is equivalent to 2^n-1, or 2^n + (max_age - 1) - 1.
# 2^(floor(log2(age))) straightforwardly calculates the most
# recent age at which we wanted to send an email.
last_due = int(math.pow(2, int(math.log(age + 2 - max_age, 2)))
+ max_age - 2)
2017-07-18 18:36:04 +02:00
# Don't let the interval double without bounds.
if last_due - max_age >= MAX_INTERVAL:
last_due = int((age - max_age - MAX_INTERVAL) / MAX_INTERVAL) \
* MAX_INTERVAL + max_age + MAX_INTERVAL
2017-07-18 18:36:04 +02:00
# And don't send emails before we've reached the minimum age
# threshold.
if last_due < max_age:
last_due = max_age
2017-03-17 14:42:06 -07:00
except TypeError:
# This exception happens when source_name, version never seen before
emails = []
last_sent = 0
last_due = max_age
if self.dry_run:
self.log("[email dry run] Age %d >= threshold %d: would email: %s" %
(age, max_age, self.lp_get_emails(source_name, version)))
# don't update the cache file in dry run mode; we'll see all output each time
return PolicyVerdict.PASS
if last_sent < last_due:
2017-03-17 14:42:06 -07:00
if not emails:
emails = self.lp_get_emails(source_name, version)
if emails:
2017-03-09 09:51:06 -08:00
recipients = ', '.join(emails)
msg = MESSAGE.format(**locals())
try:
2017-03-09 09:51:06 -08:00
self.log("%s/%s stuck for %d days, emailing %s" %
(source_name, version, age, recipients))
server = smtplib.SMTP(self.email_host)
2017-03-09 09:51:06 -08:00
server.sendmail('noreply@canonical.com', emails, msg)
server.quit()
# record the age at which the mail should have been sent
last_sent = last_due
2017-03-09 09:51:06 -08:00
except socket.error as err:
self.log("Failed to send mail! Is SMTP server running?")
self.log(err)
self.emails_by_pkg[source_name][version] = (emails, last_sent)
self._save_progress(self.emails_by_pkg)
return PolicyVerdict.PASS
def _save_progress(self, my_data):
"""Checkpoint after each sent mail"""
tmp = self.filename + '.new'
with open(tmp, 'w', encoding='utf-8') as data:
json.dump(my_data, data)
return tmp
def save_state(self, britney=None):
"""Save email notification status of all pending packages"""
if not self.dry_run:
try:
os.rename(self.filename + '.new', self.filename)
# if we haven't written any cache, don't clobber the old one
except FileNotFoundError:
pass
if britney:
self.log("Wrote email data to %s" % self.filename)