You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

808 lines
30 KiB

#! /usr/bin/python3
# Copyright 2012 Canonical Ltd.
# Author: Colin Watson <cjwatson@ubuntu.com>
# Based loosely but rather distantly on Launchpad's sync-source.py.
# TODO: This should share more code with syncpackage.
# 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
"""Sync all packages without Ubuntu-specific modifications from Debian."""
from __future__ import print_function
import atexit
from contextlib import closing
import errno
import fnmatch
from functools import cmp_to_key
import gzip
from optparse import OptionParser, Values
import os
import re
import shutil
import ssl
import subprocess
import sys
import tempfile
import time
try:
from urllib.error import HTTPError
from urllib.request import urlopen
except ImportError:
from urllib2 import HTTPError, urlopen
import apt_pkg
from debian import deb822
from launchpadlib.launchpad import Launchpad
from lazr.restfulclient.errors import ServerError
from ubuntutools.archive import DownloadError, SourcePackage
import lputils
CONSUMER_KEY = "auto-sync"
default_suite = {
# TODO: map from unstable
"debian": "sid",
}
class Percentages:
"""Helper to compute percentage ratios compared to a fixed total."""
def __init__(self, total):
self.total = total
def get_ratio(self, number):
"""Report the ratio of `number` to `self.total`, as a percentage."""
return (float(number) / self.total) * 100
def read_blacklist(url):
"""Parse resource at given URL as a 'blacklist'.
Format:
{{{
# [comment]
<sourcename> # [comment]
}}}
Return a list of patterns (fnmatch-style) matching blacklisted source
package names.
Return an empty list if the given URL doesn't exist.
"""
# TODO: The blacklist should migrate into LP, at which point this
# function will be unnecessary.
blacklist = []
try:
with closing(urlopen(url)) as url_file:
for line in url_file:
try:
line = line[:line.index(b'#')]
except ValueError:
pass
line = line.strip()
if not line:
continue
blacklist.append(line.decode('utf-8'))
pass
except HTTPError as e:
if e.code != 404:
raise
return blacklist
def is_blacklisted(blacklist, src):
for pattern in blacklist:
if fnmatch.fnmatch(src, pattern):
return True
return False
tempdir = None
def ensure_tempdir():
global tempdir
if not tempdir:
tempdir = tempfile.mkdtemp(prefix='auto-sync')
atexit.register(shutil.rmtree, tempdir)
def read_ubuntu_sources(options):
"""Read information from the Ubuntu Sources files.
Returns a sequence of:
* a mapping of source package names to versions
* a mapping of binary package names to (source, version) tuples
"""
if options.target.distribution.name != 'ubuntu':
return
print("Reading Ubuntu sources ...")
source_map = {}
binary_map = {}
ensure_tempdir()
suites = [options.target.suite]
if options.target.pocket != "Release":
suites.insert(0, options.target.series.name)
for suite in suites:
for component in ("main", "restricted", "universe", "multiverse"):
url = ("http://archive.ubuntu.com/ubuntu/dists/%s/%s/source/"
"Sources.gz" % (suite, component))
sources_path = os.path.join(
tempdir, "Ubuntu_%s_%s_Sources" % (suite, component))
with closing(urlopen(url)) as url_file:
with open("%s.gz" % sources_path, "wb") as comp_file:
comp_file.write(url_file.read())
with closing(gzip.GzipFile("%s.gz" % sources_path)) as gz_file:
with open(sources_path, "wb") as out_file:
out_file.write(gz_file.read())
with open(sources_path) as sources_file:
apt_sources = apt_pkg.TagFile(sources_file)
for section in apt_sources:
src = section["Package"]
ver = section["Version"]
if (src not in source_map or
apt_pkg.version_compare(source_map[src], ver) < 0):
source_map[src] = ver
binaries = apt_pkg.parse_depends(
section.get("Binary", src))
for pkg in [b[0][0] for b in binaries]:
if (pkg not in binary_map or
apt_pkg.version_compare(
binary_map[pkg][1], ver) < 0):
binary_map[pkg] = (src, ver)
return source_map, binary_map
def read_debian_sources(options):
"""Read information from the Debian Sources files.
Returns a mapping of source package names to (version, set of
architectures) tuples.
"""
if options.source.distribution.name != 'debian':
return
print("Reading Debian sources ...")
source_map = {}
ensure_tempdir()
for component in ("main", "contrib", "non-free"):
url = ("http://ftp.debian.org/debian/dists/%s/%s/source/"
"Sources.gz" % (options.source.suite, component))
sources_path = os.path.join(
tempdir,
"Debian_%s_%s_Sources" % (options.source.suite, component))
with closing(urlopen(url)) as url_file:
with open("%s.gz" % sources_path, "wb") as compressed_file:
compressed_file.write(url_file.read())
with closing(gzip.GzipFile("%s.gz" % sources_path)) as gz_file:
with open(sources_path, "wb") as out_file:
out_file.write(gz_file.read())
with open(sources_path) as sources_file:
apt_sources = apt_pkg.TagFile(sources_file)
for section in apt_sources:
src = section["Package"]
ver = section["Version"]
if (src not in source_map or
apt_pkg.version_compare(source_map[src][0], ver) < 0):
source_map[src] = (
ver, set(section.get("Architecture", "").split()))
return source_map
def read_new_queue(options):
"""Return the set of packages already in the NEW queue."""
new_queue = options.target.series.getPackageUploads(
archive=options.target.archive, status="New")
return set([pu.package_name for pu in new_queue
if pu.contains_source or pu.contains_copy])
def question(options, message, choices, default):
choices = "/".join([c.upper() if c == default else c for c in choices])
if options.batch:
print("%s (%s)? %s" % (message, choices, default.lower()))
return default.lower()
else:
sys.stdout.write("%s (%s)? " % (message, choices))
sys.stdout.flush()
return sys.stdin.readline().rstrip().lower()
def filter_pockets(spphs):
"""Filter SourcePackagePublishingHistory entries to useful pockets."""
return [spph for spph in spphs if spph.pocket in ("Release", "Proposed")]
def version_sort_spphs(spphs):
"""Sort a list of SourcePackagePublishingHistory entries by version.
We return the list in reversed form (highest version first), since
that's what the consumers of this function prefer anyway.
"""
def version_compare(x, y):
return apt_pkg.version_compare(
x.source_package_version, y.source_package_version)
return sorted(
spphs, key=cmp_to_key(version_compare), reverse=True)
class FakeDifference:
"""A partial stub for DistroSeriesDifference.
Used when the destination series was initialised with a different
parent, so we don't get real DSDs.
"""
def __init__(self, options, src, ver):
self.options = options
self.status = "Needs attention"
self.sourcepackagename = src
self.source_version = ver
self.real_parent_source_version = None
self.fetched_parent_source_version = False
@property
def parent_source_version(self):
"""The version in the parent series.
We can't take this directly from read_debian_sources, since we need
the version imported into Launchpad and Launchpad may be behind; so
we have to call Archive.getPublishedSources to find this out. As
such, this is expensive, so we only do it when necessary.
"""
if not self.fetched_parent_source_version:
spphs = self.options.source.archive.getPublishedSources(
distro_series=self.options.source.series,
pocket=self.options.source.pocket,
source_name=self.sourcepackagename, exact_match=True,
status="Published")
spphs = version_sort_spphs(spphs)
if spphs:
self.real_parent_source_version = \
spphs[0].source_package_version
self.fetched_parent_source_version = True
return self.real_parent_source_version
def get_differences(options, ubuntu_sources, debian_sources):
# DSDs are not quite sufficiently reliable for us to use them here,
# regardless of the parent series. See:
# https://bugs.launchpad.net/launchpad/+bug/1003969
# Also, how would this work with non-Release pockets?
# if options.source.series in options.target.series.getParentSeries():
if False:
for status in (
"Needs attention",
"Blacklisted current version",
"Blacklisted always",
):
for difference in options.target.series.getDifferencesTo(
parent_series=options.source.series, status=status):
yield difference
else:
# Hack around missing DSDs if the series was initialised with a
# different parent.
for src in sorted(debian_sources):
if (src not in ubuntu_sources or
apt_pkg.version_compare(
ubuntu_sources[src], debian_sources[src][0]) < 0):
yield FakeDifference(options, src, ubuntu_sources.get(src))
def published_in_source_series(options, difference):
# Oddly, sometimes packages seem to show up as a difference without
# actually being published in options.source.series. Filter those out.
src = difference.sourcepackagename
from_version = difference.parent_source_version
from_src = options.source.archive.getPublishedSources(
distro_series=options.source.series, pocket=options.source.pocket,
source_name=src, version=from_version, exact_match=True,
status="Published")
if not from_src:
if options.verbose:
print(
"No published sources for %s_%s in %s/%s?" % (
src, from_version,
options.source.distribution.display_name,
options.source.suite),
file=sys.stderr)
return False
else:
return True
def already_in_target_series(options, difference):
# The published Sources files may be out of date, and if we're
# particularly unlucky with timing relative to a proposed-migration run
# it's possible for them to miss something that's in the process of
# being moved between pockets. To make sure, check whether an equal or
# higher version has already been removed from the destination archive.
src = difference.sourcepackagename
from_version = difference.parent_source_version
to_src = version_sort_spphs(filter_pockets(
options.target.archive.getPublishedSources(
distro_series=options.target.series, source_name=src,
exact_match=True)))
if (to_src and
apt_pkg.version_compare(
from_version, to_src[0].source_package_version) <= 0):
return True
else:
return False
def architectures_allowed(dsc, target):
"""Return True if the architecture set dsc is compatible with target."""
if dsc == set(["all"]):
return True
for dsc_arch in dsc:
for target_arch in target:
command = [
"dpkg-architecture", "-a%s" % target_arch, "-i%s" % dsc_arch]
env = dict(os.environ)
env["CC"] = "true"
if subprocess.call(command, env=env) == 0:
return True
return False
def retry_errors(func):
for retry_count in range(7):
try:
return func()
except ssl.SSLError:
pass
except DownloadError as e:
# These are unpleasantly difficult to parse, but we have little
# choice since the exception object lacks useful metadata.
code = None
match = re.match(r".*?: (.*?) ", str(e))
if match is not None:
try:
code = int(match.group(1))
except ValueError:
pass
if code in (502, 503):
time.sleep(int(2 ** (retry_count - 1)))
else:
raise
def sync_one_difference(options, binary_map, difference, source_names):
src = difference.sourcepackagename
print(" * Trying to add %s ..." % src)
# We use SourcePackage directly here to avoid having to hardcode Debian
# and Ubuntu, and because we got the package list from
# DistroSeries.getDifferencesTo() so we can guarantee that Launchpad
# knows about all of them.
from_srcpkg = SourcePackage(
package=src, version=difference.parent_source_version,
lp=options.launchpad)
from_srcpkg.distribution = options.source.distribution.name
retry_errors(from_srcpkg.pull_dsc)
if difference.source_version is not None:
# Check whether this will require a fakesync.
to_srcpkg = SourcePackage(
package=src, version=difference.source_version,
lp=options.launchpad)
to_srcpkg.distribution = options.target.distribution.name
retry_errors(to_srcpkg.pull_dsc)
if not from_srcpkg.dsc.compare_dsc(to_srcpkg.dsc):
print("[Skipping (requires fakesync)] %s_%s (vs %s)" % (
src, difference.parent_source_version,
difference.source_version))
return False
from_binary = deb822.PkgRelation.parse_relations(
from_srcpkg.dsc["binary"])
pkgs = [entry[0]["name"] for entry in from_binary]
for pkg in pkgs:
if pkg in binary_map:
current_src, current_ver = binary_map[pkg]
# TODO: Check that a non-main source package is not trying to
# override a main binary package (we don't know binary
# components yet).
# Check that a source package is not trying to override an
# Ubuntu-modified binary package.
if "ubuntu" in current_ver:
answer = question(
options,
"%s_%s is trying to override modified binary %s_%s. "
"OK" % (
src, difference.parent_source_version,
pkg, current_ver), "yn", "n")
if answer != "y":
return False
print("I: %s -> %s_%s." % (src, pkg, current_ver))
source_names.append(src)
return True
failed_copy = None
def copy_packages(options, source_names):
global failed_copy
if failed_copy is not None and len(source_names) >= failed_copy:
source_names_left = source_names[:len(source_names) // 2]
source_names_right = source_names[len(source_names) // 2:]
copy_packages(options, source_names_left)
copy_packages(options, source_names_right)
return
try:
options.target.archive.copyPackages(
source_names=source_names,
from_archive=options.source.archive,
from_series=options.source.series.name,
to_series=options.target.series.name,
to_pocket=options.target.pocket,
include_binaries=False, sponsored=options.requestor,
auto_approve=True, silent=True)
except ServerError as e:
if len(source_names) < 100:
raise
if e.response.status != 503:
raise
print("Cannot copy %d packages at once; bisecting ..." %
len(source_names))
failed_copy = len(source_names)
source_names_left = source_names[:len(source_names) // 2]
source_names_right = source_names[len(source_names) // 2:]
copy_packages(options, source_names_left)
copy_packages(options, source_names_right)
def sync_differences(options):
stat_us = 0
stat_cant_update = 0
stat_updated = 0
stat_uptodate_modified = 0
stat_uptodate = 0
stat_count = 0
stat_blacklisted = 0
blacklist = read_blacklist(
"http://people.canonical.com/~ubuntu-archive/sync-blacklist.txt")
ubuntu_sources, binary_map = read_ubuntu_sources(options)
debian_sources = read_debian_sources(options)
new_queue = read_new_queue(options)
print("Getting differences between %s/%s and %s/%s ..." % (
options.source.distribution.display_name, options.source.suite,
options.target.distribution.display_name, options.target.suite))
new_differences = []
updated_source_names = []
new_source_names = []
seen_differences = set()
for difference in get_differences(options, ubuntu_sources, debian_sources):
status = difference.status
if status == "Resolved":
stat_uptodate += 1
continue
stat_count += 1
src = difference.sourcepackagename
if src in seen_differences:
continue
seen_differences.add(src)
to_version = difference.source_version
if to_version is None:
src_ver = src
else:
src_ver = "%s_%s" % (src, to_version)
src_is_blacklisted = is_blacklisted(blacklist, src)
if src_is_blacklisted or status == "Blacklisted always":
if options.verbose:
if src_is_blacklisted:
print("[BLACKLISTED] %s" % src_ver)
else:
comments = options.target.series.getDifferenceComments(
source_package_name=src)
if comments:
print("""[BLACKLISTED] %s (%s: "%s")""" % (
src_ver, comments[-1].comment_author.name,
comments[-1].body_text))
else:
print("[BLACKLISTED] %s" % src_ver)
stat_blacklisted += 1
# "Blacklisted current version" is supposed to mean that the version
# in options.target.series is higher than that in
# options.source.series. However, I've seen cases that suggest that
# this status isn't necessarily always kept up to date properly.
# Since we're perfectly capable of checking the versions for
# ourselves anyway, let's just be cautious and check everything with
# both plausible statuses.
elif status in ("Needs attention", "Blacklisted current version"):
from_version = difference.parent_source_version
if from_version is None:
if options.verbose:
print("[Ubuntu Specific] %s" % src_ver)
stat_us += 1
continue
if to_version is None:
if not published_in_source_series(options, difference):
continue
# Handle new packages at the end, since they require more
# interaction.
if options.new:
new_differences.append(difference)
continue
elif options.new_only:
stat_uptodate += 1
elif apt_pkg.version_compare(to_version, from_version) < 0:
if "ubuntu" in to_version:
if options.verbose:
print("[NOT Updating - Modified] %s (vs %s)" % (
src_ver, from_version))
stat_cant_update += 1
else:
if not published_in_source_series(options, difference):
continue
if already_in_target_series(options, difference):
continue
print("[Updating] %s (%s [%s] < %s [%s])" % (
src, to_version,
options.target.distribution.display_name,
from_version,
options.source.distribution.display_name))
if sync_one_difference(
options, binary_map, difference,
updated_source_names):
stat_updated += 1
else:
stat_cant_update += 1
elif "ubuntu" in to_version:
if options.verbose:
print("[Nothing to update (Modified)] %s (vs %s)" % (
src_ver, from_version))
stat_uptodate_modified += 1
else:
if options.verbose:
print("[Nothing to update] %s (%s [%s] >= %s [%s])" % (
src, to_version,
options.target.distribution.display_name,
from_version,
options.source.distribution.display_name))
stat_uptodate += 1
else:
print("[Unknown status] %s (%s)" % (src_ver, status),
file=sys.stderr)
target_architectures = set(
a.architecture_tag for a in options.target.architectures)
for difference in new_differences:
src = difference.sourcepackagename
from_version = difference.parent_source_version
if src in new_queue:
print("[Skipping (already in NEW)] %s_%s" % (src, from_version))
continue
if not architectures_allowed(
debian_sources[src][1], target_architectures):
if options.verbose:
print(
"[Skipping (not built on any target architecture)] %s_%s" %
(src, from_version))
continue
to_src = version_sort_spphs(filter_pockets(
options.target.archive.getPublishedSources(
source_name=src, exact_match=True)))
if (to_src and
apt_pkg.version_compare(
from_version, to_src[0].source_package_version) <= 0):
# Equal or higher version already removed from destination
# distribution.
continue
print("[New] %s_%s" % (src, from_version))
if to_src:
print("Previous publications in %s:" %
options.target.distribution.display_name)
for spph in to_src[:10]:
desc = " %s (%s): %s" % (
spph.source_package_version, spph.distro_series.name,
spph.status)
if (spph.status == "Deleted" and
spph.removed_by is not None and
spph.removal_comment is not None):
desc += " (removed by %s: %s)" % (
spph.removed_by.display_name, spph.removal_comment)
print(desc)
if len(to_src) > 10:
history_url = "%s/+source/%s/+publishinghistory" % (
options.target.distribution.web_link, src)
print(" ... plus %d more; see %s" %
(len(to_src) - 10, history_url))
else:
print("No previous publications in %s" %
options.target.distribution.display_name)
answer = question(options, "OK", "yn", "y")
new_ok = (answer != "n")
if new_ok:
if sync_one_difference(
options, binary_map, difference, new_source_names):
stat_updated += 1
else:
stat_cant_update += 1
else:
stat_blacklisted += 1
percentages = Percentages(stat_count)
print()
print("Out-of-date BUT modified: %3d (%.2f%%)" % (
stat_cant_update, percentages.get_ratio(stat_cant_update)))
print("Updated: %3d (%.2f%%)" % (
stat_updated, percentages.get_ratio(stat_updated)))
print("Ubuntu Specific: %3d (%.2f%%)" % (
stat_us, percentages.get_ratio(stat_us)))
print("Up-to-date [Modified]: %3d (%.2f%%)" % (
stat_uptodate_modified, percentages.get_ratio(stat_uptodate_modified)))
print("Up-to-date: %3d (%.2f%%)" % (
stat_uptodate, percentages.get_ratio(stat_uptodate)))
print("Blacklisted: %3d (%.2f%%)" % (
stat_blacklisted, percentages.get_ratio(stat_blacklisted)))
print(" -----------")
print("Total: %s" % stat_count)
if updated_source_names + new_source_names:
print()
if updated_source_names:
print("Updating: %s" % " ".join(updated_source_names))
if new_source_names:
print("New: %s" % " ".join(new_source_names))
if options.dry_run:
print("Not copying packages in dry-run mode.")
else:
answer = question(options, "OK", "yn", "y")
if answer != "n":
copy_packages(options, updated_source_names + new_source_names)
def main():
if sys.version >= '3':
# Force encoding to UTF-8 even in non-UTF-8 locales.
import io
sys.stdout = io.TextIOWrapper(
sys.stdout.detach(), encoding="UTF-8", line_buffering=True)
else:
# Avoid having to do .encode('UTF-8') everywhere. This is a pain; I
# wish Python supported something like
# "sys.stdout.encoding = 'UTF-8'".
def fix_stdout():
import codecs
sys.stdout = codecs.EncodedFile(sys.stdout, 'UTF-8')
def null_decode(input, errors='strict'):
return input, len(input)
sys.stdout.decode = null_decode
fix_stdout()
parser = OptionParser(usage="usage: %prog [options]")
parser.add_option(
"-l", "--launchpad", dest="launchpad_instance", default="production")
parser.add_option(
"-v", "--verbose", dest="verbose",
default=False, action="store_true", help="be more verbose")
parser.add_option(
"--log-directory", help="log to a file under this directory")
parser.add_option(
"-d", "--to-distro", dest="todistro", default="ubuntu",
metavar="DISTRO", help="sync to DISTRO")
parser.add_option(
"-s", "--to-suite", dest="tosuite",
metavar="SUITE", help="sync to SUITE")
parser.add_option(
"-D", "--from-distro", dest="fromdistro", default="debian",
metavar="DISTRO", help="sync from DISTRO")
parser.add_option(
"-S", "--from-suite", dest="fromsuite",
metavar="SUITE", help="sync from SUITE")
parser.add_option(
"--new-only", dest="new_only",
default=False, action="store_true", help="only sync new packages")
parser.add_option(
"--no-new", dest="new",
default=True, action="store_false", help="don't sync new packages")
parser.add_option(
"--batch", dest="batch", default=False, action="store_true",
help="assume default answer to all questions")
parser.add_option(
"--dry-run", default=False, action="store_true",
help="only show what would be done; don't copy packages")
options, args = parser.parse_args()
if args:
parser.error("This program does not accept any non-option arguments.")
apt_pkg.init()
options.launchpad = Launchpad.login_with(
CONSUMER_KEY, options.launchpad_instance, version="devel")
if options.log_directory is not None:
now = time.gmtime()
log_relative_path = os.path.join(
time.strftime("%F", now), "%s.log" % time.strftime("%T", now))
log_file = os.path.join(options.log_directory, log_relative_path)
if not os.path.isdir(os.path.dirname(log_file)):
os.makedirs(os.path.dirname(log_file))
sys.stdout = open(log_file, "w", buffering=1)
else:
log_file = None
options.source = Values()
options.source.launchpad = options.launchpad
options.source.distribution = options.fromdistro
options.source.suite = options.fromsuite
if options.source.suite is None and options.fromdistro in default_suite:
options.source.suite = default_suite[options.fromdistro]
lputils.setup_location(options.source)
options.target = Values()
options.target.launchpad = options.launchpad
options.target.distribution = options.todistro
options.target.suite = options.tosuite
lputils.setup_location(options.target, default_pocket="Proposed")
# This is a very crude check, and easily bypassed. It's simply here to
# discourage people from causing havoc by mistake. A mass auto-sync is
# a disruptive operation, and, generally speaking, archive
# administrators know when it's OK to do one. If you aren't an archive
# administrator, you should think very hard, and ask on #ubuntu-release
# if options.target.distribution is Ubuntu, before disabling this check.
owner = options.target.archive.owner
if (not options.dry_run and
options.launchpad.me != owner and
options.launchpad.me not in owner.participants):
print("You are not an archive administrator for %s. Exiting." %
options.target.distribution.display_name, file=sys.stderr)
sys.exit(1)
options.requestor = options.launchpad.people["katie"]
sync_differences(options)
if options.log_directory is not None:
sys.stdout.close()
current_link = os.path.join(options.log_directory, "current.log")
try:
os.unlink(current_link)
except OSError as e:
if e.errno != errno.ENOENT:
raise
os.symlink(log_relative_path, current_link)
if __name__ == '__main__':
main()