from abc import abstractmethod from enum import Enum, unique import apt_pkg import os import time from consts import VERSION, BINARIES @unique class PolicyVerdict(Enum): """""" """ The migration item passed the policy. """ PASS = 1 """ The policy was completely overruled by a hint. """ PASS_HINTED = 2 """ The migration item did not pass the policy, but the failure is believed to be temporary """ REJECTED_TEMPORARILY = 3 """ The migration item did not pass the policy and the failure is believed to be uncorrectable (i.e. a hint or a new version is needed) """ REJECTED_PERMANENTLY = 4 @property def is_rejected(self): return True if self.name.startswith('REJECTED') else False class BasePolicy(object): def __init__(self, options, applicable_suites): """The BasePolicy constructor :param options The options member of Britney with all the config options. :param applicable_suites A set of suite names where this policy applies. """ self.options = options self.applicable_suites = applicable_suites self.hints = None # FIXME: use a proper logging framework def log(self, msg, type="I"): """Print info messages according to verbosity level An easy-and-simple log method which prints messages to the standard output. The type parameter controls the urgency of the message, and can be equal to `I' for `Information', `W' for `Warning' and `E' for `Error'. Warnings and errors are always printed, and information is printed only if verbose logging is enabled. """ if self.options.verbose or type in ("E", "W"): print("%s: [%s] - %s" % (type, time.asctime(), msg)) def initialise(self, britney): """Called once to make the policy initialise any data structures This is useful for e.g. parsing files or other "heavy do-once" work. :param britney This is the instance of the "Britney" class. """ pass def save_state(self, britney): """Called once at the end of the run to make the policy save any persistent data Note this will *not* be called for "dry-runs" as such runs should not change the state. :param britney This is the instance of the "Britney" class. """ pass @abstractmethod def apply_policy(self, policy_info, suite, source_name, source_data_tdist, source_data_srcdist): """Apply a policy on a given source migration Britney will call this method on a given source package, when Britney is considering to migrate it from the given source suite to the target suite. The policy will then evaluate the the migration and then return a verdict. :param policy_info A dictionary of all policy results. The policy can add a value stored in a key related to its name. (e.g. policy_info['age'] = {...}). This will go directly into the "excuses.yaml" output. :param suite The name of the suite from where the source is migrating from. :param source_data_tdist Information about the source package in the target distribution (e.g. "testing"). This is the data structure in Britney.sources['testing'][source_name] :param source_data_srcdist Information about the source package in the source distribution (e.g. "unstable" or "tpu"). This is the data structure in Britney.sources[suite][source_name] :return A Policy Verdict (e.g. PolicyVerdict.PASS) """ pass class AgePolicy(BasePolicy): """Configurable Aging policy for source migrations The AgePolicy will let packages stay in the source suite for a pre-defined amount of days before letting migrate (based on their urgency, if any). The AgePolicy's decision is influenced by the following: State files: * ${STATE_DIR}/age-policy-urgencies: File containing urgencies for source packages. Note that urgencies are "sticky" and the most "urgent" urgency will be used (i.e. the one with lowest age-requirements). - This file needs to be updated externally, if the policy should take urgencies into consideration. If empty (or not updated), the policy will simply use the default urgency (see the "Config" section below) - In Debian, these values are taken from the .changes file, but that is not a requirement for Britney. * ${STATE_DIR}/age-policy-dates: File containing the age of all source packages. - The policy will automatically update this file. Config: * DEFAULT_URGENCY: Name of the urgency used for packages without an urgency (or for unknown urgencies). Will also be used to set the "minimum" aging requirements for packages not in the target suite. * MINDAYS_: The age-requirements in days for packages with the given urgency. - Commonly used urgencies are: low, medium, high, emergency, critical Hints: * urgent /: Disregard the age requirements for a given source/version. * age-days X /: Set the age requirements for a given source/version to X days. Note that X can exceed the highest age-requirement normally given. """ def __init__(self, options, mindays): super().__init__(options, {'unstable'}) self._min_days = mindays if options.default_urgency not in mindays: raise ValueError("Missing age-requirement for default urgency (MINDAYS_%s)" % options.default_urgency) self._min_days_default = mindays[options.default_urgency] # britney's "day" begins at 3pm self._date_now = int(((time.time() / (60*60)) - 15) / 24) self._dates = {} self._urgencies = {} def initialise(self, britney): super().initialise(britney) self._read_dates_file() self._read_urgencies_file(britney) def save_state(self, britney): super().save_state(britney) self._write_dates_file() def apply_policy(self, policy_info, suite, source_name, source_data_tdist, source_data_srcdist): # retrieve the urgency for the upload, ignoring it if this is a NEW package (not present in testing) urgency = self._urgencies.get(source_name, self.options.default_urgency) if 'age' not in policy_info: policy_info['age'] = age_info = {} else: age_info = policy_info['age'] if urgency not in self._min_days: age_info['unknown-urgency'] = urgency urgency = self.options.default_urgency if not source_data_tdist: if self._min_days[urgency] < self._min_days_default: age_info['urgency-reduced'] = { 'from': urgency, 'to': self.options.default_urgency, } urgency = self.options.default_urgency if source_name not in self._dates: self._dates[source_name] = (source_data_srcdist[VERSION], self._date_now) elif self._dates[source_name][0] != source_data_srcdist[VERSION]: self._dates[source_name] = (source_data_srcdist[VERSION], self._date_now) days_old = self._date_now - self._dates[source_name][1] min_days = self._min_days[urgency] age_info['age-requirement'] = min_days age_info['current-age'] = days_old for age_days_hint in self.hints.search('age-days', package=source_name, version=source_data_srcdist[VERSION]): new_req = int(age_days_hint.days) age_info['age-requirement-reduced'] = { 'new-requirement': new_req, 'changed-by': age_days_hint.user } min_days = new_req if days_old < min_days: urgent_hints = self.hints.search('urgent', package=source_name, version=source_data_srcdist[VERSION]) if urgent_hints: age_info['age-requirement-reduced'] = { 'new-requirement': 0, 'changed-by': urgent_hints[0].user } return PolicyVerdict.PASS_HINTED else: return PolicyVerdict.REJECTED_TEMPORARILY return PolicyVerdict.PASS def _read_dates_file(self): """Parse the dates file""" dates = self._dates fallback_filename = os.path.join(self.options.testing, 'Dates') try: filename = os.path.join(self.options.state_dir, 'age-policy-dates') if not os.path.exists(filename) and os.path.exists(fallback_filename): filename = fallback_filename except AttributeError: filename = fallback_filename with open(filename, encoding='utf-8') as fd: for line in fd: # l = line.split() if len(l) != 3: continue try: dates[l[0]] = (l[1], int(l[2])) except ValueError: pass def _read_urgencies_file(self, britney): urgencies = self._urgencies min_days_default = self._min_days_default fallback_filename = os.path.join(self.options.testing, 'Urgency') try: filename = os.path.join(self.options.state_dir, 'age-policy-urgencies') if not os.path.exists(filename) and os.path.exists(fallback_filename): filename = fallback_filename except AttributeError: filename = fallback_filename with open(filename, errors='surrogateescape', encoding='ascii') as fd: for line in fd: # l = line.split() if len(l) != 3: continue # read the minimum days associated with the urgencies urgency_old = urgencies.get(l[0], None) mindays_old = self._min_days.get(urgency_old, 1000) mindays_new = self._min_days.get(l[2], min_days_default) # if the new urgency is lower (so the min days are higher), do nothing if mindays_old <= mindays_new: continue # if the package exists in testing and it is more recent, do nothing tsrcv = britney.sources['testing'].get(l[0], None) if tsrcv and apt_pkg.version_compare(tsrcv[VERSION], l[1]) >= 0: continue # if the package doesn't exist in unstable or it is older, do nothing usrcv = britney.sources['unstable'].get(l[0], None) if not usrcv or apt_pkg.version_compare(usrcv[VERSION], l[1]) < 0: continue # update the urgency for the package urgencies[l[0]] = l[2] def _write_dates_file(self): dates = self._dates try: directory = self.options.state_dir basename = 'age-policy-dates' old_file = os.path.join(self.options.testing, 'Dates') except AttributeError: directory = self.options.testing basename = 'Dates' old_file = None filename = os.path.join(directory, basename) filename_tmp = os.path.join(directory, '%s_new' % basename) with open(filename_tmp, 'w', encoding='utf-8') as fd: for pkg in sorted(dates): version, date = dates[pkg] fd.write("%s %s %d\n" % (pkg, version, date)) os.rename(filename_tmp, filename) if old_file is not None and os.path.exists(old_file): self.log("Removing old age-policy-dates file %s" % old_file) os.unlink(old_file) class RCBugPolicy(BasePolicy): """RC bug regression policy for source migrations The RCBugPolicy will read provided list of RC bugs and block any source upload that would introduce a *new* RC bug in the target suite. The RCBugPolicy's decision is influenced by the following: State files: * ${STATE_DIR}/rc-bugs-unstable: File containing RC bugs for packages in the source suite. - This file needs to be updated externally. * ${STATE_DIR}/rc-bugs-testing: File containing RC bugs for packages in the target suite. - This file needs to be updated externally. """ def __init__(self, options): super().__init__(options, {'unstable'}) self._bugs = {} def initialise(self, britney): super().initialise(britney) fallback_unstable = os.path.join(self.options.unstable, 'BugsV') fallback_testing = os.path.join(self.options.testing, 'BugsV') try: filename_unstable = os.path.join(self.options.state_dir, 'rc-bugs-unstable') filename_testing = os.path.join(self.options.state_dir, 'rc-bugs-testing') if not os.path.exists(filename_unstable) and not os.path.exists(filename_testing) and \ os.path.exists(fallback_unstable) and os.path.exists(fallback_testing): filename_unstable = fallback_unstable filename_testing = fallback_testing except AttributeError: filename_unstable = fallback_unstable filename_testing = fallback_testing self._bugs['unstable'] = self._read_bugs(filename_unstable) self._bugs['testing'] = self._read_bugs(filename_testing) def apply_policy(self, policy_info, suite, source_name, source_data_tdist, source_data_srcdist): # retrieve the urgency for the upload, ignoring it if this is a NEW package (not present in testing) if 'rc-bugs' not in policy_info: policy_info['rc-bugs'] = rcbugs_info = {} else: rcbugs_info = policy_info['rc-bugs'] bugs_t = set() bugs_u = set() for src_key in (source_name, 'src:%s' % source_name): if source_data_tdist and src_key in self._bugs['testing']: bugs_t.update(self._bugs['testing'][src_key]) if src_key in self._bugs['unstable']: bugs_u.update(self._bugs['unstable'][src_key]) for pkg, _, _ in source_data_srcdist[BINARIES]: if pkg in self._bugs['unstable']: bugs_u |= self._bugs['unstable'][pkg] if source_data_tdist: for pkg, _, _ in source_data_tdist[BINARIES]: if pkg in self._bugs['testing']: bugs_t |= self._bugs['testing'][pkg] # If a package is not in testing, it has no RC bugs per # definition. Unfortunately, it seems that the live-data is # not always accurate (e.g. live-2011-12-13 suggests that # obdgpslogger had the same bug in testing and unstable, # but obdgpslogger was not in testing at that time). # - For the curious, obdgpslogger was removed on that day # and the BTS probably had not caught up with that fact. # (https://tracker.debian.org/news/415935) assert not bugs_t or source_data_tdist, "%s had bugs in testing but is not in testing" % source_name rcbugs_info['shared-bugs'] = sorted(bugs_u & bugs_t) rcbugs_info['unique-source-bugs'] = sorted(bugs_u - bugs_t) rcbugs_info['unique-target-bugs'] = sorted(bugs_t - bugs_u) if not bugs_u or bugs_u <= bugs_t: return PolicyVerdict.PASS return PolicyVerdict.REJECTED_PERMANENTLY def _read_bugs(self, filename): """Read the release critical bug summary from the specified file The file contains rows with the format: [,...] The method returns a dictionary where the key is the binary package name and the value is the list of open RC bugs for it. """ bugs = {} self.log("Loading RC bugs data from %s" % filename) for line in open(filename, encoding='ascii'): l = line.split() if len(l) != 2: self.log("Malformed line found in line %s" % (line), type='W') continue pkg = l[0] if pkg not in bugs: bugs[pkg] = set() bugs[pkg].update(l[1].split(",")) return bugs