yocto-autobuilder-helper/scripts/utils.py
Alexis Lothoré 44650c9681 scripts/send_qa_email: re-clarify base and target revisions
There are some inversions in words used to describe elements of comparison
for regression reporting: the main function of send_qa_email starts using
"base" to talk about the target revision and "compare" to talk about the
reference against which it is compared. Then later in the script, the
"base" is used as "base of comparison"/reference revision, while the
"target" branch/revision appears. This becomes quite confusing when we need
to update the script

Re-align wording to avoid confusion:
- always use "target" to talk about current branch/revision of interest
  (the newest)
- always use "base" to talk about the reference branch/revision  (the
  oldest), against which we want to compare the target revision

Signed-off-by: Alexis Lothoré <alexis.lothore@bootlin.com>
Signed-off-by: Richard Purdie <richard.purdie@linuxfoundation.org>
2023-10-04 23:39:50 +01:00

536 lines
19 KiB
Python

#!/usr/bin/env python3
#
# Copyright Linux Foundation, Richard Purdie
#
# SPDX-License-Identifier: GPL-2.0-only
#
import subprocess
import copy
import os
import json
import errno
import time
import codecs
import sys
import re
import argparse
import fnmatch
import glob
import fcntl
def is_a_main_branch(reponame, branchname):
"""
Checks if target repo/branch combo represent a main branch. This
includes master and release branches in poky, while excluding "next"
branches
"""
return reponame == "poky" and not branchname.endswith("-next")
#
# Check if config contains all the listed params
#
def contains(params, config):
for p in params:
if p not in config:
return False
return True
# Check if a config boolean is set
def configtrue(name, config):
if name in config and config[name]:
return True
return False
# Handle variable expansion of return values, variables are of the form ${XXX}
# need to handle expansion in list and dicts
__expand_re__ = re.compile(r"\${[^{}@\n\t :]+}")
def expandresult(entry, config):
if isinstance(entry, list):
ret = []
for k in entry:
ret.append(expandresult(k, config))
return ret
if isinstance(entry, dict):
ret = {}
for k in entry:
ret[expandresult(k, config)] = expandresult(entry[k], config)
return ret
if not isinstance(entry, str):
return entry
class expander:
def __init__(self, config):
self.config = config
def expand(self, entry):
ret = getconfig(entry.group(0)[2:-1], config)
if not ret:
return entry.group(0)
return ret
e = expander(config)
while entry.find('${') != -1:
newentry = __expand_re__.sub(e.expand, entry)
if newentry == entry:
break
entry = newentry
return entry
# Get a configuration value
def getconfig(name, config):
if name in config:
ret = config[name]
return expandresult(ret, config)
return False
# Get a build configuration variable, check overrides first, then defaults
def getconfigvar(name, config, target=None, stepnum=None):
if target:
if target in config['overrides']:
if stepnum:
step = "step" + str(stepnum)
if step in config['overrides'][target] and name in config['overrides'][target][step]:
ret = config['overrides'][target][step][name]
return expandresult(ret, config)
if name in config['overrides'][target]:
ret = config['overrides'][target][name]
return expandresult(ret, config)
if name in config['defaults']:
ret = config['defaults'][name]
return expandresult(ret, config)
return False
def getconfiglist(name, config, target, stepnum):
ret = []
step = "step" + str(stepnum)
if target in config['overrides']:
if step in config['overrides'][target] and name in config['overrides'][target][step]:
ret.extend(config['overrides'][target][step][name])
if name in config['overrides'][target]:
ret.extend(config['overrides'][target][name])
if name in config['defaults']:
ret.extend(config['defaults'][name])
return expandresult(ret, config)
# Return only unique configuration values (identified with '=' in them)
def getconfiglistfilter(name, config, target, stepnum):
def merge(main, newvals):
have = []
for i in main:
a, b = i.split(" ", 1)
have.append(a)
for i in newvals:
# Don't want to match +=
if " = " in i:
a, b = i.split(" ", 1)
if a not in have:
main.append(i)
else:
main.append(i)
ret = []
step = "step" + str(stepnum)
if target in config['overrides']:
if step in config['overrides'][target] and name in config['overrides'][target][step]:
merge(ret, config['overrides'][target][step][name])
if name in config['overrides'][target]:
merge(ret, config['overrides'][target][name])
if name in config['defaults']:
merge(ret, config['defaults'][name])
return expandresult(ret, config)
#
# Expand 'templates' with the configuration
#
# This allows values to be imported from a template if they're not already set
#
def expandtemplates(ourconfig):
orig = copy.deepcopy(ourconfig)
for t in orig['overrides']:
if "TEMPLATE" in orig['overrides'][t]:
template = orig['overrides'][t]["TEMPLATE"]
if template not in orig['templates']:
print("Error, template %s not defined" % template)
sys.exit(1)
for v in orig['templates'][template]:
val = orig['templates'][template][v]
if v not in ourconfig['overrides'][t]:
ourconfig['overrides'][t][v] = copy.deepcopy(orig['templates'][template][v])
elif not isinstance(val, str) and not isinstance(val, bool) and not isinstance(val, int):
for w in val:
if w not in ourconfig['overrides'][t][v]:
ourconfig['overrides'][t][v][w] = copy.deepcopy(orig['templates'][template][v][w])
return ourconfig
#
# Helper to load the json config files
#
# Defaults to the top level config.json file
# however this can be customised from the environment, e.g.:
# ABHELPER_JSON="config.json local.json"
# ABHELPER_JSON="config.json /path/to/local.json"
# files without paths are assumed to be in scripts/..
#
# Values from later files overwrite values from earlier files
# Lists are replaced, dict elements are replaced.
# Deletion of a field isn't possible
#
def loadconfig():
files = "config.json"
if "ABHELPER_JSON" in os.environ:
files = os.environ["ABHELPER_JSON"]
scriptsdir = os.path.dirname(os.path.realpath(__file__))
def handledict(config, ourconfig, c):
if not c in ourconfig:
ourconfig[c] = config[c]
return
for x in config[c]:
if isinstance(config[c][x], dict):
handledict(config[c], ourconfig[c], x)
else:
ourconfig[c][x] = config[c][x]
ourconfig = {}
for f in files.split():
p = f
if not f.startswith("/"):
p = os.path.join(scriptsdir, '..', f)
with open(p) as j:
config = json.load(j)
for c in config:
if isinstance(config[c], dict):
handledict(config, ourconfig, c)
else:
ourconfig[c] = config[c]
# Expand templates in the configuration
ourconfig = expandtemplates(ourconfig)
return ourconfig
#
# Common function to determine if buildhistory is enabled and what the parameters are
#
def getbuildhistoryconfig(ourconfig, builddir, target, reponame, branchname, stepnum):
if contains(["BUILD_HISTORY_DIR", "BUILD_HISTORY_REPO"], ourconfig):
if getconfigvar("BUILDHISTORY", ourconfig, target, stepnum):
base = None
if "/" in reponame:
reponame = reponame.rsplit("/", 1)[1]
if reponame.endswith(".git"):
reponame = reponame[:-4]
if is_a_main_branch(reponame, branchname):
base = reponame + ":" + branchname
if (reponame + ":" + branchname) in getconfig("BUILD_HISTORY_FORKPUSH", ourconfig):
base = getconfig("BUILD_HISTORY_FORKPUSH", ourconfig)[reponame + ":" + branchname]
if base:
baserepo, basebranch = base.split(":")
bh_path = getconfig("BUILD_HISTORY_DIR", ourconfig)
if not os.path.isabs(bh_path):
bh_path = os.path.join(builddir, bh_path)
remoterepo = getconfig("BUILD_HISTORY_REPO", ourconfig)
remotebranch = reponame + "/" + branchname + "/" + target
baseremotebranch = baserepo + "/" + basebranch + "/" + target
return bh_path, remoterepo, remotebranch, baseremotebranch
return None, None, None, None
#
# Run a command, trigger a traceback with command output if it fails
#
def runcmd(cmd):
return subprocess.check_output(cmd, stderr=subprocess.STDOUT)
def fetchgitrepo(clonedir, repo, params, stashdir, depth=None):
sharedrepo = "%s/%s" % (clonedir, repo)
branch = params["branch"]
revision = params["revision"]
if revision != "HEAD" or branch != "master":
depth = None
fetchopt = []
depthopt = []
if depth:
fetchopt = ["--depth", str(depth), branch + ":origin/" + branch]
depthopt = ["--depth", str(depth), "--branch", branch]
print("Checking for stash at: " + stashdir + "/" + repo)
flush()
if os.path.exists(stashdir + "/" + repo):
print("Cloning from stash to %s..." % sharedrepo)
flush()
subprocess.check_call(["git", "clone", "file://%s/%s" % (stashdir, repo), "%s/%s" % (clonedir, repo)] + depthopt)
subprocess.check_call(["git", "remote", "rm", "origin"], cwd=sharedrepo)
subprocess.check_call(["git", "remote", "add", "origin", params["url"]], cwd=sharedrepo)
print("Updating from origin...")
flush()
subprocess.check_call(["git", "fetch", "origin"] + fetchopt, cwd=sharedrepo)
if not depth:
subprocess.check_call(["git", "fetch", "origin", "-t", "-f"], cwd=sharedrepo)
else:
print("Cloning from origin to %s..." % sharedrepo)
flush()
subprocess.check_call(["git", "clone", params["url"], sharedrepo] + depthopt)
print("Updating checkout...")
flush()
subprocess.check_call(["git", "checkout", branch], cwd=sharedrepo)
# git reset revision==HEAD won't help, we need to reset onto the potentially fetched origin branch
subprocess.check_call(["git", "reset", "origin/" + branch, "--hard"], cwd=sharedrepo)
subprocess.check_call(["git", "reset", revision, "--hard"], cwd=sharedrepo)
def publishrepo(clonedir, repo, publishdir):
sharedrepo = "%s/%s" % (clonedir, repo)
revision = subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=sharedrepo).decode('utf-8').strip()
archive_name = repo + "-" + revision + ".tar.bz2"
subprocess.check_call("git archive --format=tar HEAD --prefix=" + repo + "/ | bzip2 -c > " + archive_name, shell=True, cwd=sharedrepo)
subprocess.check_call("sha256sum " + archive_name + " >> " + archive_name + ".sha256sum", shell=True, cwd=sharedrepo)
mkdir(publishdir)
subprocess.check_call("rsync -av " + archive_name + "* " + publishdir, shell=True, cwd=sharedrepo)
def mkdir(path):
try:
os.makedirs(path)
except OSError as e:
if e.errno != errno.EEXIST:
# Do not complain if the directory exists
raise e
def flush():
sys.stdout.flush()
sys.stderr.flush()
def printheader(msg, timestamp=True):
print("")
print("====================================================================================================")
if timestamp is True:
print("%s (%s)" % (msg, round(time.time(), 1)))
elif timestamp:
print("%s (%s)" % (msg, timestamp))
else:
print(msg)
print("====================================================================================================")
print("")
flush()
class HeaderPrinter(object):
def __init__(self):
self.last = time.time()
def printheader(self, msg):
printheader(msg, "%s: %s" % (round(time.time(), 1), round(time.time() - self.last, 1)))
self.last = time.time()
def errorreportdir(builddir):
return builddir + "/tmp/log/error-report/"
class ErrorReport(object):
def __init__(self, ourconfig, target, builddir, branchname, revision):
self.ourconfig = ourconfig
self.target = target
self.builddir = builddir
self.branchname = branchname
self.revision = revision
def create(self, command, stepnum, logfile):
report = {}
report['machine'] = getconfigvar("MACHINE", self.ourconfig, self.target, stepnum)
report['distro'] = getconfigvar("DISTRO", self.ourconfig, self.target, stepnum)
report['build_sys'] = "unknown"
report['nativelsb'] = "unknown"
report['target_sys'] = "unknown"
report['component'] = 'bitbake'
report['branch_commit'] = self.branchname + ': ' + self.revision
failure = {}
failure['package'] = "bitbake"
if 'bitbake-selftest' in command:
report['error_type'] = 'bitbake-selftest'
failure['task'] = command[command.find('bitbake-selftest'):]
elif 'oe-selftest' in command:
report['error_type'] = 'oe-selftest'
failure['task'] = command[command.find('oe-selftest'):]
elif 'yocto-check-layer' in command:
report['error_type'] = 'check-layer'
failure['task'] = command[command.find('yocto-check-layer'):]
else:
report['error_type'] = 'core'
failure['task'] = command[command.find('bitbake'):]
if os.path.exists(logfile):
with open(logfile) as f:
loglines = f.readlines()
failure['log'] = "".join(loglines)
else:
failure['log'] = "Command failed"
report['failures'] = [failure]
errordir = errorreportdir(self.builddir)
mkdir(errordir)
filename = os.path.join(errordir, "error_report_bitbake_%d.txt" % (int(time.time())))
with codecs.open(filename, 'w', 'utf-8') as f:
json.dump(report, f, indent=4, sort_keys=True)
class ArgParser(argparse.ArgumentParser):
def error(self, message):
# Show the help if there's an argument parsing error (e.g. no arguments, missing argument, ...)
sys.stderr.write('error: %s\n' % message)
self.print_help()
sys.exit(2)
#
# Figure out which branch we might need to compare against
# Also return whether this is a forked branch or not.
#
def getcomparisonbranch(ourconfig, reponame, branchname):
print("Working off %s:%s\n" % (reponame, branchname))
if "/" in reponame:
reponame = reponame.rsplit("/", 1)[1]
if reponame.endswith(".git"):
reponame = reponame[:-4]
if (reponame + ":" + branchname) in getconfig("BUILD_HISTORY_FORKPUSH", ourconfig):
base = getconfig("BUILD_HISTORY_FORKPUSH", ourconfig)[reponame + ":" + branchname]
if base:
baserepo, basebranch = base.split(":")
print("Comparing to %s\n" % (basebranch))
return branchname, basebranch
if is_a_main_branch(reponame, branchname):
return branchname, None
return None, None
def sha256_file(filename):
"""
Return the hex string representation of the 256-bit SHA checksum of
filename.
"""
import hashlib
import mmap
method = hashlib.sha256()
with open(filename, "rb") as f:
try:
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
for chunk in iter(lambda: mm.read(8192), b''):
method.update(chunk)
except ValueError:
# You can't mmap() an empty file so silence this exception
pass
return method.hexdigest()
def enable_buildtools_tarball(btdir):
btenv = glob.glob(btdir + "/environment-setup*")
print("Using buildtools %s" % btenv)
# We either parse or wrap all our execution calls, rock and a hard place :(
with open(btenv[0], "r") as f:
for line in f.readlines():
if line.startswith("export "):
line = line.strip().split(" ", 1)[1].split("=", 1)
if "$PATH" in line[1]:
line[1] = line[1].replace("$PATH", os.environ["PATH"])
if line[1].startswith(("'", '"')):
line[1] = line[1][1:-1]
os.environ[line[0]] = line[1]
elif line.startswith("unset "):
line = line.strip().split(" ", 1)[1]
if line in os.environ:
del os.environ[line]
def setup_buildtools_tarball(ourconfig, workername, btdir, checkonly=False):
bttarball = None
if "buildtools" in ourconfig and workername:
btcfg = getconfig("buildtools", ourconfig)
for entry in btcfg:
if fnmatch.fnmatch(workername, entry):
bttarball = btcfg[entry]
break
if checkonly:
return bttarball
btenv = None
if bttarball:
sha256 = None
if ";" in bttarball:
bttarball, sha256 = bttarball.split(";")
btdir = os.path.abspath(btdir)
if not os.path.exists(btdir):
btdlpath = getconfig("BASE_SHAREDDIR", ourconfig) + "/buildtools/" + os.path.basename(bttarball)
print("Extracting buildtools %s" % bttarball)
btlock = btdlpath + ".lock"
if not os.path.exists(os.path.dirname(btdlpath)):
os.makedirs(os.path.dirname(btdlpath), exist_ok=True)
while True:
try:
with open(btlock, 'a+') as lf:
fileno = lf.fileno()
fcntl.flock(fileno, fcntl.LOCK_EX)
if sha256 and os.path.exists(btdlpath):
dl_sha256 = sha256_file(btdlpath)
if dl_sha256 != sha256:
os.unlink(btdlpath)
if not os.path.exists(btdlpath):
if bttarball.startswith("/"):
subprocess.check_call(["cp", bttarball, btdlpath])
else:
subprocess.check_call(["wget", "-O", btdlpath, bttarball])
os.chmod(btdlpath, 0o775)
break
except OSError:
# We raced with someone else, try again
pass
subprocess.check_call(["bash", btdlpath, "-d", btdir, "-y"])
enable_buildtools_tarball(btdir)
def get_string_from_version(version, milestone=None, rc=None):
""" Point releases finishing by 0 (e.g 4.0.0, 4.1.0) do no exists,
those are major releases
"""
if len(version) == 3 and version[-1] == 0:
version = version[:-1]
result = ".".join(list(map(str, version)))
if milestone:
result += "_M" + str(milestone)
if rc:
result += ".rc" + str(rc)
return result
def get_tag_from_version(version, milestone):
if not milestone:
return "yocto-" + get_string_from_version(version, milestone)
return get_string_from_version(version, milestone)
def get_version_from_string(raw_version):
""" Get version as list of int from raw_version.
Raw version _can_ be prefixed by "yocto-",
Raw version _can_ be suffixed by "_MX"
Raw version _can_ be suffixed by ".rcY"
"""
version = None
milestone = None
rc = None
if raw_version[:6] == "yocto-":
raw_version = raw_version[6:]
raw_version = raw_version.split(".")
if raw_version[-1][:2] == "rc":
rc = int(raw_version[-1][-1])
raw_version = raw_version[:-1]
if raw_version[-1][-3:-1] == "_M":
milestone = int(raw_version[-1][-1])
raw_version = raw_version[:-1] + [raw_version[-1][:-3]]
version = list(map(int, raw_version))
""" Point releases finishing by 0 (e.g 4.0.0, 4.1.0) do no exists,
those are major releases
"""
if len(version) == 3 and version[-1] == 0:
version = version[:-1]
return version, milestone, rc