#!/usr/bin/env python # Sync a suite with a Seed list. # Copyright (C) 2004, 2005, 2009, 2010, 2011, 2012 Canonical Ltd. # Author: James Troup # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation; either version 2 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA # XXX - add indication if all of the binaries of a source packages are # listed for promotion at once # i.e. to allow 'change-override -S' usage from __future__ import print_function __metaclass__ = type import atexit from collections import defaultdict, OrderedDict import copy import csv import gzip try: from html import escape except ImportError: from cgi import escape import json from optparse import OptionParser import os import shutil import sys import tempfile from textwrap import dedent import time try: from urllib.parse import quote_plus except ImportError: from urllib import quote_plus import apt_pkg from launchpadlib.launchpad import Launchpad from charts import make_chart, make_chart_header tempdir = None archive_source = {} archive_binary = {} current_source = {} current_binary = {} germinate_source = {} germinate_binary = {} seed_source = defaultdict(set) seed_binary = defaultdict(set) class MIRLink: def __init__(self, id, status, title, assignee): self.id = id self.status = status self.title = title self.assignee = assignee def __str__(self): if self.status not in ('Fix Committed', 'Fix Released') and self.assignee: s = "MIR: #%d (%s for %s)" % (self.id, self.status, self.assignee.display_name) else: s = "MIR: #%d (%s)" % (self.id, self.status) # no need to repeat the standard title if not self.title.startswith("[MIR]"): s += " %s" % self.title return s def html(self): h = 'MIR: #%d (%s)' % ( self.id, self.id, escape(self.status)) # no need to repeat the standard title if not self.title.startswith("[MIR]"): h += " %s" % escape(self.title) return h def ensure_tempdir(): global tempdir if not tempdir: tempdir = tempfile.mkdtemp(prefix='component-mismatches') atexit.register(shutil.rmtree, tempdir) def decompress_open(tagfile): ensure_tempdir() decompressed = tempfile.mktemp(dir=tempdir) fin = gzip.GzipFile(filename=tagfile) with open(decompressed, 'wb') as fout: fout.write(fin.read()) return open(decompressed, 'r') def read_current_source(options): for suite in options.suites: for component in options.all_components: sources_path = "%s/dists/%s/%s/source/Sources.gz" % ( options.archive_dir, suite, component) for section in apt_pkg.TagFile(decompress_open(sources_path)): if 'Package' in section and 'Version' in section: (pkg, version) = (section['Package'], section['Version']) if pkg not in archive_source: archive_source[pkg] = (version, component) else: if apt_pkg.version_compare( archive_source[pkg][0], version) < 0: archive_source[pkg] = ( version, component.split("/")[0]) for pkg, (version, component) in archive_source.items(): if component in options.components: current_source[pkg] = (version, component) def read_current_binary(options): components_with_di = [] for component in options.all_components: components_with_di.append(component) components_with_di.append('%s/debian-installer' % component) for suite in options.suites: for component in components_with_di: for arch in [ "i386", "amd64", "armhf", "arm64", "ppc64el", "s390x"]: binaries_path = "%s/dists/%s/%s/binary-%s/Packages.gz" % ( options.archive_dir, suite, component, arch) for section in apt_pkg.TagFile(decompress_open(binaries_path)): if 'Package' in section and 'Version' in section: (pkg, version) = (section['Package'], section['Version']) if 'source' in section: src = section['Source'].split(" ", 1)[0] else: src = section['Package'] if pkg not in archive_binary: archive_binary[pkg] = ( version, component.split("/")[0], src) else: if apt_pkg.version_compare( archive_binary[pkg][0], version) < 0: archive_binary[pkg] = (version, component, src) for pkg, (version, component, src) in archive_binary.items(): if component in options.components: current_binary[pkg] = (version, component, src) def read_germinate(options): for flavour in reversed(options.flavours.split(",")): # List of seeds seeds = ["all"] try: filename = "%s/structure_%s_%s_i386" % ( options.germinate_path, flavour, options.suite) with open(filename) as structure: for line in structure: if not line or line.startswith('#') or ':' not in line: continue seeds.append(line.split(':')[0]) except IOError: continue # ideally supported+build-depends too, but Launchpad's # cron.germinate doesn't save this for arch in ["i386", "amd64", "armhf", "arm64", "ppc64el", "s390x"]: for seed in seeds: filename = "%s/%s_%s_%s_%s" % ( options.germinate_path, seed, flavour, options.suite, arch) with open(filename) as f: for line in f: # Skip header and footer if (line[0] == "-" or line.startswith("Package") or line[0] == " "): continue # Skip empty lines line = line.strip() if not line: continue pkg, source, why = [word.strip() for word in line.split('|')][:3] if seed == "all": germinate_binary[pkg] = ( source, why, flavour, arch) germinate_source[source] = (flavour, arch) else: seed_binary[seed].add(pkg) seed_source[seed].add(source) def is_included_binary(options, pkg): if options.include: for seed in options.include.split(","): if seed in seed_binary and pkg in seed_binary[seed]: return True return False return True def is_excluded_binary(options, pkg): if options.exclude: seeds = set(seed_binary) - set(options.exclude.split(",")) for seed in seeds: if seed in seed_binary and pkg in seed_binary[seed]: return False for seed in options.exclude.split(","): if seed in seed_binary and pkg in seed_binary[seed]: return True return False def is_included_source(options, pkg): if options.include: for seed in options.include.split(","): if seed in seed_source and pkg in seed_source[seed]: return True return False return True def is_excluded_source(options, pkg): if options.exclude: seeds = set(seed_source) - set(options.exclude.split(",")) for seed in seeds: if seed in seed_source and pkg in seed_source[seed]: return False for seed in options.exclude.split(","): if seed in seed_source and pkg in seed_source[seed]: return True return False def get_source(binary): return current_binary[binary][2] def find_signer(options, source): # look at the source package publishing history for the most recent # package_signer, a copy from debian won't have a package signer series = options.distro.getSeries(name_or_version=options.suite) publications = options.archive.getPublishedSources( distro_series=series, source_name=source, exact_match=True) if not publications: return('no publications found', '') sorted_pubs = sorted([(ps.date_published, ps) for ps in publications if ps.date_published is not None], reverse=True) for pub in sorted_pubs: if pub[1].package_signer: signer = pub[1].package_signer.name web_link = pub[1].package_signer.web_link return(signer, web_link) else: signer = '' web_link = '' return (signer, web_link) def do_reverse(options, source, binaries, why_d): global signers try: signers.keys() except NameError: signers = {} output = [] depend = {} recommend = {} build_depend = {} for binary in binaries: why = why_d[source][binary] if why.find("Build-Depend") != -1: why = why.replace("(Build-Depend)", "").strip() build_depend[why] = "" elif why.find("Recommends") != -1: why = why.replace("(Recommends)", "").strip() recommend[why] = "" else: depend[why] = "" def do_category(map, category): keys = [] for k in map: if k.startswith('Rescued from '): pkg = k.replace('Rescued from ', '') else: pkg = k # seed names have spaces in them if ' ' not in pkg: try: source = get_source(pkg) except KeyError: source = pkg pass if source not in signers: signer, web_link = find_signer(options, source) if signer and web_link: signers[source] = (signer, web_link) if k in current_binary: keys.append('%s (%s)' % (k, current_binary[k][1].upper())) elif k in current_source: keys.append('%s (%s)' % (k, current_source[k][1].upper())) else: keys.append(k) keys.sort() if keys: return ["[Reverse-%s: %s]" % (category, ", ".join(keys))] else: return [] output.extend(do_category(depend, 'Depends')) output.extend(do_category(recommend, 'Recommends')) output.extend(do_category(build_depend, 'Build-Depends')) return output def do_dot(why, fd, mir_bugs, suite): # write dot graph for given why dictionary written_nodes = set() fd.write( 'digraph "component-mismatches: movements to main/restricted" {\n') for s, binwhy in why.iteritems(): for binary, why in binwhy.iteritems(): # ignore binaries from this source, and "rescued" if why in binwhy or why.startswith('Rescued'): continue if "(Recommends)" in why: relation = " R " color = "gray" why = why.replace(" (Recommends)", "") elif "Build-Depend" in why: relation = " B" color = "blue" why = why.replace(" (Build-Depend)", "") else: relation = "" color = "black" try: why = get_source(why) except KeyError: # happens for sources which are in universe, or seeds try: why = germinate_binary[why][0] except: pass # helper function to write a node def write_node(name): # ensure to only write it once if name in written_nodes: return name written_nodes.add(name) fd.write(' "%s" [label="%s" style="filled" tooltip="%s"' % (name, name, ', '.join(package_team_mapping[name]))) mirs = mir_bugs.get(name, []) approved_mirs = [ id for id, status, title, assignee in mirs if status in ('Fix Committed', 'Fix Released')] url = None if name.endswith(' seed'): fc = "green" elif name in current_source: fc = "lightgreen" url = ("https://launchpad.net/ubuntu/+source/%s" % quote_plus(name)) elif approved_mirs: fc = "yellow" url = "https://launchpad.net/bugs/%i" % approved_mirs[0] elif mirs: if mirs[0][1] == 'Incomplete': fc = "darkkhaki" else: fc = "darksalmon" url = "https://launchpad.net/bugs/%i" % mirs[0][0] else: fc = "white" # Need to use & otherwise the svg will have a syntax error url = ("https://launchpad.net/ubuntu/+source/%s/+filebug?" "field.title=%s&field.status=Incomplete" "&field.tags=%s" % (quote_plus(name), quote_plus("[MIR] %s" % name), quote_plus(suite))) fd.write(' fillcolor="%s"' % fc) if url: fd.write(' URL="%s"' % url) fd.write("]\n") return name s_node = write_node(s) why_node = write_node(why) # generate relation fd.write(' "%s" -> "%s" [label="%s" color="%s" ' 'fontcolor="%s"]\n' % (why_node, s_node, relation, color, color)) # add legend fd.write(""" { rank="source" NodeLegend[shape=none, margin=0, label=<
Nodes
seed
in main/restricted
approved MIR (clickable)
unapproved MIR (clickable)
Incomplete/stub MIR (clickable)
No MIR (click to file one)
>]; EdgeLegend[shape=none, margin=0, label=<
Edges
Depends:
Recommends:
Build-Depends:
>]; } } """) def filter_source(component, sources): return [ s for s in sources if s in archive_source and archive_source[s][1] == component] def filter_binary(component, binaries): return [ b for b in binaries if b in archive_binary and archive_binary[b][1] == component] package_team_mapping = defaultdict(set) def get_teams(options, source): global package_team_mapping if os.path.exists(options.package_team_mapping): with open(options.package_team_mapping) as ptm_file: for team, packages in json.load(ptm_file).items(): if team == "unsubscribed": continue for package in packages: package_team_mapping[package].add(team) if source in package_team_mapping: for team in package_team_mapping[source]: yield team elif package_team_mapping: yield "unsubscribed" def print_section_text(options, header, body, source_and_binary=False, binary_only=False): if body: print(" %s" % header) print(" %s" % ("-" * len(header))) print() for entry in body: line = entry[0] source = line[0] binaries = " ".join(line[1:]) if source_and_binary: print(" o %s: %s" % (source, binaries)) elif binary_only: indent_right = 75 - len(binaries) - len(source) - 2 print(" o %s%s{%s}" % (binaries, " " * indent_right, source)) else: print(" o %s" % source) for line in entry[1:]: print(" %s" % line) if len(entry) != 1: print() if len(body[-1]) == 1: print() print("=" * 70) print() def print_section_html(options, header, body, source_and_binary=False, binary_only=False): if body: def print_html(*args, **kwargs): print(*args, file=options.html_output, **kwargs) def source_link(source): return ( '%s' % ( escape(source, quote=True), escape(source))) print_html("

%s

" % escape(header)) print_html("") for entry in body: line = entry[0] source = line[0] binaries = " ".join(line[1:]) if source_and_binary: print_html( '' % escape(binaries), end="") print_html( "" % source_link(source)) else: print_html( '' % source_link(source)) for line in entry[1:]: if isinstance(line, MIRLink): line = line.html() else: for item in line.strip('[]').split(' '): if item.strip(',') in signers: comma = '' if item.endswith(','): comma = ',' pkg = item.strip(',') else: pkg = item # neither of these will help fix the issue if signers[pkg][0] in ['ps-jenkins', 'ci-train-bot']: continue line = line.replace(item, '%s (Uploader: %s)%s' % (pkg, signers[pkg][0], comma)) line = escape(line) print_html( '' % line) print_html("
%s: %s' % ( source_link(source), escape(binaries))) elif binary_only: print_html('
%s%s
%s
%s' '
") def do_output(options, orig_source_add, orig_source_remove, binary_add, binary_remove, mir_bugs): results = {} results["time"] = int(options.time * 1000) global package_team_mapping package_team_mapping = defaultdict(set) if os.path.exists(options.package_team_mapping): with open(options.package_team_mapping) as ptm_file: for team, packages in json.load(ptm_file).items(): if team == "unsubscribed": continue for package in packages: package_team_mapping[package].add(team) if options.html_output is not None: print(dedent("""\ Component mismatches for %s %s

