Merge the boottest-jenkins script wrapper into BootTest class for simplicity and extend the API to match what we already for auto-package-testing (ADT).

This commit is contained in:
Celso Providelo 2015-02-04 08:54:00 -05:00
parent 06ea2ab941
commit 1e8dc398e9
3 changed files with 193 additions and 96 deletions

View File

@ -13,11 +13,17 @@
# GNU General Public License for more details. # GNU General Public License for more details.
from __future__ import print_function from __future__ import print_function
from collections import defaultdict
from contextlib import closing
import os import os
import subprocess import subprocess
import tempfile
from textwrap import dedent
import time import time
import urllib import urllib
import apt_pkg
from consts import BINARIES from consts import BINARIES
@ -41,12 +47,23 @@ class TouchManifest(object):
""" """
def __init__(self, distribution, series, verbose=False, fetch=True):
self.verbose = verbose
self.path = "boottest/images/{}/{}/manifest".format(
distribution, series)
if fetch:
self.__fetch_manifest(distribution, series)
self._manifest = self._load()
def __fetch_manifest(self, distribution, series): def __fetch_manifest(self, distribution, series):
url = "http://cdimage.ubuntu.com/{}/daily-preinstalled/" \ url = "http://cdimage.ubuntu.com/{}/daily-preinstalled/" \
"pending/{}-preinstalled-touch-armhf.manifest".format( "pending/{}-preinstalled-touch-armhf.manifest".format(
distribution, series distribution, series
) )
print("I: [%s] - Fetching manifest from %s" % (time.asctime(), url)) if self.verbose:
print(
"I: [%s] - Fetching manifest from %s" % (
time.asctime(), url))
response = urllib.urlopen(url) response = urllib.urlopen(url)
# Only [re]create the manifest file if one was successfully downloaded # Only [re]create the manifest file if one was successfully downloaded
# this allows for an existing image to be used if the download fails. # this allows for an existing image to be used if the download fails.
@ -55,15 +72,6 @@ class TouchManifest(object):
with open(self.path, 'w') as fp: with open(self.path, 'w') as fp:
fp.write(response.read()) fp.write(response.read())
def __init__(self, distribution, series, fetch=True):
self.path = "boottest/images/{}/{}/manifest".format(
distribution, series)
if fetch:
self.__fetch_manifest(distribution, series)
self._manifest = self._load()
def _load(self): def _load(self):
pkg_list = [] pkg_list = []
@ -87,52 +95,11 @@ class TouchManifest(object):
return key in self._manifest return key in self._manifest
class BootTestJenkinsJob(object):
"""Boottest - Jenkins **glue**.
Wraps 'boottest/jenkins/boottest-britney' script for:
* 'check' existing boottest job status ('check <source> <version>')
* 'submit' new boottest jobs ('submit <source> <version>')
"""
script_path = "boottest/jenkins/boottest-britney"
def __init__(self, distribution, series):
self.distribution = distribution
self.series = series
def _run(self, *args):
if not os.path.exists(self.script_path):
print("E: [%s] - Boottest/Jenking glue script missing: %s" % (
time.asctime(), self.script_path))
return '-'
command = [
self.script_path,
"-d", self.distribution, "-s", self.series,
]
command.extend(args)
return subprocess.check_output(command).strip()
def get_status(self, name, version):
"""Return the current boottest jenkins job status.
Request a boottest attempt if it's new.
"""
try:
status = self._run('check', name, version)
except subprocess.CalledProcessError as err:
status = self._run('submit', name, version)
return status
class BootTest(object): class BootTest(object):
"""Boottest criteria for Britney. """Boottest criteria for Britney.
Process (update) excuses for the 'boottest' criteria. Request and monitor This class provides an API for handling the boottest-jenkins
boottest attempts (see `BootTestJenkinsJob`) for binaries present in the integration layer (mostly derived from auto-package-testing/adt):
phone image manifest (see `TouchManifest`).
""" """
VALID_STATUSES = ('PASS', 'SKIPPED') VALID_STATUSES = ('PASS', 'SKIPPED')
@ -143,48 +110,126 @@ class BootTest(object):
"RUNNING": '<span style="background:#99ddff">Test in progress</span>', "RUNNING": '<span style="background:#99ddff">Test in progress</span>',
} }
script_path = "boottest/jenkins/boottest-britney"
def __init__(self, britney, distribution, series, debug=False): def __init__(self, britney, distribution, series, debug=False):
self.britney = britney self.britney = britney
self.distribution = distribution self.distribution = distribution
self.series = series self.series = series
self.debug = debug self.debug = debug
self.rc_path = None
self._read()
manifest_fetch = getattr( manifest_fetch = getattr(
self.britney.options, "boottest_fetch", "no") == "yes" self.britney.options, "boottest_fetch", "no") == "yes"
self.phone_manifest = TouchManifest( self.phone_manifest = TouchManifest(
self.distribution, self.series, fetch=manifest_fetch) self.distribution, self.series, fetch=manifest_fetch,
self.dispatcher = BootTestJenkinsJob(self.distribution, self.series) verbose=self.britney.options.verbose)
def update(self, excuse): @property
"""Return the boottest status for the given excuse. def _request_path(self):
return "boottest/work/adt.request.%s" % self.series
A new boottest job will be requested if the the source was not @property
yet processed, otherwise the status of the corresponding job will def _result_path(self):
be returned. return "boottest/work/adt.result.%s" % self.series
def _ensure_rc_file(self):
if self.rc_path:
return
self.rc_path = os.path.abspath("boottest/rc.%s" % self.series)
with open(self.rc_path, "w") as rc_file:
home = os.path.expanduser("~")
print(dedent("""\
release: %s
aptroot: ~/.chdist/%s-proposed-armhf/
apturi: file:%s/mirror/%s
components: main restricted universe multiverse
rsync_host: rsync://tachash.ubuntu-ci/adt/
datadir: ~/proposed-migration/boottest/data""" %
(self.series, self.series, home, self.distribution)),
file=rc_file)
def _run(self, *args):
self._ensure_rc_file()
if not os.path.exists(self.script_path):
print("E: [%s] - Boottest/Jenking glue script missing: %s" % (
time.asctime(), self.script_path))
return '-'
command = [
self.script_path,
"-c", self.rc_path,
"-d", self.distribution, "-s", self.series,
]
command.extend(args)
return subprocess.check_output(command).strip()
def _read(self):
"""Loads a list of results (sources tests and their status).
Provides internal data for `get_status()`.
"""
self.pkglist = defaultdict(dict)
if not os.path.exists(self._result_path):
return
with open(self._result_path) as f:
for line in f:
line = line.strip()
if line.startswith("Suite:") or line.startswith("Date:"):
continue
linebits = line.split()
if len(linebits) < 2:
print("W: Invalid line format: '%s', skipped" % line)
continue
(src, ver, status) = linebits[:3]
if not (src in self.pkglist and ver in self.pkglist[src]):
self.pkglist[src][ver] = status
def get_status(self, name, version):
"""Return test status for the given source name and version."""
return self.pkglist[name][version]
def request(self, packages):
"""Requests boottests for the given sources list ([(src, ver),])."""
request_path = self._request_path
if os.path.exists(request_path):
os.unlink(request_path)
with closing(tempfile.NamedTemporaryFile(mode="w")) as request_file:
for src, ver in packages:
if src in self.pkglist and ver in self.pkglist[src]:
continue
print("%s %s" % (src, ver), file=request_file)
# Update 'pkglist' so even if submit/collect is not called
# (dry-run), britney has some results.
self.pkglist[src][ver] = 'RUNNING'
request_file.flush()
self._run("request", "-O", request_path, request_file.name)
def submit(self):
"""Submits the current boottests requests for processing."""
self._run("submit", self._request_path)
def collect(self):
"""Collects boottests results and updates internal registry."""
self._run("collect", "-O", self._result_path)
self._read()
def needs_test(self, name, version):
"""Whether or not the given source and version should be tested.
Sources are only considered for boottesting if they produce binaries Sources are only considered for boottesting if they produce binaries
that are part of the phone image manifest. See `TouchManifest`. that are part of the phone image manifest. See `TouchManifest`.
""" """
# Discover all binaries for the 'excused' source. # Discover all binaries for the 'excused' source.
unstable_sources = self.britney.sources['unstable'] unstable_sources = self.britney.sources['unstable']
# Dismiss if source is not yet recognized (??). # Dismiss if source is not yet recognized (??).
if excuse.name not in unstable_sources: if name not in unstable_sources:
return None return False
# Binaries are a seq of "<binname>/<arch>" and, practically, boottest # Binaries are a seq of "<binname>/<arch>" and, practically, boottest
# is only concerned about armhf binaries mentioned in the phone # is only concerned about armhf binaries mentioned in the phone
# manifest. Anything else should be skipped. # manifest. Anything else should be skipped.
phone_binaries = [ phone_binaries = [
b for b in unstable_sources[excuse.name][BINARIES] b for b in unstable_sources[name][BINARIES]
if b.split('/')[1] in self.britney.options.boottest_arches.split() if b.split('/')[1] in self.britney.options.boottest_arches.split()
and b.split('/')[0] in self.phone_manifest and b.split('/')[0] in self.phone_manifest
] ]
return bool(phone_binaries)
# Process (request or update) a boottest attempt for the source
# if one or more of its binaries are part of the phone image.
if phone_binaries:
status = self.dispatcher.get_status(excuse.name, excuse.ver[1])
else:
status = 'SKIPPED'
return status

