aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMarkus Lehtonen <markus.lehtonen@linux.intel.com>2016-11-02 16:41:53 +0200
committerMarkus Lehtonen <markus.lehtonen@linux.intel.com>2017-03-31 15:34:03 +0300
commit36786114b2b71b14569f6af2a08cb96299ee0b80 (patch)
tree556349973e1e6ec1e5b817a486694a8570fb9196
parent49772e1a1f291d1cacce27b381009dbb441c483e (diff)
downloadopenembedded-core-contrib-36786114b2b71b14569f6af2a08cb96299ee0b80.tar.gz
Add scripts/contrib/build-perf-git-import.py
Script for converting old archived build perf results in new JSON-based format and committing them in a Git repository. Signed-off-by: Markus Lehtonen <markus.lehtonen@linux.intel.com>
-rwxr-xr-xscripts/contrib/build-perf-git-import.py841
1 files changed, 841 insertions, 0 deletions
diff --git a/scripts/contrib/build-perf-git-import.py b/scripts/contrib/build-perf-git-import.py
new file mode 100755
index 0000000000..a630252e92
--- /dev/null
+++ b/scripts/contrib/build-perf-git-import.py
@@ -0,0 +1,841 @@
+#!/usr/bin/python3
+#
+# Copyright (c) 2016, Intel Corporation.
+#
+# This program is free software; you can redistribute it and/or modify it
+# under the terms and conditions of the GNU General Public License,
+# version 2, as published by the Free Software Foundation.
+#
+# This program is distributed in the hope 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.
+#
+import argparse
+import csv
+import json
+import locale
+import logging
+import os
+import re
+import shutil
+import sys
+import tempfile
+import time
+from collections import defaultdict, OrderedDict
+from datetime import datetime, timedelta, tzinfo
+from glob import glob
+from subprocess import check_output, CalledProcessError
+
+# Import oe libs
+scripts_path = os.path.dirname(os.path.realpath(__file__))
+sys.path.append(os.path.join(scripts_path, '../lib'))
+import scriptpath
+scriptpath.add_oe_lib_path()
+
+from oeqa.utils.git import GitRepo, GitError
+
+
+# Setup logging
+logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s',
+ stream=sys.stdout)
+log = logging.getLogger()
+
+
+class CommitError(Exception):
+ """Script's internal error handling"""
+ pass
+
+class ConversionError(Exception):
+ """Error in converting results"""
+ pass
+
+
+class ResultsJsonEncoder(json.JSONEncoder):
+ """Extended encoder for build perf test results"""
+ unix_epoch = datetime.utcfromtimestamp(0)
+
+ def default(self, obj):
+ """Encoder for our types"""
+ if isinstance(obj, datetime):
+ # NOTE: we assume that all timestamps are in UTC time
+ return (obj - self.unix_epoch).total_seconds()
+ if isinstance(obj, timedelta):
+ return obj.total_seconds()
+ return json.JSONEncoder.default(self, obj)
+
+
+class TimeZone(tzinfo):
+ """Simple fixed-offset tzinfo"""
+ def __init__(self, seconds, name):
+ self._offset = timedelta(seconds=seconds)
+ self._name = name
+
+ def utcoffset(self, dt):
+ return self._offset
+
+ def tzname(self, dt):
+ return self._name
+
+ def dst(self, dt):
+ return None
+
+TIMEZONES = {'UTC': TimeZone(0, 'UTC'),
+ 'EDT': TimeZone(-18000, 'EDT'),
+ 'EST': TimeZone(-14400, 'EST'),
+ 'ET': TimeZone(-14400, 'ET'),
+ 'EET': TimeZone(7200, 'EET'),
+ 'EEST': TimeZone(10800, 'EEST')}
+
+
+class OutputLogRecord(object):
+ """Class representing one row in the log"""
+ def __init__(self, timestamp, msg):
+ self.time = timestamp
+ self.msg = msg
+
+ def __str__(self):
+ return "[{}] {}".format(self.time.isoformat(), self.msg)
+
+class OutputLog(object):
+ """Class representing the 'old style' main output log"""
+ def __init__(self, filepath):
+ self.new_fmt = False
+ self.records = []
+ self._start = None
+ self._head = None
+ self._end = None
+ self._read(filepath)
+
+ @staticmethod
+ def _parse_line_old_default(line):
+ """Parse "old" style line in C locale"""
+ split = line.split(None, 6)
+ try:
+ timestamp = datetime.strptime(' '.join(split[0:4] + split[5:6]),
+ '%a %b %d %H:%M:%S %Y:')
+ except ValueError:
+ raise ConversionError("Unable to parse RO timestamp")
+ timezone = TIMEZONES[split[4]]
+ return timestamp, timezone, split[6].strip()
+
+ @staticmethod
+ def _parse_line_old_ro(line):
+ """Parse "old" style line in RO locale"""
+ split = line.split(None, 6)
+ try:
+ timestamp = datetime.strptime(' '.join(split[0:5]), '%A %d %B %Y, %H:%M:%S')
+ except ValueError:
+ raise ConversionError("Unable to parse RO timestamp")
+ hhmm = split[5]
+ offset = int(hhmm[0] + '1') * (int(hhmm[1:3])*3600 + int(hhmm[3:5])*60)
+ timezone = TimeZone(offset, hhmm)
+ return timestamp, timezone, split[6].strip()
+
+ def _read(self, filepath):
+ """Read 'old style' output.log"""
+
+ orig_locale = locale.getlocale()
+ fobj = open(filepath)
+ try:
+ # Check if the log is from the old shell-based or new Python script
+ if fobj.read(1) == '[':
+ self.new_fmt = True
+ else:
+ # Determine timestamp format
+ fobj.seek(0)
+ line = fobj.readline()
+ try:
+ locale.setlocale(locale.LC_ALL, 'C')
+ self._parse_line_old_default(line)
+ parse_line = self._parse_line_old_default
+ except ConversionError:
+ parse_line = None
+ if not parse_line:
+ try:
+ locale.setlocale(locale.LC_ALL, 'ro_RO.UTF-8')
+ self._parse_line_old_ro(line)
+ parse_line = self._parse_line_old_ro
+ except ConversionError:
+ raise ConversionError("Unable to parse output.log timestamps")
+ fobj.seek(0)
+
+ for line in fobj.readlines():
+ if self.new_fmt:
+ split = line.split(']', 1)
+ try:
+ timestamp = datetime.strptime(split[0],
+ '[%Y-%m-%d %H:%M:%S,%f')
+ except ValueError:
+ # Seems to be multi-line record, append to last msg
+ self.records[-1].msg += '\n' + line.rstrip()
+ else:
+ self.records.append(OutputLogRecord(timestamp,
+ split[1].strip()))
+ else:
+ timestamp, timezone, message = parse_line(line)
+ # Convert timestamps to UTC time
+ timestamp = timestamp - timezone.utcoffset(timestamp)
+ #timestamp = timestamp.replace(tzinfo=TIMEZONES['UTC'])
+ timestamp = timestamp.replace(tzinfo=None)
+ self.records.append(OutputLogRecord(timestamp, message))
+ finally:
+ fobj.close()
+ locale.setlocale(locale.LC_ALL, orig_locale)
+
+ def _find(self, regex, start=0, end=None):
+ """Find record matching regex"""
+ if end is None:
+ end = len(self.records)
+ re_c = re.compile(regex)
+ for i in range(start, end):
+ if re_c.match(self.records[i].msg):
+ return i
+ raise ConversionError("No match for regex '{}' in output.log between "
+ "lines {} and {}".format(regex, start+1, end+1))
+
+ def set_start(self, regex):
+ """Set test start point in log"""
+ i = self._find(regex)
+ self._start = self._head = i
+ self._end = None
+ return self.records[i]
+
+ def set_end(self, regex):
+ """Set test start point in log"""
+ i = self._find(regex, start=self._start)
+ self._end = i
+ return self.records[i]
+
+ def find(self, regex):
+ """Find record matching regex between head and end"""
+ i = self._find(regex, self._head, self._end)
+ self._head = i + 1
+ return self.records[i]
+
+ def get_git_rev_info(self):
+ """Helper for getting target branch name"""
+ if self.new_fmt:
+ rev_re = r'INFO: Using Git branch:revision (\S+):(\S+)'
+ else:
+ rev_re = r'Running on (\S+):(\S+)'
+ branch, rev = re.match(rev_re, self.records[0].msg).groups()
+ # Map all detached checkouts to '(nobranch)'
+ if branch.startswith('(detachedfrom') or branch.startswith('(HEADdetachedat'):
+ branch = '(nobranch)'
+ return branch, rev
+
+ def get_test_descr(self):
+ """Helper for getting test description from 'start' row"""
+ return self.records[self._start].msg.split(':', 1)[1].strip()
+
+ def get_sysres_meas_start_time(self):
+ """Helper for getting 'legend' for next sysres measurement"""
+ record = self.find("Timing: ")
+ return record.time
+
+ def get_sysres_meas_time(self):
+ """Helper for getting wall clock time of sysres measurement"""
+ msg = self.find("TIME: ").msg
+ return msg.split(':', 1)[1].strip()
+
+
+def time_log_to_json(time_log):
+ """Convert time log to json results"""
+ def str_time_to_timedelta(strtime):
+ """Convert time strig from the time utility to timedelta"""
+ split = strtime.split(':')
+ hours = int(split[0]) if len(split) > 2 else 0
+ mins = int(split[-2]) if len(split) > 1 else 0
+
+ split = split[-1].split('.')
+ secs = int(split[0])
+ frac = split[1] if len(split) > 1 else '0'
+ microsecs = int(float('0.' + frac) * pow(10, 6))
+
+ return timedelta(0, hours*3600 + mins*60 + secs, microsecs)
+
+ res = {'rusage': {}}
+ log.debug("Parsing time log: %s", time_log)
+ exit_status = None
+ with open(time_log) as fobj:
+ for line in fobj.readlines():
+ key, val = line.strip().rsplit(' ', 1)
+ val = val.strip()
+ key = key.rstrip(':')
+ # Parse all fields
+ if key == 'Exit status':
+ exit_status = int(val)
+ elif key.startswith('Elapsed (wall clock)'):
+ res['elapsed_time'] = str_time_to_timedelta(val)
+ elif key == 'User time (seconds)':
+ res['rusage']['ru_utime'] = str_time_to_timedelta(val)
+ elif key == 'System time (seconds)':
+ res['rusage']['ru_stime'] = str_time_to_timedelta(val)
+ elif key == 'Maximum resident set size (kbytes)':
+ res['rusage']['ru_maxrss'] = int(val)
+ elif key == 'Major (requiring I/O) page faults':
+ res['rusage']['ru_majflt'] = int(val)
+ elif key == 'Minor (reclaiming a frame) page faults':
+ res['rusage']['ru_minflt'] = int(val)
+ elif key == 'Voluntary context switches':
+ res['rusage']['ru_nvcsw'] = int(val)
+ elif key == 'Involuntary context switches':
+ res['rusage']['ru_nivcsw'] = int(val)
+ elif key == 'File system inputs':
+ res['rusage']['ru_inblock'] = int(val)
+ elif key == 'File system outputs':
+ res['rusage']['ru_oublock'] = int(val)
+ if exit_status is None:
+ raise ConversionError("Truncated log file '{}'".format(
+ os.path.basename(time_log)))
+ return exit_status, res
+
+
+def convert_buildstats(indir, outfile):
+ """Convert buildstats into JSON format"""
+ def split_nevr(nevr):
+ """Split name and version information from recipe "nevr" string"""
+ n_e_v, revision = nevr.rsplit('-', 1)
+ match = re.match(r'^(?P<name>\S+)-((?P<epoch>[0-9]{1,5})_)?(?P<version>[0-9]\S*)$',
+ n_e_v)
+ if not match:
+ # If we're not able to parse a version starting with a number, just
+ # take the part after last dash
+ match = re.match(r'^(?P<name>\S+)-((?P<epoch>[0-9]{1,5})_)?(?P<version>[^-]+)$',
+ n_e_v)
+ name = match.group('name')
+ version = match.group('version')
+ epoch = match.group('epoch')
+ return name, epoch, version, revision
+
+ def bs_to_json(filename):
+ """Convert (task) buildstats file into json format"""
+ bs_json = {'iostat': {},
+ 'rusage': {},
+ 'child_rusage': {}}
+ end_time = None
+ with open(filename) as fobj:
+ for line in fobj.readlines():
+ key, val = line.split(':', 1)
+ val = val.strip()
+ if key == 'Started':
+ start_time = datetime.utcfromtimestamp(float(val))
+ bs_json['start_time'] = start_time
+ elif key == 'Ended':
+ end_time = datetime.utcfromtimestamp(float(val))
+ elif key.startswith('IO '):
+ split = key.split()
+ bs_json['iostat'][split[1]] = int(val)
+ elif key.find('rusage') >= 0:
+ split = key.split()
+ ru_key = split[-1]
+ if ru_key in ('ru_stime', 'ru_utime'):
+ val = float(val)
+ else:
+ val = int(val)
+ ru_type = 'rusage' if split[0] == 'rusage' else \
+ 'child_rusage'
+ bs_json[ru_type][ru_key] = val
+ elif key == 'Status':
+ bs_json['status'] = val
+ if end_time is None:
+ return None
+ bs_json['elapsed_time'] = end_time - start_time
+ return bs_json
+
+ log.debug('Converting buildstats %s -> %s', indir, outfile)
+ buildstats = []
+ for fname in os.listdir(indir):
+ recipe_dir = os.path.join(indir, fname)
+ if not os.path.isdir(recipe_dir):
+ continue
+ name, epoch, version, revision = split_nevr(fname)
+ recipe_bs = {'name': name,
+ 'epoch': epoch,
+ 'version': version,
+ 'revision': revision,
+ 'tasks': {}}
+ for task in os.listdir(recipe_dir):
+ task_bs = bs_to_json(os.path.join(recipe_dir, task))
+ if not task_bs:
+ raise ConversionError("Incomplete buildstats in {}:{}".format(
+ fname, task))
+ recipe_bs['tasks'][task] = task_bs
+ buildstats.append(recipe_bs)
+
+ # Write buildstats into json file
+ with open(outfile, 'w') as fobj:
+ json.dump(buildstats, fobj, indent=4, sort_keys=True,
+ cls=ResultsJsonEncoder)
+
+
+def convert_results(globalres, poky_repo, results_dir):
+ """Convert 'old style' to new JSON based format.
+
+ Conversion is a destructive operation, converted files being deleted.
+ """
+ if not globalres:
+ raise CommitError("Need non-empty globalres for conversion")
+
+ times = []
+ test_params = OrderedDict([
+ ('test1', {'log_start_re': "Running Test 1, part 1/3",
+ 'log_end_re': "Buildstats are saved in.*-test1$",
+ 'sysres_meas_params': [(1, 'build', 'bitbake core-image-sato')],
+ }),
+ ('test12', {'log_start_re': "Running Test 1, part 2/3",
+ 'log_end_re': "More stats can be found in.*results.log.2",
+ 'sysres_meas_params': [(2, 'build', 'bitbake virtual/kernel')]
+ }),
+ ('test13', {'log_start_re': "Running Test 1, part 3/3",
+ 'log_end_re': "Buildstats are saved in.*-test13$",
+ 'sysres_meas_params': [(3, 'build', 'bitbake core-image-sato')],
+ }),
+ ('test2', {'log_start_re': "Running Test 2",
+ 'log_end_re': "More stats can be found in.*results.log.4",
+ 'sysres_meas_params': [(4, 'do_rootfs', 'bitbake do_rootfs')],
+ }),
+ ('test3', {'log_start_re': "Running Test 3",
+ 'log_end_re': "More stats can be found in.*results.log.7",
+ 'sysres_meas_params': [(5, 'parse_1', 'bitbake -p (no caches)'),
+ (6, 'parse_2', 'bitbake -p (no tmp/cache)'),
+ (7, 'parse_3', 'bitbake -p (cached)')]
+ }),
+ ('test4', {'log_start_re': "Running Test 4",
+ 'log_end_re': "All done, cleaning up",
+ 'sysres_meas_params': [(8, 'deploy', 'eSDK deploy')],
+ })
+ ])
+ test_du_params = {
+ 'test1': [(0, 'tmpdir', 'tmpdir', 1)],
+ 'test13': [(1, 'tmpdir', 'tmpdir', 1)],
+ 'test4': [(2, 'installer_bin', 'eSDK installer', 0),
+ (3, 'deploy_dir', 'deploy dir', 2)]
+ }
+
+ def _import_test(topdir, name, output_log, log_start_re, log_end_re,
+ sysres_meas_params):
+ """Import test results from one results.log.X into JSON format"""
+ test_res = {'name': name,
+ 'measurements': [],
+ 'status': 'SUCCESS'}
+ start_time = output_log.set_start(log_start_re).time
+ end_time = output_log.set_end(log_end_re).time
+ test_res['description'] = output_log.get_test_descr()
+ test_res['start_time'] = start_time
+ test_res['elapsed_time'] = end_time - start_time
+ for i, meas_name, meas_legend in sysres_meas_params:
+ measurement = {'type': 'sysres'}
+ start_time = output_log.get_sysres_meas_start_time()
+ measurement['name'] = meas_name
+ measurement['legend'] = meas_legend
+
+ time_log_fn = os.path.join(topdir, 'results.log.{}'.format(i))
+ if not os.path.isfile(time_log_fn):
+ raise ConversionError("results.log.{} not found".format(i))
+ exit_status, measurement['values'] = time_log_to_json(time_log_fn)
+ measurement['values']['start_time'] = start_time
+
+ # Track wall clock times also in the separate helper array
+ if exit_status == 0:
+ test_res['measurements'].append(measurement)
+ times.append(output_log.get_sysres_meas_time())
+ else:
+ log.debug("Detected failed test %s in %s", name, topdir)
+ times.append('0')
+ test_res['status'] = 'ERROR'
+
+ # Remove old results.log
+ os.unlink(time_log_fn)
+ return test_res
+
+
+ # Read main logfile
+ out_log = OutputLog(os.path.join(results_dir, 'output.log'))
+ if out_log.new_fmt:
+ raise ConversionError("New output.log format detected, refusing to "
+ "convert results")
+
+ tests = OrderedDict()
+
+ # Read timing results of tests
+ for test, params in test_params.items():
+ # Special handling for test4
+ if (test == 'test4' and
+ not os.path.exists(os.path.join(results_dir, 'results.log.8'))):
+ continue
+ try:
+ tests[test] = _import_test(results_dir, test, out_log, **params)
+ except ConversionError as err:
+ raise ConversionError("Presumably incomplete test run. Unable to "
+ "parse '{}' from output.log: {}".format(test, err))
+
+ # Try to find the corresponding entry from globalres
+ git_rev = out_log.records[0].msg.split()[-1].split(':')[1]
+ gr_data = None
+ for i in range(len(globalres[git_rev])):
+ if globalres[git_rev][i]['times'] == times:
+ gr_data = globalres[git_rev].pop(i)
+ break
+ if gr_data is None:
+ raise CommitError("Unable to find row in globalres.log corresponding "
+ "{}".format(os.path.basename(results_dir)))
+
+ # Add disk usage measurements
+ for test, params in test_du_params.items():
+ for du_params in params:
+ sz_index = du_params[0]
+ if len(gr_data['sizes']) > sz_index:
+ du_meas = {'type': 'diskusage',
+ 'name': du_params[1],
+ 'legend': du_params[2],
+ 'values': {'size': gr_data['sizes'][sz_index]}}
+ tests[test]['measurements'].insert(du_params[3], du_meas)
+
+ # Convert buildstats
+ for path in glob(results_dir + '/buildstats-*'):
+ testname = os.path.basename(path).split('-', 1)[1]
+ if not testname in ('test1', 'test13'):
+ raise CommitError("Unkown buildstats: {}".format(
+ os.path.basename(path)))
+
+ # No measurements indicates failed test -> don't import buildstats
+ if tests[testname]['measurements']:
+ bs_relpath = os.path.join(testname, 'buildstats.json')
+ os.mkdir(os.path.join(results_dir, testname))
+ try:
+ convert_buildstats(path, os.path.join(results_dir, bs_relpath))
+ except ConversionError as err:
+ log.warn("Buildstats for %s not imported: %s", testname, err)
+ else:
+ # We know that buildstats have only been saved for the first
+ # measurement of the two tests.
+ tests[testname]['measurements'][0]['values']['buildstats_file'] = \
+ bs_relpath
+ # Remove old buildstats directory
+ shutil.rmtree(path)
+
+ # Create final results dict
+ cmd = ['rev-list', '--count', git_rev, '--']
+ commit_cnt = poky_repo.run_cmd(cmd).splitlines()[0]
+ results = OrderedDict((('tester_host', gr_data['host']),
+ ('start_time', out_log.records[0].time),
+ ('elapsed_time', (out_log.records[-1].time -
+ out_log.records[0].time)),
+ ('git_branch', gr_data['branch']),
+ ('git_commit', git_rev),
+ ('git_commit_count', commit_cnt),
+ ('product', 'poky'),
+ ('tests', tests)))
+
+ # Write results.json
+ with open(os.path.join(results_dir, 'results.json'), 'w') as fobj:
+ json.dump(results, fobj, indent=4, cls=ResultsJsonEncoder)
+
+
+def git_commit_dir(data_repo, src_dir, branch, msg, tag=None, tag_msg="",
+ timestamp=None):
+ """Commit the content of a directory to a branch"""
+ env = {'GIT_WORK_TREE': os.path.abspath(src_dir)}
+ if timestamp:
+ env['GIT_COMMITTER_DATE'] = timestamp
+ env['GIT_AUTHOR_DATE'] = timestamp
+
+ log.debug('Committing %s to git branch %s', src_dir, branch)
+ data_repo.run_cmd(['symbolic-ref', 'HEAD', 'refs/heads/' + branch], env)
+ data_repo.run_cmd(['add', '.'], env)
+ data_repo.run_cmd(['commit', '-m', msg], env)
+
+ log.debug('Tagging %s', tag)
+ data_repo.run_cmd(['tag', '-a', '-m', tag_msg, tag, 'HEAD'], env)
+
+
+def import_testrun(archive, data_repo, poky_repo, branch_fmt, tag_fmt,
+ convert=False, globalres=None):
+ """Import one testrun into Git"""
+ archive = os.path.abspath(archive)
+ archive_fn = os.path.basename(archive)
+
+ fields = archive_fn.split('-')
+ fn_fields = {'timestamp': fields[-1].split('.')[0],
+ 'rev': fields[-2],
+ 'host': None}
+ if os.path.isfile(archive):
+ if len(fields) != 4:
+ log.warn('Invalid archive %s, skipping...', archive)
+ return False, "Invalid filename"
+ fn_fields['host'] = fields[0]
+ elif os.path.isdir(archive):
+ fn_fields['host'] = os.environ.get('BUILD_PERF_GIT_IMPORT_HOST')
+ if not fn_fields['host'] and not convert:
+ raise CommitError("You need to define tester host in "
+ "BUILD_PERF_GIT_IMPORT_HOST env var "
+ "when raw importing directories")
+ else:
+ raise CommitError("{} does not exist".format(archive))
+
+ # Check that the commit is valid
+ if poky_repo.rev_parse(fn_fields['rev']) is None:
+ log.warn("Commit %s not found in Poky Git, skipping...", fn_fields['rev'])
+ return False, "Commit {} not found in Poky Git".format(fn_fields['rev'])
+
+ tmpdir = os.path.abspath(tempfile.mkdtemp(dir='.'))
+ try:
+ # Unpack tarball
+ if os.path.isfile(archive):
+ log.info('Unpacking %s', archive)
+ # Unpack in two stages in order to skip (possible) build data
+ check_output(['tar', '-xf', archive, '-C', tmpdir,
+ '--exclude', 'build/*'])
+ try:
+ check_output(['tar', '-xf', archive, '-C', tmpdir,
+ '--wildcards', '*/build/conf'])
+ except CalledProcessError:
+ log.warn("Archive doesn't contain build/conf")
+ if len(os.listdir(tmpdir)) > 1:
+ log.warn("%s contains multiple subdirs!", archive)
+ results_dir = '{}-{}-{}'.format('results', fn_fields['rev'],
+ fn_fields['timestamp'])
+ results_dir = os.path.join(tmpdir, results_dir)
+ if not os.path.exists(results_dir):
+ log.warn("%s does not contain '%s/', skipping...",
+ archive, os.path.basename(results_dir))
+ return False, "Invalid content"
+ else:
+ # Make a safe copy, filtering out possible build data
+ results_dir = os.path.join(tmpdir, archive_fn)
+ log.debug('Copying %s', archive)
+ os.mkdir(results_dir)
+ for f in glob(archive + '/*'):
+ tgt_path = os.path.join(results_dir, os.path.basename(f))
+ if os.path.isfile(f):
+ # Regular files
+ shutil.copy2(f, tgt_path)
+ elif os.path.basename(f) == 'build':
+ # From build dir we only want to conf
+ os.mkdir(tgt_path)
+ shutil.copytree(os.path.join(f, 'conf'),
+ os.path.join(tgt_path, 'conf'))
+ else:
+ # Other directories are copied as is
+ shutil.copytree(f, tgt_path)
+
+ # Remove redundant buildstats subdir(s)
+ for buildstat_dir in glob(results_dir + '/buildstats-*'):
+ buildstat_tmpdir = buildstat_dir + '.tmp'
+ shutil.move(buildstat_dir, buildstat_tmpdir)
+ builds = sorted(glob(buildstat_tmpdir + '/*'))
+ buildstat_subdir = builds[-1]
+ if len(builds) != 1:
+ log.warn('%s in %s contains multiple builds, using only %s',
+ os.path.basename(buildstat_dir), archive,
+ os.path.basename(buildstat_subdir))
+
+ # Handle the formerly used two-level buildstat directory structure
+ # (where build target formed the first level)
+ builds = os.listdir(buildstat_subdir)
+ if re.match('^20[0-9]{10,12}$', builds[-1]):
+ if len(builds) != 1:
+ log.warn('%s in %s contains multiple builds, using only %s',
+ os.path.join(os.path.basename(buildstat_dir), buildstat_subdir), archive,
+ os.path.basename(buildstat_subdir))
+ buildstat_subdir = os.path.join(buildstat_subdir, builds[-1])
+
+ shutil.move(buildstat_subdir, buildstat_dir)
+ shutil.rmtree(buildstat_tmpdir)
+
+ # Check if the file hierarchy is 'old style'
+ converted = False
+ if os.path.exists(os.path.join(results_dir, 'output.log')) and convert:
+ log.info("Converting test results from %s", archive_fn)
+ try:
+ convert_results(globalres, poky_repo, results_dir)
+ converted = True
+ except ConversionError as err:
+ log.warn("Skipping %s, conversion failed: %s", archive_fn, err)
+ return False, str(err)
+ else:
+ log.info('Importing test results from %s', archive)
+
+ # Get info for git branch and tag names
+ fmt_fields = {'host': fn_fields['host'],
+ 'product': 'poky',
+ 'branch': None,
+ 'rev': None,
+ 'machine': 'qemux86',
+ 'rev_cnt': None}
+
+ if os.path.exists(os.path.join(results_dir, 'results.json')):
+ with open(os.path.join(results_dir, 'results.json')) as fobj:
+ data = json.load(fobj)
+ fmt_fields['host'] = data['tester_host']
+ fmt_fields['branch'] = data['git_branch']
+ fmt_fields['rev'] = data['git_commit']
+ fmt_fields['rev_cnt'] = data['git_commit_count']
+ else:
+ out_log = OutputLog(os.path.join(results_dir, 'output.log'))
+ fmt_fields['branch'], fmt_fields['rev'] = \
+ out_log.get_git_rev_info()
+ cmd = ['rev-list', '--count', fmt_fields['rev'], '--']
+ fmt_fields['rev_cnt'] = poky_repo.run_cmd(cmd).splitlines()[0]
+
+ # Compose git branch and tag name
+ git_branch = branch_fmt % fmt_fields
+ git_tag = tag_fmt % fmt_fields
+ tag_cnt = len(data_repo.run_cmd(['tag', '-l', git_tag + '/*']).splitlines())
+ git_tag += '/%d' % tag_cnt
+
+ # Get timestamp for commit and tag
+ timestamp = datetime.strptime(fn_fields['timestamp'], '%Y%m%d%H%M%S')
+ git_timestamp = "%d" % time.mktime(timestamp.timetuple())
+
+ # Commit to git
+ commit_msg = "Results of {}:{} on {}\n\n".format(
+ fmt_fields['branch'], fmt_fields['rev'], fmt_fields['host'])
+
+ if os.path.isdir(archive):
+ archive_fn += '/'
+ if converted:
+ commit_msg += "(converted from {})".format(archive_fn)
+ else:
+ commit_msg += "(imported from {})".format(archive_fn)
+
+ tag_msg = "Test run #{} of {}:{} on {}\n".format(
+ tag_cnt, fmt_fields['branch'], fmt_fields['rev'],
+ fmt_fields['host'])
+ git_commit_dir(data_repo, results_dir, git_branch, commit_msg,
+ git_tag, tag_msg, git_timestamp)
+ finally:
+ shutil.rmtree(tmpdir)
+ return True, "OK"
+
+
+def read_globalres(path):
+ """Read globalres file"""
+ # Read globalres.log
+ globalres = defaultdict(list)
+
+ log.info("Reading '%s'", path)
+ with open(path) as fobj:
+ reader = csv.reader(fobj)
+ for row in reader:
+ # Skip manually added comments
+ if row[0].startswith('#'):
+ continue
+ res = {'host': row[0]}
+ res['branch'], res['revision'] = row[1].split(':')
+ if len(row) == 12:
+ res['times'] = row[3:10]
+ res['sizes'] = row[10:]
+ elif len(row) == 14 or len(row) == 15:
+ res['times'] = row[3:11]
+ res['sizes'] = row[11:]
+ else:
+ log.warning("globalres: ignoring invalid row that contains "
+ "%s values: %s", len(row), row)
+ globalres[res['revision']].append(res)
+ return globalres
+
+
+def get_archive_timestamp(filename):
+ """Helper for sorting result tarballs"""
+ split = os.path.basename(filename).rsplit('-', 2)
+ if len(split) == 4:
+ return split[3].split('.')[0]
+ else:
+ return split[2]
+
+
+def parse_args(argv=None):
+ """Parse command line arguments"""
+ parser = argparse.ArgumentParser()
+
+ parser.add_argument('-d', '--debug', action='store_true',
+ help='Debug level logging')
+ parser.add_argument('-B', '--git-branch-name',
+ default='%(host)s/%(branch)s/%(machine)s',
+ help="Branch name to use")
+ parser.add_argument('-T', '--git-tag-name',
+ default='%(host)s/%(branch)s/%(machine)s/%(rev_cnt)s-g%(rev)s',
+ help="Tag 'basename' to use, tag number will be "
+ "automatically appended")
+ parser.add_argument('-G', '--globalres', type=os.path.abspath,
+ help="Globalres log file, used in conversion")
+ parser.add_argument('-c', '--convert', action='store_true',
+ help="Convert results to new JSON-based format, "
+ "requires -G option to be defined")
+ parser.add_argument('-P', '--poky-git', type=os.path.abspath, required=True,
+ help="Path to poky clone")
+ parser.add_argument('-g', '--git-dir', type=os.path.abspath, required=True,
+ help="Git repository where to commit results")
+ parser.add_argument('archive', nargs="+", type=os.path.abspath,
+ help="Results archive")
+ args = parser.parse_args()
+ if args.convert and not args.globalres:
+ parser.error("-c/--convert requires -G/--globalres to be defined")
+
+ return args
+
+
+def main(argv=None):
+ """Script entry point"""
+ args = parse_args(argv)
+ if args.debug:
+ log.setLevel(logging.DEBUG)
+
+ ret = 1
+ try:
+ # Check archives to be imported
+ for archive in args.archive:
+ if not os.path.exists(archive):
+ raise CommitError("File does not exist: {}".format(archive))
+
+ # Check Poky repo
+ poky_repo = GitRepo(args.poky_git, is_topdir=True)
+
+ # Check results repository
+ if not os.path.exists(args.git_dir):
+ log.info('Creating Git repository %s', args.git_dir)
+ os.mkdir(args.git_dir)
+ data_repo = GitRepo.init(args.git_dir)
+ else:
+ data_repo = GitRepo(args.git_dir, is_topdir=True)
+
+ # Import test results data
+ if args.globalres:
+ globalres = read_globalres(args.globalres)
+ else:
+ globalres = None
+
+ # Import archived results
+ imported = []
+ skipped = []
+ for archive in sorted(args.archive, key=get_archive_timestamp):
+ result = import_testrun(archive, data_repo, poky_repo,
+ args.git_branch_name, args.git_tag_name,
+ args.convert, globalres)
+ if result[0]:
+ imported.append(result[1])
+ else:
+ skipped.append((archive, result[1]))
+
+ log.debug('Resetting git worktree')
+ data_repo.run_cmd(['reset', '--hard', 'HEAD', '--'])
+ data_repo.run_cmd(['clean', '-fd'])
+
+ print("\nSuccessfully imported {} archived results".format(len(imported)))
+ if skipped:
+ print("Failed to import {} result archives:".format(len(skipped)))
+ for archive, reason in skipped:
+ print(" {}: {}".format(archive, reason))
+
+ ret = 0
+ except CommitError as err:
+ if len(str(err)) > 0:
+ log.error(str(err))
+
+ return ret
+
+if __name__ == '__main__':
+ sys.exit(main())