Component mismatches for %s

""") % (escape(options.suite), make_chart_header(), escape(options.suite)), file=options.html_output) # Additions binary_only = defaultdict(dict) both = defaultdict(dict) source_add = copy.copy(orig_source_add) source_remove = copy.copy(orig_source_remove) for pkg in binary_add: (source, why, flavour, arch) = binary_add[pkg] if source not in orig_source_add: binary_only[source][pkg] = why else: both[source][pkg] = why if source in source_add: source_add.remove(source) all_output = OrderedDict() results["source promotions"] = 0 results["binary promotions"] = 0 for component in options.components: if component == "main": counterpart = "universe" elif component == "restricted": counterpart = "multiverse" else: continue output = [] for source in filter_source(counterpart, sorted(both)): binaries = sorted(both[source]) entry = [[source] + binaries] for (id, status, title, assignee) in mir_bugs.get(source, []): entry.append(MIRLink(id, status, title, assignee)) entry.extend(do_reverse(options, source, binaries, both)) output.append(entry) all_output["Source and binary movements to %s" % component] = { "output": output, "source_and_binary": True, } results["source promotions"] += len(output) output = [] for source in sorted(binary_only): binaries = filter_binary(counterpart, sorted(binary_only[source])) if binaries: entry = [[source] + binaries] entry.extend(do_reverse(options, source, binaries, binary_only)) output.append(entry) all_output["Binary only movements to %s" % component] = { "output": output, "binary_only": True, } results["binary promotions"] += len(output) output = [] for source in filter_source(counterpart, sorted(source_add)): output.append([[source]]) all_output["Source only movements to %s" % component] = { "output": output, } results["source promotions"] += len(output) if options.dot: with open(options.dot, 'w') as f: do_dot(both, f, mir_bugs, options.suite) # Removals binary_only = defaultdict(dict) both = defaultdict(dict) for pkg in binary_remove: source = get_source(pkg) if source not in orig_source_remove: binary_only[source][pkg] = "" else: both[source][pkg] = "" if source in source_remove: source_remove.remove(source) results["source demotions"] = 0 results["binary demotions"] = 0 for component in options.components: if component == "main": counterpart = "universe" elif component == "restricted": counterpart = "multiverse" else: continue output = [] for source in filter_source(component, sorted(both)): binaries = sorted(both[source]) output.append([[source] + binaries]) all_output["Source and binary movements to %s" % counterpart] = { "output": output, "source_and_binary": True, } results["source demotions"] += len(output) output = [] for source in sorted(binary_only): binaries = filter_binary(component, sorted(binary_only[source])) if binaries: output.append([[source] + binaries]) all_output["Binary only movements to %s" % counterpart] = { "output": output, "binary_only": True, } results["binary demotions"] += len(output) output = [] for source in filter_source(component, sorted(source_remove)): output.append([[source]]) all_output["Source only movements to %s" % counterpart] = { "output": output, } results["source demotions"] += len(output) for title, output_spec in all_output.items(): source_and_binary = output_spec.get("source_and_binary", False) binary_only = output_spec.get("binary_only", False) print_section_text( options, title, output_spec["output"], source_and_binary=source_and_binary, binary_only=binary_only) if options.html_output is not None and package_team_mapping: by_team = defaultdict(list) for entry in output_spec["output"]: source = entry[0][0] for team in package_team_mapping[source]: by_team[team].append(entry) if not package_team_mapping[source]: by_team["unsubscribed"].append(entry) for team, entries in sorted(by_team.items()): print_section_html( options, "%s (%s)" % (title, team), entries, source_and_binary=source_and_binary, binary_only=binary_only) if options.html_output is not None: print("

Over time

", file=options.html_output) print( make_chart("component-mismatches.csv", [ "source promotions", "binary promotions", "source demotions", "binary demotions", ]), file=options.html_output) print( "

Generated: %s

" % escape(options.timestamp), file=options.html_output) print("", file=options.html_output) return results def do_source_diff(options): removed = [] added = [] removed = list(set(current_source).difference(set(germinate_source))) for pkg in germinate_source: if (pkg not in current_source and is_included_source(options, pkg) and not is_excluded_source(options, pkg)): added.append(pkg) removed.sort() added.sort() return (added, removed) def do_binary_diff(options): removed = [] added = {} removed = list(set(current_binary).difference(set(germinate_binary))) for pkg in germinate_binary: if (pkg not in current_binary and is_included_binary(options, pkg) and not is_excluded_binary(options, pkg)): added[pkg] = germinate_binary[pkg] removed.sort() return (added, removed) def get_mir_bugs(options, sources): '''Return MIR bug information for a set of source packages. Return a map source -> [(id, status, title, assignee), ...] ''' result = defaultdict(list) mir_team = options.launchpad.people['ubuntu-mir'] bug_statuses = ("New", "Incomplete", "Won't Fix", "Confirmed", "Triaged", "In Progress", "Fix Committed", "Fix Released") for source in sources: tasks = options.distro.getSourcePackage(name=source).searchTasks( bug_subscriber=mir_team, status=bug_statuses) for task in tasks: result[source].append((task.bug.id, task.status, task.bug.title, task.assignee)) return result def main(): apt_pkg.init() parser = OptionParser(description='Sync a suite with a Seed list.') parser.add_option( "-l", "--launchpad", dest="launchpad_instance", default="production") parser.add_option('-o', '--output-file', help='output to this file') parser.add_option('--html-output-file', help='output HTML to this file') parser.add_option( '--csv-file', help='record CSV time series data in this file') parser.add_option( '--package-team-mapping', default=os.path.expanduser('~/public_html/package-team-mapping.json'), help='path to package-team-mapping.json') parser.add_option('-s', '--suite', help='check this suite') parser.add_option('-f', '--flavours', default='ubuntu', help='check these flavours (comma-separated)') parser.add_option('-i', '--include', help='include these seeds') parser.add_option('-e', '--exclude', help='exclude these seeds') parser.add_option('-d', '--dot', help='generate main promotion graph suitable for dot') parser.add_option( '--germinate-path', default=os.path.expanduser('~/mirror/ubuntu-germinate/'), help='read Germinate output from this directory') parser.add_option( '--archive-dir', default=os.path.expanduser('~/mirror/ubuntu/'), help='use Ubuntu archive located in this directory') options, args = parser.parse_args() options.launchpad = Launchpad.login_anonymously( 'component-mismatches', options.launchpad_instance) options.distro = options.launchpad.distributions['ubuntu'] options.archive = options.distro.getArchive(name='primary') options.component = "main,restricted" options.components = options.component.split(',') options.all_components = ["main", "restricted", "universe", "multiverse"] if options.suite is None: options.suite = options.distro.current_series.name # Considering all the packages to have a full installable suite. So: # -security = release + -security # -updates = release + -updates + -security # -proposed = release + updates + security + proposed if "-" in options.suite: options.suite, options.pocket = options.suite.split("-") options.suites = [options.suite] if options.pocket in ["updates", "security", "proposed"]: options.suites.append("%s-security" % options.suite) if options.pocket in ["updates", "proposed"]: options.suites.append("%s-updates" % options.suite) if options.pocket in ["proposed"]: options.suites.append("%s-proposed" % options.suite) else: options.suites = [options.suite] if options.output_file is not None: sys.stdout = open('%s.new' % options.output_file, 'w') if options.html_output_file is not None: options.html_output = open('%s.new' % options.html_output_file, 'w') else: options.html_output = None options.time = time.time() options.timestamp = time.strftime( '%a %b %e %H:%M:%S %Z %Y', time.gmtime(options.time)) print('Generated: %s' % options.timestamp) print() read_germinate(options) read_current_source(options) read_current_binary(options) source_add, source_remove = do_source_diff(options) binary_add, binary_remove = do_binary_diff(options) mir_bugs = get_mir_bugs(options, source_add) results = do_output( options, source_add, source_remove, binary_add, binary_remove, mir_bugs) if options.html_output_file is not None: options.html_output.close() os.rename( '%s.new' % options.html_output_file, options.html_output_file) if options.output_file is not None: sys.stdout.close() os.rename('%s.new' % options.output_file, options.output_file) if options.csv_file is not None: if sys.version < "3": open_mode = "ab" open_kwargs = {} else: open_mode = "a" open_kwargs = {"newline": ""} csv_is_new = not os.path.exists(options.csv_file) with open(options.csv_file, open_mode, **open_kwargs) as csv_file: # Field names deliberately hardcoded; any changes require # manually rewriting the output file. fieldnames = [ "time", "source promotions", "binary promotions", "source demotions", "binary demotions", ] csv_writer = csv.DictWriter(csv_file, fieldnames) if csv_is_new: csv_writer.writeheader() csv_writer.writerow(results) if __name__ == '__main__': main()