View File

@ -1908,6 +1908,7 @@ class Britney(object):
boottest = BootTest( boottest = BootTest(
self, self.options.distribution, self.options.series, self, self.options.distribution, self.options.series,
debug=boottest_debug) debug=boottest_debug)
boottest_excuses = []
for excuse in self.excuses: for excuse in self.excuses:
# Skip already invalid excuses. # Skip already invalid excuses.
if not excuse.run_boottest: if not excuse.run_boottest:
@ -1919,15 +1920,38 @@ class Britney(object):
"_" in excuse.name or "_" in excuse.name or
excuse.ver[1] == "-"): excuse.ver[1] == "-"):
continue continue
# Update valid excuses from the boottest context. # Allows hints to skip boottest attempts
status = boottest.update(excuse) hints = self.hints.search(
'force-skiptest', package=excuse.name)
forces = [x for x in hints
if same_source(excuse.ver[1], x.version)]
if forces:
excuse.addhtml(
"boottest skipped from hints by %s" % forces[0].user)
continue
# Only sources whitelisted in the boottest context should
# be tested (currently only sources building phone binaries).
if not boottest.needs_test(excuse.name, excuse.ver[1]):
label = BootTest.EXCUSE_LABELS.get('SKIPPED')
excuse.addhtml("Boottest result: %s" % (label))
continue
# Okay, aggregate required boottests requests.
boottest_excuses.append(excuse)
boottest.request([(e.name, e.ver[1]) for e in boottest_excuses])
# Dry-run avoids data exchange with external systems.
if not self.options.dry_run:
boottest.submit()
boottest.collect()
# Update excuses from the boottest context.
for excuse in boottest_excuses:
status = boottest.get_status(excuse.name, excuse.ver[1])
label = BootTest.EXCUSE_LABELS.get(status, 'UNKNOWN STATUS') label = BootTest.EXCUSE_LABELS.get(status, 'UNKNOWN STATUS')
excuse.addhtml("Boottest result: %s" % (label)) excuse.addhtml("Boottest result: %s" % (label))
# Allows hints to force boottest failures/attempts # Allows hints to force boottest failures/attempts
# to be ignored. # to be ignored.
hints = self.hints.search('force', package=excuse.name) hints = self.hints.search('force', package=excuse.name)
hints.extend( hints.extend(
self.hints.search('force-skiptest', package=excuse.name)) self.hints.search('force-badtest', package=excuse.name))
forces = [x for x in hints forces = [x for x in hints
if same_source(excuse.ver[1], x.version)] if same_source(excuse.ver[1], x.version)]
if forces: if forces:

