blob: c11ef5fa94a4d184a4cc38d4a9245200ea7ad8f5 [file] [log] [blame]
#!/usr/bin/env python3
"""
This script determines the given package's openbmc dependencies from its
configure.ac file where it downloads, configures, builds, and installs each of
these dependencies. Then the given package is configured, built, and installed
prior to executing its unit tests.
"""
import argparse
import json
import multiprocessing
import os
import platform
import re
import shutil
import subprocess
import sys
from subprocess import CalledProcessError, check_call
from tempfile import TemporaryDirectory
from urllib.parse import urljoin
from git import Repo
# interpreter is not used directly but this resolves dependency ordering
# that would be broken if we didn't include it.
from mesonbuild import interpreter # noqa: F401
from mesonbuild import coredata, optinterpreter
from mesonbuild.mesonlib import OptionKey
from mesonbuild.mesonlib import version_compare as meson_version_compare
class DepTree:
"""
Represents package dependency tree, where each node is a DepTree with a
name and DepTree children.
"""
def __init__(self, name):
"""
Create new DepTree.
Parameter descriptions:
name Name of new tree node.
"""
self.name = name
self.children = list()
def AddChild(self, name):
"""
Add new child node to current node.
Parameter descriptions:
name Name of new child
"""
new_child = DepTree(name)
self.children.append(new_child)
return new_child
def AddChildNode(self, node):
"""
Add existing child node to current node.
Parameter descriptions:
node Tree node to add
"""
self.children.append(node)
def RemoveChild(self, name):
"""
Remove child node.
Parameter descriptions:
name Name of child to remove
"""
for child in self.children:
if child.name == name:
self.children.remove(child)
return
def GetNode(self, name):
"""
Return node with matching name. Return None if not found.
Parameter descriptions:
name Name of node to return
"""
if self.name == name:
return self
for child in self.children:
node = child.GetNode(name)
if node:
return node
return None
def GetParentNode(self, name, parent_node=None):
"""
Return parent of node with matching name. Return none if not found.
Parameter descriptions:
name Name of node to get parent of
parent_node Parent of current node
"""
if self.name == name:
return parent_node
for child in self.children:
found_node = child.GetParentNode(name, self)
if found_node:
return found_node
return None
def GetPath(self, name, path=None):
"""
Return list of node names from head to matching name.
Return None if not found.
Parameter descriptions:
name Name of node
path List of node names from head to current node
"""
if not path:
path = []
if self.name == name:
path.append(self.name)
return path
for child in self.children:
match = child.GetPath(name, path + [self.name])
if match:
return match
return None
def GetPathRegex(self, name, regex_str, path=None):
"""
Return list of node paths that end in name, or match regex_str.
Return empty list if not found.
Parameter descriptions:
name Name of node to search for
regex_str Regex string to match node names
path Path of node names from head to current node
"""
new_paths = []
if not path:
path = []
match = re.match(regex_str, self.name)
if (self.name == name) or (match):
new_paths.append(path + [self.name])
for child in self.children:
return_paths = None
full_path = path + [self.name]
return_paths = child.GetPathRegex(name, regex_str, full_path)
for i in return_paths:
new_paths.append(i)
return new_paths
def MoveNode(self, from_name, to_name):
"""
Mode existing from_name node to become child of to_name node.
Parameter descriptions:
from_name Name of node to make a child of to_name
to_name Name of node to make parent of from_name
"""
parent_from_node = self.GetParentNode(from_name)
from_node = self.GetNode(from_name)
parent_from_node.RemoveChild(from_name)
to_node = self.GetNode(to_name)
to_node.AddChildNode(from_node)
def ReorderDeps(self, name, regex_str):
"""
Reorder dependency tree. If tree contains nodes with names that
match 'name' and 'regex_str', move 'regex_str' nodes that are
to the right of 'name' node, so that they become children of the
'name' node.
Parameter descriptions:
name Name of node to look for
regex_str Regex string to match names to
"""
name_path = self.GetPath(name)
if not name_path:
return
paths = self.GetPathRegex(name, regex_str)
is_name_in_paths = False
name_index = 0
for i in range(len(paths)):
path = paths[i]
if path[-1] == name:
is_name_in_paths = True
name_index = i
break
if not is_name_in_paths:
return
for i in range(name_index + 1, len(paths)):
path = paths[i]
if name in path:
continue
from_name = path[-1]
self.MoveNode(from_name, name)
def GetInstallList(self):
"""
Return post-order list of node names.
Parameter descriptions:
"""
install_list = []
for child in self.children:
child_install_list = child.GetInstallList()
install_list.extend(child_install_list)
install_list.append(self.name)
return install_list
def PrintTree(self, level=0):
"""
Print pre-order node names with indentation denoting node depth level.
Parameter descriptions:
level Current depth level
"""
INDENT_PER_LEVEL = 4
print(" " * (level * INDENT_PER_LEVEL) + self.name)
for child in self.children:
child.PrintTree(level + 1)
def check_call_cmd(*cmd, **kwargs):
"""
Verbose prints the directory location the given command is called from and
the command, then executes the command using check_call.
Parameter descriptions:
dir Directory location command is to be called from
cmd List of parameters constructing the complete command
"""
printline(os.getcwd(), ">", " ".join(cmd))
check_call(cmd, **kwargs)
def clone_pkg(pkg, branch):
"""
Clone the given openbmc package's git repository from gerrit into
the WORKSPACE location
Parameter descriptions:
pkg Name of the package to clone
branch Branch to clone from pkg
"""
pkg_dir = os.path.join(WORKSPACE, pkg)
if os.path.exists(os.path.join(pkg_dir, ".git")):
return pkg_dir
pkg_repo = urljoin("https://gerrit.openbmc.org/openbmc/", pkg)
os.mkdir(pkg_dir)
printline(pkg_dir, "> git clone", pkg_repo, branch, "./")
try:
# first try the branch
clone = Repo.clone_from(pkg_repo, pkg_dir, branch=branch)
repo_inst = clone.working_dir
except Exception:
printline("Input branch not found, default to master")
clone = Repo.clone_from(pkg_repo, pkg_dir, branch="master")
repo_inst = clone.working_dir
return repo_inst
def make_target_exists(target):
"""
Runs a check against the makefile in the current directory to determine
if the target exists so that it can be built.
Parameter descriptions:
target The make target we are checking
"""
try:
cmd = ["make", "-n", target]
with open(os.devnull, "w") as devnull:
check_call(cmd, stdout=devnull, stderr=devnull)
return True
except CalledProcessError:
return False
make_parallel = [
"make",
# Run enough jobs to saturate all the cpus
"-j",
str(multiprocessing.cpu_count()),
# Don't start more jobs if the load avg is too high
"-l",
str(multiprocessing.cpu_count()),
# Synchronize the output so logs aren't intermixed in stdout / stderr
"-O",
]
def build_and_install(name, build_for_testing=False):
"""
Builds and installs the package in the environment. Optionally
builds the examples and test cases for package.
Parameter description:
name The name of the package we are building
build_for_testing Enable options related to testing on the package?
"""
os.chdir(os.path.join(WORKSPACE, name))
# Refresh dynamic linker run time bindings for dependencies
check_call_cmd("sudo", "-n", "--", "ldconfig")
pkg = Package()
if build_for_testing:
pkg.test()
else:
pkg.install()
def build_dep_tree(name, pkgdir, dep_added, head, branch, dep_tree=None):
"""
For each package (name), starting with the package to be unit tested,
extract its dependencies. For each package dependency defined, recursively
apply the same strategy
Parameter descriptions:
name Name of the package
pkgdir Directory where package source is located
dep_added Current dict of dependencies and added status
head Head node of the dependency tree
branch Branch to clone from pkg
dep_tree Current dependency tree node
"""
if not dep_tree:
dep_tree = head
with open("/tmp/depcache", "r") as depcache:
cache = depcache.readline()
# Read out pkg dependencies
pkg = Package(name, pkgdir)
build = pkg.build_system()
if not build:
raise Exception(f"Unable to find build system for {name}.")
for dep in set(build.dependencies()):
if dep in cache:
continue
# Dependency package not already known
if dep_added.get(dep) is None:
print(f"Adding {dep} dependency to {name}.")
# Dependency package not added
new_child = dep_tree.AddChild(dep)
dep_added[dep] = False
dep_pkgdir = clone_pkg(dep, branch)
# Determine this dependency package's
# dependencies and add them before
# returning to add this package
dep_added = build_dep_tree(
dep, dep_pkgdir, dep_added, head, branch, new_child
)
else:
# Dependency package known and added
if dep_added[dep]:
continue
else:
# Cyclic dependency failure
raise Exception("Cyclic dependencies found in " + name)
if not dep_added[name]:
dep_added[name] = True
return dep_added
def run_cppcheck():
if not os.path.exists(os.path.join("build", "compile_commands.json")):
return None
with TemporaryDirectory() as cpp_dir:
# http://cppcheck.sourceforge.net/manual.pdf
try:
check_call_cmd(
"cppcheck",
"-j",
str(multiprocessing.cpu_count()),
"--enable=style,performance,portability,missingInclude",
"--inline-suppr",
"--suppress=useStlAlgorithm",
"--suppress=unusedStructMember",
"--suppress=postfixOperator",
"--suppress=unreadVariable",
"--suppress=knownConditionTrueFalse",
"--library=googletest",
"--project=build/compile_commands.json",
f"--cppcheck-build-dir={cpp_dir}",
)
except subprocess.CalledProcessError:
print("cppcheck found errors")
def is_valgrind_safe():
"""
Returns whether it is safe to run valgrind on our platform
"""
src = "unit-test-vg.c"
exe = "./unit-test-vg"
with open(src, "w") as h:
h.write("#include <errno.h>\n")
h.write("#include <stdio.h>\n")
h.write("#include <stdlib.h>\n")
h.write("#include <string.h>\n")
h.write("int main() {\n")
h.write("char *heap_str = malloc(16);\n")
h.write('strcpy(heap_str, "RandString");\n')
h.write('int res = strcmp("RandString", heap_str);\n')
h.write("free(heap_str);\n")
h.write("char errstr[64];\n")
h.write("strerror_r(EINVAL, errstr, sizeof(errstr));\n")
h.write('printf("%s\\n", errstr);\n')
h.write("return res;\n")
h.write("}\n")
try:
with open(os.devnull, "w") as devnull:
check_call(
["gcc", "-O2", "-o", exe, src], stdout=devnull, stderr=devnull
)
check_call(
["valgrind", "--error-exitcode=99", exe],
stdout=devnull,
stderr=devnull,
)
return True
except Exception:
sys.stderr.write("###### Platform is not valgrind safe ######\n")
return False
finally:
os.remove(src)
os.remove(exe)
def is_sanitize_safe():
"""
Returns whether it is safe to run sanitizers on our platform
"""
src = "unit-test-sanitize.c"
exe = "./unit-test-sanitize"
with open(src, "w") as h:
h.write("int main() { return 0; }\n")
try:
with open(os.devnull, "w") as devnull:
check_call(
[
"gcc",
"-O2",
"-fsanitize=address",
"-fsanitize=undefined",
"-o",
exe,
src,
],
stdout=devnull,
stderr=devnull,
)
check_call([exe], stdout=devnull, stderr=devnull)
# TODO - Sanitizer not working on ppc64le
# https://github.com/openbmc/openbmc-build-scripts/issues/31
if platform.processor() == "ppc64le":
sys.stderr.write("###### ppc64le is not sanitize safe ######\n")
return False
else:
return True
except Exception:
sys.stderr.write("###### Platform is not sanitize safe ######\n")
return False
finally:
os.remove(src)
os.remove(exe)
def maybe_make_valgrind():
"""
Potentially runs the unit tests through valgrind for the package
via `make check-valgrind`. If the package does not have valgrind testing
then it just skips over this.
"""
# Valgrind testing is currently broken by an aggressive strcmp optimization
# that is inlined into optimized code for POWER by gcc 7+. Until we find
# a workaround, just don't run valgrind tests on POWER.
# https://github.com/openbmc/openbmc/issues/3315
if not is_valgrind_safe():
sys.stderr.write("###### Skipping valgrind ######\n")
return
if not make_target_exists("check-valgrind"):
return
try:
cmd = make_parallel + ["check-valgrind"]
check_call_cmd(*cmd)
except CalledProcessError:
for root, _, files in os.walk(os.getcwd()):
for f in files:
if re.search("test-suite-[a-z]+.log", f) is None:
continue
check_call_cmd("cat", os.path.join(root, f))
raise Exception("Valgrind tests failed")
def maybe_make_coverage():
"""
Potentially runs the unit tests through code coverage for the package
via `make check-code-coverage`. If the package does not have code coverage
testing then it just skips over this.
"""
if not make_target_exists("check-code-coverage"):
return
# Actually run code coverage
try:
cmd = make_parallel + ["check-code-coverage"]
check_call_cmd(*cmd)
except CalledProcessError:
raise Exception("Code coverage failed")
class BuildSystem(object):
"""
Build systems generally provide the means to configure, build, install and
test software. The BuildSystem class defines a set of interfaces on top of
which Autotools, Meson, CMake and possibly other build system drivers can
be implemented, separating out the phases to control whether a package
should merely be installed or also tested and analyzed.
"""
def __init__(self, package, path):
"""Initialise the driver with properties independent of the build
system
Keyword arguments:
package: The name of the package. Derived from the path if None
path: The path to the package. Set to the working directory if None
"""
self.path = "." if not path else path
realpath = os.path.realpath(self.path)
self.package = package if package else os.path.basename(realpath)
self.build_for_testing = False
def probe(self):
"""Test if the build system driver can be applied to the package
Return True if the driver can drive the package's build system,
otherwise False.
Generally probe() is implemented by testing for the presence of the
build system's configuration file(s).
"""
raise NotImplementedError
def dependencies(self):
"""Provide the package's dependencies
Returns a list of dependencies. If no dependencies are required then an
empty list must be returned.
Generally dependencies() is implemented by analysing and extracting the
data from the build system configuration.
"""
raise NotImplementedError
def configure(self, build_for_testing):
"""Configure the source ready for building
Should raise an exception if configuration failed.
Keyword arguments:
build_for_testing: Mark the package as being built for testing rather
than for installation as a dependency for the
package under test. Setting to True generally
implies that the package will be configured to build
with debug information, at a low level of
optimisation and possibly with sanitizers enabled.
Generally configure() is implemented by invoking the build system
tooling to generate Makefiles or equivalent.
"""
raise NotImplementedError
def build(self):
"""Build the software ready for installation and/or testing
Should raise an exception if the build fails
Generally build() is implemented by invoking `make` or `ninja`.
"""
raise NotImplementedError
def install(self):
"""Install the software ready for use
Should raise an exception if installation fails
Like build(), install() is generally implemented by invoking `make` or
`ninja`.
"""
raise NotImplementedError
def test(self):
"""Build and run the test suite associated with the package
Should raise an exception if the build or testing fails.
Like install(), test() is generally implemented by invoking `make` or
`ninja`.
"""
raise NotImplementedError
def analyze(self):
"""Run any supported analysis tools over the codebase
Should raise an exception if analysis fails.
Some analysis tools such as scan-build need injection into the build
system. analyze() provides the necessary hook to implement such
behaviour. Analyzers independent of the build system can also be
specified here but at the cost of possible duplication of code between
the build system driver implementations.
"""
raise NotImplementedError
class Autotools(BuildSystem):
def __init__(self, package=None, path=None):
super(Autotools, self).__init__(package, path)
def probe(self):
return os.path.isfile(os.path.join(self.path, "configure.ac"))
def dependencies(self):
configure_ac = os.path.join(self.path, "configure.ac")
contents = ""
# Prepend some special function overrides so we can parse out
# dependencies
for macro in DEPENDENCIES.keys():
contents += (
"m4_define(["
+ macro
+ "], ["
+ macro
+ "_START$"
+ str(DEPENDENCIES_OFFSET[macro] + 1)
+ macro
+ "_END])\n"
)
with open(configure_ac, "rt") as f:
contents += f.read()
autoconf_cmdline = ["autoconf", "-Wno-undefined", "-"]
autoconf_process = subprocess.Popen(
autoconf_cmdline,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
document = contents.encode("utf-8")
(stdout, stderr) = autoconf_process.communicate(input=document)
if not stdout:
print(stderr)
raise Exception("Failed to run autoconf for parsing dependencies")
# Parse out all of the dependency text
matches = []
for macro in DEPENDENCIES.keys():
pattern = "(" + macro + ")_START(.*?)" + macro + "_END"
for match in re.compile(pattern).finditer(stdout.decode("utf-8")):
matches.append((match.group(1), match.group(2)))
# Look up dependencies from the text
found_deps = []
for macro, deptext in matches:
for potential_dep in deptext.split(" "):
for known_dep in DEPENDENCIES[macro].keys():
if potential_dep.startswith(known_dep):
found_deps.append(DEPENDENCIES[macro][known_dep])
return found_deps
def _configure_feature(self, flag, enabled):
"""
Returns an configure flag as a string
Parameters:
flag The name of the flag
enabled Whether the flag is enabled or disabled
"""
return "--" + ("enable" if enabled else "disable") + "-" + flag
def configure(self, build_for_testing):
self.build_for_testing = build_for_testing
conf_flags = [
self._configure_feature("silent-rules", False),
self._configure_feature("examples", build_for_testing),
self._configure_feature("tests", build_for_testing),
self._configure_feature("itests", INTEGRATION_TEST),
]
conf_flags.extend(
[
self._configure_feature("code-coverage", False),
self._configure_feature("valgrind", build_for_testing),
]
)
# Add any necessary configure flags for package
if CONFIGURE_FLAGS.get(self.package) is not None:
conf_flags.extend(CONFIGURE_FLAGS.get(self.package))
for bootstrap in ["bootstrap.sh", "bootstrap", "autogen.sh"]:
if os.path.exists(bootstrap):
check_call_cmd("./" + bootstrap)
break
check_call_cmd("./configure", *conf_flags)
def build(self):
check_call_cmd(*make_parallel)
def install(self):
check_call_cmd("sudo", "-n", "--", *(make_parallel + ["install"]))
def test(self):
try:
cmd = make_parallel + ["check"]
for i in range(0, args.repeat):
check_call_cmd(*cmd)
maybe_make_valgrind()
maybe_make_coverage()
except CalledProcessError:
for root, _, files in os.walk(os.getcwd()):
if "test-suite.log" not in files:
continue
check_call_cmd("cat", os.path.join(root, "test-suite.log"))
raise Exception("Unit tests failed")
def analyze(self):
run_cppcheck()
class CMake(BuildSystem):
def __init__(self, package=None, path=None):
super(CMake, self).__init__(package, path)
def probe(self):
return os.path.isfile(os.path.join(self.path, "CMakeLists.txt"))
def dependencies(self):
return []
def configure(self, build_for_testing):
self.build_for_testing = build_for_testing
if INTEGRATION_TEST:
check_call_cmd(
"cmake",
"-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
"-DCMAKE_CXX_FLAGS='-DBOOST_USE_VALGRIND'",
"-DITESTS=ON",
".",
)
else:
check_call_cmd(
"cmake",
"-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
"-DCMAKE_CXX_FLAGS='-DBOOST_USE_VALGRIND'",
".",
)
def build(self):
check_call_cmd(
"cmake",
"--build",
".",
"--",
"-j",
str(multiprocessing.cpu_count()),
)
def install(self):
check_call_cmd("sudo", "cmake", "--install", ".")
def test(self):
if make_target_exists("test"):
check_call_cmd("ctest", ".")
def analyze(self):
if os.path.isfile(".clang-tidy"):
with TemporaryDirectory(prefix="build", dir=".") as build_dir:
# clang-tidy needs to run on a clang-specific build
check_call_cmd(
"cmake",
"-DCMAKE_C_COMPILER=clang",
"-DCMAKE_CXX_COMPILER=clang++",
"-DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
"-H.",
"-B" + build_dir,
)
check_call_cmd(
"run-clang-tidy", "-header-filter=.*", "-p", build_dir
)
maybe_make_valgrind()
maybe_make_coverage()
run_cppcheck()
class Meson(BuildSystem):
@staticmethod
def _project_name(path):
doc = subprocess.check_output(
["meson", "introspect", "--projectinfo", path],
stderr=subprocess.STDOUT,
).decode("utf-8")
return json.loads(doc)["descriptive_name"]
def __init__(self, package=None, path=None):
super(Meson, self).__init__(package, path)
def probe(self):
return os.path.isfile(os.path.join(self.path, "meson.build"))
def dependencies(self):
meson_build = os.path.join(self.path, "meson.build")
if not os.path.exists(meson_build):
return []
found_deps = []
for root, dirs, files in os.walk(self.path):
if "meson.build" not in files:
continue
with open(os.path.join(root, "meson.build"), "rt") as f:
build_contents = f.read()
pattern = r"dependency\('([^']*)'.*?\),?\n"
for match in re.finditer(pattern, build_contents):
group = match.group(1)
maybe_dep = DEPENDENCIES["PKG_CHECK_MODULES"].get(group)
if maybe_dep is not None:
found_deps.append(maybe_dep)
return found_deps
def _parse_options(self, options_file):
"""
Returns a set of options defined in the provides meson_options.txt file
Parameters:
options_file The file containing options
"""
oi = optinterpreter.OptionInterpreter("")
oi.process(options_file)
return oi.options
def _configure_boolean(self, val):
"""
Returns the meson flag which signifies the value
True is true which requires the boolean.
False is false which disables the boolean.
Parameters:
val The value being converted
"""
if val is True:
return "true"
elif val is False:
return "false"
else:
raise Exception("Bad meson boolean value")
def _configure_feature(self, val):
"""
Returns the meson flag which signifies the value
True is enabled which requires the feature.
False is disabled which disables the feature.
None is auto which autodetects the feature.
Parameters:
val The value being converted
"""
if val is True:
return "enabled"
elif val is False:
return "disabled"
elif val is None:
return "auto"
else:
raise Exception("Bad meson feature value")
def _configure_option(self, opts, key, val):
"""
Returns the meson flag which signifies the value
based on the type of the opt
Parameters:
opt The meson option which we are setting
val The value being converted
"""
if isinstance(opts[key], coredata.UserBooleanOption):
str_val = self._configure_boolean(val)
elif isinstance(opts[key], coredata.UserFeatureOption):
str_val = self._configure_feature(val)
else:
raise Exception("Unknown meson option type")
return "-D{}={}".format(key, str_val)
def configure(self, build_for_testing):
self.build_for_testing = build_for_testing
meson_options = {}
if os.path.exists("meson.options"):
meson_options = self._parse_options("meson.options")
elif os.path.exists("meson_options.txt"):
meson_options = self._parse_options("meson_options.txt")
meson_flags = [
"-Db_colorout=never",
"-Dwerror=true",
"-Dwarning_level=3",
"-Dcpp_args='-DBOOST_USE_VALGRIND'",
]
if build_for_testing:
# -Ddebug=true -Doptimization=g is helpful for abi-dumper but isn't a combination that
# is supported by meson's build types. Configure it manually.
meson_flags.append("-Ddebug=true")
meson_flags.append("-Doptimization=g")
else:
meson_flags.append("--buildtype=debugoptimized")
if OptionKey("tests") in meson_options:
meson_flags.append(
self._configure_option(
meson_options, OptionKey("tests"), build_for_testing
)
)
if OptionKey("examples") in meson_options:
meson_flags.append(
self._configure_option(
meson_options, OptionKey("examples"), build_for_testing
)
)
if OptionKey("itests") in meson_options:
meson_flags.append(
self._configure_option(
meson_options, OptionKey("itests"), INTEGRATION_TEST
)
)
if MESON_FLAGS.get(self.package) is not None:
meson_flags.extend(MESON_FLAGS.get(self.package))
try:
check_call_cmd(
"meson", "setup", "--reconfigure", "build", *meson_flags
)
except Exception:
shutil.rmtree("build", ignore_errors=True)
check_call_cmd("meson", "setup", "build", *meson_flags)
self.package = Meson._project_name("build")
def build(self):
check_call_cmd("ninja", "-C", "build")
def install(self):
check_call_cmd("sudo", "-n", "--", "ninja", "-C", "build", "install")
def test(self):
# It is useful to check various settings of the meson.build file
# for compatibility, such as meson_version checks. We shouldn't
# do this in the configure path though because it affects subprojects
# and dependencies as well, but we only want this applied to the
# project-under-test (otherwise an upstream dependency could fail
# this check without our control).
self._extra_meson_checks()
try:
test_args = ("--repeat", str(args.repeat), "-C", "build")
check_call_cmd("meson", "test", "--print-errorlogs", *test_args)
except CalledProcessError:
raise Exception("Unit tests failed")
def _setup_exists(self, setup):
"""
Returns whether the meson build supports the named test setup.
Parameter descriptions:
setup The setup target to check
"""
try:
with open(os.devnull, "w"):
output = subprocess.check_output(
[
"meson",
"test",
"-C",
"build",
"--setup",
"{}:{}".format(self.package, setup),
"__likely_not_a_test__",
],
stderr=subprocess.STDOUT,
)
except CalledProcessError as e:
output = e.output
output = output.decode("utf-8")
return not re.search("Unknown test setup '[^']+'[.]", output)
def _maybe_valgrind(self):
"""
Potentially runs the unit tests through valgrind for the package
via `meson test`. The package can specify custom valgrind
configurations by utilizing add_test_setup() in a meson.build
"""
if not is_valgrind_safe():
sys.stderr.write("###### Skipping valgrind ######\n")
return
try:
if self._setup_exists("valgrind"):
check_call_cmd(
"meson",
"test",
"-t",
"10",
"-C",
"build",
"--print-errorlogs",
"--setup",
"{}:valgrind".format(self.package),
)
else:
check_call_cmd(
"meson",
"test",
"-t",
"10",
"-C",
"build",
"--print-errorlogs",
"--wrapper",
"valgrind --error-exitcode=1",
)
except CalledProcessError:
raise Exception("Valgrind tests failed")
def analyze(self):
self._maybe_valgrind()
# Run clang-tidy only if the project has a configuration
if os.path.isfile(".clang-tidy"):
os.environ["CXX"] = "clang++"
with TemporaryDirectory(prefix="build", dir=".") as build_dir:
check_call_cmd("meson", "setup", build_dir)
if not os.path.isfile(".openbmc-no-clang"):
check_call_cmd("meson", "compile", "-C", build_dir)
try:
check_call_cmd("ninja", "-C", build_dir, "clang-tidy-fix")
except subprocess.CalledProcessError:
check_call_cmd(
"git", "-C", CODE_SCAN_DIR, "--no-pager", "diff"
)
raise
# Run the basic clang static analyzer otherwise
else:
check_call_cmd("ninja", "-C", "build", "scan-build")
# Run tests through sanitizers
# b_lundef is needed if clang++ is CXX since it resolves the
# asan symbols at runtime only. We don't want to set it earlier
# in the build process to ensure we don't have undefined
# runtime code.
if is_sanitize_safe():
check_call_cmd(
"meson",
"configure",
"build",
"-Db_sanitize=address,undefined",
"-Db_lundef=false",
)
check_call_cmd(
"meson",
"test",
"-C",
"build",
"--print-errorlogs",
"--logbase",
"testlog-ubasan",
env=os.environ | {"UBSAN_OPTIONS": "halt_on_error=1"},
)
# TODO: Fix memory sanitizer
# check_call_cmd('meson', 'configure', 'build',
# '-Db_sanitize=memory')
# check_call_cmd('meson', 'test', '-C', 'build'
# '--logbase', 'testlog-msan')
check_call_cmd("meson", "configure", "build", "-Db_sanitize=none")
else:
sys.stderr.write("###### Skipping sanitizers ######\n")
# Run coverage checks
check_call_cmd("meson", "configure", "build", "-Db_coverage=true")
self.test()
# Only build coverage HTML if coverage files were produced
for root, dirs, files in os.walk("build"):
if any([f.endswith(".gcda") for f in files]):
check_call_cmd("ninja", "-C", "build", "coverage-html")
break
check_call_cmd("meson", "configure", "build", "-Db_coverage=false")
run_cppcheck()
def _extra_meson_checks(self):
with open(os.path.join(self.path, "meson.build"), "rt") as f:
build_contents = f.read()
# Find project's specified meson_version.
meson_version = None
pattern = r"meson_version:[^']*'([^']*)'"
for match in re.finditer(pattern, build_contents):
group = match.group(1)
meson_version = group
# C++20 requires at least Meson 0.57 but Meson itself doesn't
# identify this. Add to our unit-test checks so that we don't
# get a meson.build missing this.
pattern = r"'cpp_std=c\+\+20'"
for match in re.finditer(pattern, build_contents):
if not meson_version or not meson_version_compare(
meson_version, ">=0.57"
):
raise Exception(
"C++20 support requires specifying in meson.build: "
+ "meson_version: '>=0.57'"
)
# C++23 requires at least Meson 1.1.1 but Meson itself doesn't
# identify this. Add to our unit-test checks so that we don't
# get a meson.build missing this.
pattern = r"'cpp_std=c\+\+23'"
for match in re.finditer(pattern, build_contents):
if not meson_version or not meson_version_compare(
meson_version, ">=1.1.1"
):
raise Exception(
"C++23 support requires specifying in meson.build: "
+ "meson_version: '>=1.1.1'"
)
if "get_variable(" in build_contents:
if not meson_version or not meson_version_compare(
meson_version, ">=0.58"
):
raise Exception(
"dep.get_variable() with positional argument requires "
+ "meson_Version: '>=0.58'"
)
class Package(object):
def __init__(self, name=None, path=None):
self.supported = [Meson, Autotools, CMake]
self.name = name
self.path = path
self.test_only = False
def build_systems(self):
instances = (system(self.name, self.path) for system in self.supported)
return (instance for instance in instances if instance.probe())
def build_system(self, preferred=None):
systems = list(self.build_systems())
if not systems:
return None
if preferred:
return {type(system): system for system in systems}[preferred]
return next(iter(systems))
def install(self, system=None):
if not system:
system = self.build_system()
system.configure(False)
system.build()
system.install()
def _test_one(self, system):
system.configure(True)
system.build()
system.install()
system.test()
if not TEST_ONLY:
system.analyze()
def test(self):
for system in self.build_systems():
self._test_one(system)
def find_file(filename, basedir):
"""
Finds all occurrences of a file (or list of files) in the base
directory and passes them back with their relative paths.
Parameter descriptions:
filename The name of the file (or list of files) to
find
basedir The base directory search in
"""
if not isinstance(filename, list):
filename = [filename]
filepaths = []
for root, dirs, files in os.walk(basedir):
if os.path.split(root)[-1] == "subprojects":
for f in files:
subproject = ".".join(f.split(".")[0:-1])
if f.endswith(".wrap") and subproject in dirs:
# don't find files in meson subprojects with wraps
dirs.remove(subproject)
for f in filename:
if f in files:
filepaths.append(os.path.join(root, f))
return filepaths
if __name__ == "__main__":
# CONFIGURE_FLAGS = [GIT REPO]:[CONFIGURE FLAGS]
CONFIGURE_FLAGS = {
"phosphor-logging": [
"--enable-metadata-processing",
"--enable-openpower-pel-extension",
"YAML_DIR=/usr/local/share/phosphor-dbus-yaml/yaml",
]
}
# MESON_FLAGS = [GIT REPO]:[MESON FLAGS]
MESON_FLAGS = {
"phosphor-dbus-interfaces": [
"-Ddata_com_ibm=true",
"-Ddata_org_open_power=true",
],
"phosphor-logging": ["-Dopenpower-pel-extension=enabled"],
}
# DEPENDENCIES = [MACRO]:[library/header]:[GIT REPO]
DEPENDENCIES = {
"AC_CHECK_LIB": {"mapper": "phosphor-objmgr"},
"AC_CHECK_HEADER": {
"host-ipmid": "phosphor-host-ipmid",
"blobs-ipmid": "phosphor-ipmi-blobs",
"sdbusplus": "sdbusplus",
"sdeventplus": "sdeventplus",
"stdplus": "stdplus",
"gpioplus": "gpioplus",
"phosphor-logging/log.hpp": "phosphor-logging",
},
"AC_PATH_PROG": {"sdbus++": "sdbusplus"},
"PKG_CHECK_MODULES": {
"phosphor-dbus-interfaces": "phosphor-dbus-interfaces",
"libipmid": "phosphor-host-ipmid",
"libipmid-host": "phosphor-host-ipmid",
"sdbusplus": "sdbusplus",
"sdeventplus": "sdeventplus",
"stdplus": "stdplus",
"gpioplus": "gpioplus",
"phosphor-logging": "phosphor-logging",
"phosphor-snmp": "phosphor-snmp",
"ipmiblob": "ipmi-blob-tool",
"hei": "openpower-libhei",
"phosphor-ipmi-blobs": "phosphor-ipmi-blobs",
"libcr51sign": "google-misc",
},
}
# Offset into array of macro parameters MACRO(0, 1, ...N)
DEPENDENCIES_OFFSET = {
"AC_CHECK_LIB": 0,
"AC_CHECK_HEADER": 0,
"AC_PATH_PROG": 1,
"PKG_CHECK_MODULES": 1,
}
# DEPENDENCIES_REGEX = [GIT REPO]:[REGEX STRING]
DEPENDENCIES_REGEX = {"phosphor-logging": r"\S+-dbus-interfaces$"}
# Set command line arguments
parser = argparse.ArgumentParser()
parser.add_argument(
"-w",
"--workspace",
dest="WORKSPACE",
required=True,
help="Workspace directory location(i.e. /home)",
)
parser.add_argument(
"-p",
"--package",
dest="PACKAGE",
required=True,
help="OpenBMC package to be unit tested",
)
parser.add_argument(
"-t",
"--test-only",
dest="TEST_ONLY",
action="store_true",
required=False,
default=False,
help="Only run test cases, no other validation",
)
arg_inttests = parser.add_mutually_exclusive_group()
arg_inttests.add_argument(
"--integration-tests",
dest="INTEGRATION_TEST",
action="store_true",
required=False,
default=True,
help="Enable integration tests [default].",
)
arg_inttests.add_argument(
"--no-integration-tests",
dest="INTEGRATION_TEST",
action="store_false",
required=False,
help="Disable integration tests.",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="Print additional package status messages",
)
parser.add_argument(
"-r", "--repeat", help="Repeat tests N times", type=int, default=1
)
parser.add_argument(
"-b",
"--branch",
dest="BRANCH",
required=False,
help="Branch to target for dependent repositories",
default="master",
)
parser.add_argument(
"-n",
"--noformat",
dest="FORMAT",
action="store_false",
required=False,
help="Whether or not to run format code",
)
args = parser.parse_args(sys.argv[1:])
WORKSPACE = args.WORKSPACE
UNIT_TEST_PKG = args.PACKAGE
TEST_ONLY = args.TEST_ONLY
INTEGRATION_TEST = args.INTEGRATION_TEST
BRANCH = args.BRANCH
FORMAT_CODE = args.FORMAT
if args.verbose:
def printline(*line):
for arg in line:
print(arg, end=" ")
print()
else:
def printline(*line):
pass
CODE_SCAN_DIR = os.path.join(WORKSPACE, UNIT_TEST_PKG)
# Run format-code.sh, which will in turn call any repo-level formatters.
if FORMAT_CODE:
check_call_cmd(
os.path.join(
WORKSPACE, "openbmc-build-scripts", "scripts", "format-code.sh"
),
CODE_SCAN_DIR,
)
# Check to see if any files changed
check_call_cmd(
"git", "-C", CODE_SCAN_DIR, "--no-pager", "diff", "--exit-code"
)
# Check if this repo has a supported make infrastructure
pkg = Package(UNIT_TEST_PKG, CODE_SCAN_DIR)
if not pkg.build_system():
print("No valid build system, exit")
sys.exit(0)
prev_umask = os.umask(000)
# Determine dependencies and add them
dep_added = dict()
dep_added[UNIT_TEST_PKG] = False
# Create dependency tree
dep_tree = DepTree(UNIT_TEST_PKG)
build_dep_tree(UNIT_TEST_PKG, CODE_SCAN_DIR, dep_added, dep_tree, BRANCH)
# Reorder Dependency Tree
for pkg_name, regex_str in DEPENDENCIES_REGEX.items():
dep_tree.ReorderDeps(pkg_name, regex_str)
if args.verbose:
dep_tree.PrintTree()
install_list = dep_tree.GetInstallList()
# We don't want to treat our package as a dependency
install_list.remove(UNIT_TEST_PKG)
# Install reordered dependencies
for dep in install_list:
build_and_install(dep, False)
# Run package unit tests
build_and_install(UNIT_TEST_PKG, True)
os.umask(prev_umask)
# Run any custom CI scripts the repo has, of which there can be
# multiple of and anywhere in the repository.
ci_scripts = find_file(["run-ci.sh", "run-ci"], CODE_SCAN_DIR)
if ci_scripts:
os.chdir(CODE_SCAN_DIR)
for ci_script in ci_scripts:
check_call_cmd(ci_script)