View File

@ -20,7 +20,6 @@ sys.path.insert(0, PROJECT_DIR)
from tests import TestBase from tests import TestBase
from boottest import ( from boottest import (
BootTest, BootTest,
BootTestJenkinsJob,
TouchManifest, TouchManifest,
) )
@ -166,31 +165,45 @@ class TestBoottestEnd2End(TestBase):
with open(script_path, 'w') as f: with open(script_path, 'w') as f:
f.write('''#!%(py)s f.write('''#!%(py)s
import argparse import argparse
import os
import sys import sys
def submit(): template = """
print 'RUNNING' green 1.1~beta RUNNING green 1.1~beta
pyqt5-src 1.1~beta PASS pyqt5-src 1.1~beta
pyqt5-src 1.1 FAIL pyqt5-src 1.1
"""
def check(): def request():
if args.name != 'pyqt5-src': os.makedirs(os.path.dirname(args.output))
sys.exit(100) with open(args.output, 'w') as f:
if args.version == '1.1~beta': f.write(template)
print 'PASS'
else: def submit():
print 'FAIL' with open(args.input, 'w') as f:
f.write(template)
def collect():
with open(args.output, 'w') as f:
f.write(template)
p = argparse.ArgumentParser() p = argparse.ArgumentParser()
p.add_argument('-d') p.add_argument('-d')
p.add_argument('-s') p.add_argument('-s')
p.add_argument('-c')
sp = p.add_subparsers() sp = p.add_subparsers()
psubmit = sp.add_parser('submit') psubmit = sp.add_parser('submit')
psubmit.add_argument('name') psubmit.add_argument('input')
psubmit.add_argument('version')
psubmit.set_defaults(func=submit) psubmit.set_defaults(func=submit)
pcheck = sp.add_parser('check')
pcheck.add_argument('name') prequest = sp.add_parser('request')
pcheck.add_argument('version') prequest.add_argument('-O', dest='output')
pcheck.set_defaults(func=check) prequest.add_argument('input')
prequest.set_defaults(func=request)
pcollect = sp.add_parser('collect')
pcollect.add_argument('-O', dest='output')
pcollect.set_defaults(func=collect)
args = p.parse_args() args = p.parse_args()
args.func() args.func()
@ -268,9 +281,24 @@ args.func()
with open(hints_path, 'w') as fd: with open(hints_path, 'w') as fd:
fd.write(content) fd.write(content)
def test_skipped_by_hints(self):
# `Britney` allows boottests to be skipped by hinting the
# corresponding source with 'force-skiptest'. The boottest
# attempt will not be requested.
context = [
('pyqt5', {'Source': 'pyqt5-src', 'Version': '1.1',
'Architecture': 'all'}),
]
self.create_hint('cjwatson', 'force-skiptest pyqt5-src/1.1')
self.do_test(
context,
[r'\bpyqt5-src\b.*\(- to .*>1.1<',
'<li>boottest skipped from hints by cjwatson',
'<li>Valid candidate'])
def test_fail_but_forced_by_hints(self): def test_fail_but_forced_by_hints(self):
# `Britney` allows boottests results to be ignored by hinting the # `Britney` allows boottests results to be ignored by hinting the
# corresponding source with 'force' or 'force-skiptest'. The boottest # corresponding source with 'force' or 'force-badtest'. The boottest
# attempt will still be requested and its results would be considered # attempt will still be requested and its results would be considered
# for other non-forced sources. # for other non-forced sources.
context = [ context = [
@ -287,13 +315,13 @@ args.func()
'but forced by cjwatson', 'but forced by cjwatson',
'<li>Valid candidate']) '<li>Valid candidate'])
def test_fail_but_skipped_by_hints(self): def test_fail_but_ignored_by_hints(self):
# See `test_fail_but_forced_by_hints`. # See `test_fail_but_forced_by_hints`.
context = [ context = [
('green', {'Source': 'green', 'Version': '1.1~beta', ('green', {'Source': 'green', 'Version': '1.1~beta',
'Architecture': 'armhf', 'Depends': 'libc6 (>= 0.9)'}), 'Architecture': 'armhf', 'Depends': 'libc6 (>= 0.9)'}),
] ]
self.create_hint('cjwatson', 'force-skiptest green/1.1~beta') self.create_hint('cjwatson', 'force-badtest green/1.1~beta')
self.do_test( self.do_test(
context, context,
[r'\bgreen\b.*>1</a> to .*>1.1~beta<', [r'\bgreen\b.*>1</a> to .*>1.1~beta<',