| #!/usr/bin/env python3 | |
| # Copyright 2017 Obsidian Research Corp. | |
| # Licensed under BSD (MIT variant) or GPLv2. See COPYING. | |
| """check-build - Run static checks on a build""" | |
| from __future__ import print_function | |
| import argparse | |
| import inspect | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import tempfile | |
| import sys | |
| import copy | |
| import shlex | |
| import pipes | |
| from contextlib import contextmanager; | |
| from pkg_resources import parse_version | |
| def get_src_dir(): | |
| """Get the source directory using git""" | |
| git_top = subprocess.check_output(["git","rev-parse","--git-dir"]).decode().strip(); | |
| if git_top == ".git": | |
| return "."; | |
| return os.path.dirname(git_top); | |
| def get_package_version(args): | |
| """Return PACKAGE_VERSION from CMake""" | |
| with open(os.path.join(args.SRC,"CMakeLists.txt")) as F: | |
| for ln in F: | |
| g = re.match(r'^set\(PACKAGE_VERSION "(.+)"\)',ln) | |
| if g is None: | |
| continue; | |
| return g.group(1); | |
| raise RuntimeError("Could not find version"); | |
| @contextmanager | |
| def inDirectory(dir): | |
| cdir = os.getcwd(); | |
| try: | |
| os.chdir(dir); | |
| yield True; | |
| finally: | |
| os.chdir(cdir); | |
| @contextmanager | |
| def private_tmp(): | |
| """Simple version of Python 3's tempfile.TemporaryDirectory""" | |
| dfn = tempfile.mkdtemp(); | |
| try: | |
| yield dfn; | |
| finally: | |
| shutil.rmtree(dfn); | |
| # ------------------------------------------------------------------------- | |
| def get_symbol_vers(fn,exported=True): | |
| """Return the symbol version suffixes from the ELF file, eg IB_VERBS_1.0, etc""" | |
| syms = subprocess.check_output(["readelf","--wide","-s",fn]).decode(); | |
| go = False; | |
| res = set(); | |
| for I in syms.splitlines(): | |
| if I.startswith("Symbol table '.dynsym'"): | |
| go = True; | |
| continue; | |
| if I.startswith(" ") and go: | |
| itms = I.split(); | |
| if exported: | |
| if (len(itms) == 8 and itms[3] == "OBJECT" and | |
| itms[4] == "GLOBAL" and itms[6] == "ABS"): | |
| res.add(itms[7]); | |
| else: | |
| if (len(itms) >= 8 and itms[3] == "FUNC" and | |
| itms[4] == "GLOBAL" and itms[6] == "UND"): | |
| res.add(itms[7]); | |
| else: | |
| go = False; | |
| if not res: | |
| raise ValueError("Failed to read ELF symbol versions from %r"%(fn)); | |
| return res; | |
| def symver_parse_version(ver): | |
| return parse_version(ver.partition("_")[-1]) | |
| def check_lib_symver(args,fn): | |
| g = re.match(r"lib([^.]+)\.so\.(\d+)\.(\d+)\.(.*)",fn); | |
| if g.group(4) != args.PACKAGE_VERSION: | |
| raise ValueError("Shared Library filename %r does not have the package version %r (%r)"%( | |
| fn,args.PACKAGE_VERSION,g.groups())); | |
| # umad/etc used the wrong symbol version name when they moved to soname 3.0 | |
| if g.group(1) == "ibumad": | |
| newest_symver = "%s_%s.%s"%(g.group(1).upper(),'1',g.group(3)); | |
| elif g.group(1) == "ibmad": | |
| newest_symver = "%s_%s.%s"%(g.group(1).upper(),'1',g.group(3)); | |
| elif g.group(1) == "ibnetdisc": | |
| newest_symver = "%s_%s.%s"%(g.group(1).upper(),'1',g.group(3)); | |
| else: | |
| newest_symver = "%s_%s.%s"%(g.group(1).upper(),g.group(2),g.group(3)); | |
| syms = get_symbol_vers(fn); | |
| if newest_symver not in syms: | |
| raise ValueError("Symbol version %r implied by filename %r not in ELF (%r)"%( | |
| newest_symver,fn,syms)); | |
| # The private symbol tag should also be older than the package version | |
| private = set(I for I in syms if "PRIVATE" in I) | |
| if len(private) > 1: | |
| raise ValueError("Too many private symbol versions in ELF %r (%r)"%(fn,private)); | |
| if private: | |
| private_rel = list(private)[0].split('_')[-1]; | |
| if private_rel > args.PACKAGE_VERSION: | |
| raise ValueError("Private Symbol Version %r is newer than the package version %r"%( | |
| private,args.PACKAGE_VERSION)); | |
| syms = list(syms - private); | |
| syms.sort(key=symver_parse_version) | |
| if newest_symver != syms[-1]: | |
| raise ValueError("Symbol version %r implied by filename %r not the newest in ELF (%r)"%( | |
| newest_symver,fn,syms)); | |
| def test_lib_names(args): | |
| """Check that the library filename matches the symbol versions""" | |
| libd = os.path.join(args.BUILD,"lib"); | |
| # List of shlibs that follow the ABI guidelines | |
| libs = {}; | |
| with inDirectory(libd): | |
| for fn in os.listdir("."): | |
| if os.path.islink(fn): | |
| lfn = os.readlink(fn); | |
| if not os.path.islink(lfn): | |
| check_lib_symver(args,lfn); | |
| # ------------------------------------------------------------------------- | |
| def check_verbs_abi(args,fn): | |
| g = re.match(r"lib([^-]+)-rdmav(\d+).so",fn); | |
| if g is None: | |
| raise ValueError("Provider library has unknown file name format %r"%(fn)); | |
| private_ver = int(g.group(2)); | |
| syms = get_symbol_vers(fn,exported=False); | |
| syms = {I.partition("@")[2] for I in syms}; | |
| assert "IBVERBS_PRIVATE_%u"%(private_ver) in syms; | |
| assert len([I for I in syms if I.startswith("IBVERBS_PRIVATE")]) == 1; | |
| def test_verbs_private(args): | |
| """Check that the IBVERBS_PRIVATE symbols match the library name, eg that the | |
| map file and the cmake stuff are in sync.""" | |
| libd = os.path.join(args.BUILD,"lib"); | |
| with inDirectory(libd): | |
| for fn in os.listdir("."): | |
| if not os.path.islink(fn) and "rdmav" in fn and fn.endswith(".so"): | |
| check_verbs_abi(args,fn); | |
| # ------------------------------------------------------------------------- | |
| def check_abi(args,fn): | |
| g1 = re.match(r"lib([^.]+).so\.(.+)\.(.+)",fn); | |
| g2 = re.match(r"lib([^.]+).so\.(.+\..+)",fn); | |
| if g1 is None or g2 is None: | |
| raise ValueError("Library has unknown file name format %r"%(fn)); | |
| ref_fn = os.path.join(args.SRC,"ABI",g1.group(1) + ".dump"); | |
| cur_fn = os.path.join(args.SRC,"ABI","current-" + g1.group(1) + ".dump"); | |
| subprocess.check_call(["abi-dumper", | |
| "-lver",g2.group(1), | |
| fn, | |
| "-o",cur_fn]); | |
| if not os.path.exists(ref_fn): | |
| print("ABI file does not exist for %r"%(ref_fn), file=sys.stderr); | |
| return False; | |
| subprocess.check_call(["abi-compliance-checker", | |
| "-l",g1.group(1), | |
| "-old",ref_fn, | |
| "-new",cur_fn]); | |
| return True; | |
| def test_verbs_uapi(args): | |
| """Compare the ABI output from 'abi-dumper' between what is present in git and | |
| what was built in this tree. This allows us to detect changes in ABI on | |
| the -stable branch.""" | |
| # User must provide the ABI dir in the source tree | |
| if not os.path.isdir(os.path.join(args.SRC,"ABI")): | |
| print("ABI check skipped, no ABI/ directory."); | |
| return; | |
| libd = os.path.join(args.BUILD,"lib"); | |
| success = True; | |
| with inDirectory(libd): | |
| for fn in os.listdir("."): | |
| if not os.path.islink(fn) and re.match(r"lib.+\.so\..+\..+",fn): | |
| success = success & check_abi(args,fn); | |
| assert success == True; | |
| # ------------------------------------------------------------------------- | |
| def is_obsolete(fn): | |
| """True if the header is obsolete and should not be compiled anyhow.""" | |
| with open(fn) as F: | |
| for ln in F.readlines(): | |
| if re.search(r"#warning.*This header is obsolete",ln): | |
| return True; | |
| return False; | |
| def is_fixup(fn): | |
| """True if this is a fixup header, fixup headers are exempted because they | |
| required includes are not the same for kernel headers (eg netinet/in.h)""" | |
| if os.path.islink(fn): | |
| return "buildlib/fixup-include/" in os.readlink(fn); | |
| return False; | |
| def get_headers(incdir): | |
| includes = set(); | |
| for root,dirs,files in os.walk(incdir): | |
| for I in files: | |
| if I.endswith(".h"): | |
| includes.add(os.path.join(root,I)); | |
| return includes; | |
| def compile_test_headers(tmpd,incdir,includes,with_cxx=False): | |
| cppflags = subprocess.check_output(["pkg-config","libnl-3.0","--cflags-only-I"]).decode().strip(); | |
| cppflags = "-I %s %s"%(incdir,cppflags) | |
| with open(os.path.join(tmpd,"build.ninja"),"wt") as F: | |
| print("rule comp", file=F); | |
| print(" command = %s -Werror -c %s $in -o $out"%(args.CC,cppflags), file=F); | |
| print(" description=Header check for $in", file=F); | |
| print("rule comp_cxx", file=F); | |
| print(" command = %s -Werror -c %s $in -o $out"%(args.CXX,cppflags), file=F); | |
| print(" description=Header C++ check for $in", file=F); | |
| count = 0; | |
| for I in sorted(includes): | |
| if is_obsolete(I) or is_fixup(I): | |
| continue; | |
| print("build %s : comp %s"%("out%d.o"%(count),I), file=F); | |
| print("default %s"%("out%d.o"%(count)), file=F); | |
| print("build %s : comp_cxx %s"%("outxx%d.o"%(count),I), file=F); | |
| if with_cxx: | |
| print("default %s"%("outxx%d.o"%(count)), file=F); | |
| count = count + 1; | |
| subprocess.check_call(["ninja"],cwd=tmpd); | |
| def test_published_headers(args): | |
| """Test that every header file can be included on its own, and has no obvious | |
| implicit dependencies. This is intended as a first pass check of the public | |
| installed API headers""" | |
| incdir = os.path.abspath(os.path.join(args.BUILD,"include")); | |
| includes = get_headers(incdir); | |
| # Make a little ninja file to compile each header | |
| with private_tmp() as tmpd: | |
| compile_test_headers(tmpd,incdir,includes); | |
| # ------------------------------------------------------------------------- | |
| allowed_uapi_headers = { | |
| # This header is installed in all supported distributions | |
| "rdma/ib_user_sa.h", | |
| "rdma/ib_user_verbs.h", | |
| "linux/stddef.h", | |
| } | |
| non_cxx_headers = { | |
| "infiniband/arch.h", | |
| "infiniband/ib.h", | |
| "infiniband/ib_user_ioctl_verbs.h", | |
| "infiniband/ibnetdisc_osd.h", | |
| "infiniband/mad_osd.h", | |
| "infiniband/mlx5_api.h", | |
| "infiniband/mlx5_user_ioctl_verbs.h", | |
| "infiniband/opcode.h", | |
| "infiniband/sa-kern-abi.h", | |
| "infiniband/sa.h", | |
| "infiniband/verbs_api.h", | |
| "rdma/rdma_cma_abi.h", | |
| } | |
| def test_installed_headers(args): | |
| """This test also checks that the public headers can be compiled on their own, | |
| but goes further and confirms that the public headers do not depend on any | |
| internal headers, or kernel kAPI headers.""" | |
| with private_tmp() as tmpd: | |
| env = copy.deepcopy(os.environ); | |
| env["DESTDIR"] = tmpd; | |
| subprocess.check_output(["ninja","install"],env=env,cwd=args.BUILD); | |
| includes = get_headers(tmpd); | |
| incdir = os.path.commonprefix(list(includes)); | |
| rincludes = {I[len(incdir):] for I in includes}; | |
| bincdir = os.path.abspath(os.path.join(args.BUILD,"include")); | |
| all_includes = set(); | |
| for I in get_headers(bincdir): | |
| if not is_fixup(I) and not is_obsolete(I): | |
| all_includes.add(I[len(bincdir)+1:]); | |
| # Drop error includes for any include file that is internal, this way | |
| # when we compile the public headers any include of an internal header | |
| # will fail. | |
| for I in sorted(all_includes - rincludes): | |
| if I in allowed_uapi_headers: | |
| continue; | |
| I = os.path.join(incdir,I) | |
| dfn = os.path.dirname(I); | |
| if not os.path.isdir(dfn): | |
| os.makedirs(dfn); | |
| assert not os.path.exists(I); | |
| with open(I,"w") as F: | |
| print('#error "Private internal header"', file=F); | |
| # Roughly check that the headers have the extern "C" for C++ | |
| # compilation. | |
| for I in sorted(rincludes - non_cxx_headers): | |
| with open(os.path.join(incdir,I)) as F: | |
| if 'extern "C" {' not in F.read(): | |
| raise ValueError("No extern C in %r"%(I)); | |
| compile_test_headers(tmpd,incdir,includes,with_cxx=True); | |
| # ------------------------------------------------------------------------- | |
| def get_symbol_names(fn): | |
| """Return the defined, public, symbols from a ELF shlib""" | |
| syms = subprocess.check_output(["readelf", "--wide", "-s", fn]).decode() | |
| go = False | |
| res = set() | |
| for I in syms.splitlines(): | |
| if I.startswith("Symbol table '.dynsym'"): | |
| go = True | |
| continue | |
| if I.startswith(" ") and go: | |
| g = re.match( | |
| r"\s+\d+:\s+[0-9a-f]+\s+\d+.*(?:FUNC|OBJECT)\s+GLOBAL\s+DEFAULT\s+\d+\s+(\S+)@@(\S+)$", | |
| I) | |
| if not g or "PRIVATE" in g.group(2): | |
| continue | |
| res.add(g.group(1)) | |
| else: | |
| go = False | |
| return res | |
| def get_cc_args_from_pkgconfig(args, name, static): | |
| """Get the compile arguments from pkg-config for the named librarary""" | |
| os.environ["PKG_CONFIG_PATH"] = os.path.join(args.BUILD, "lib", | |
| "pkgconfig") | |
| flags = ["pkg-config", "--errors-to-stdout", "--cflags", "--libs"] | |
| if static: | |
| flags.append("--static") | |
| opts = subprocess.check_output(flags + ["lib" + name]).decode() | |
| opts = shlex.split(opts) | |
| opts.insert(0, "-Wall") | |
| opts.insert(0, "-Werror") | |
| opts.insert(0, "-L%s" % (os.path.join(args.BUILD, "lib"))) | |
| opts.insert(1, "-I%s" % (os.path.join(args.BUILD, "include"))) | |
| if not static: | |
| return opts | |
| # Only static link the pkg-config stuff, otherwise we get warnings about | |
| # static linking portions of glibc that need NSS. | |
| opts.insert(0, "-Wl,-Bstatic") | |
| opts.append("-Wl,-Bdynamic") | |
| # We need this extra libpthread/m because libnl's pkgconfig file is | |
| # broken and doesn't include the private libraries it requires. :( | |
| if "-lnl-3" in opts: | |
| opts.append("-lm") | |
| opts.append("-lpthread") | |
| # Put glibc associated libraries out side the static link section, | |
| if "-lpthread" in opts: | |
| while "-lpthread" in opts: | |
| opts.remove("-lpthread") | |
| opts.append("-lpthread") | |
| if "-lm" in opts: | |
| while "-lm" in opts: | |
| opts.remove("-lm") | |
| opts.append("-lm") | |
| return opts | |
| def compile_ninja(args, Fninja, name, cfn, opts): | |
| print(""" | |
| rule comp_{name} | |
| command = {CC} -Wall -o $out $in {opts} | |
| description = Compile and link $out | |
| build {name} : comp_{name} {cfn} | |
| default {name}""".format( | |
| name=name, | |
| CC=args.CC, | |
| cfn=cfn, | |
| opts=" ".join(pipes.quote(I) for I in opts)), file=Fninja) | |
| def get_providers(args): | |
| """Return a list of provider names""" | |
| return set( | |
| I for I in os.listdir(os.path.join(args.SRC, "providers")) | |
| if not I.startswith(".")) | |
| def check_static_lib(args, tmpd, Fninja, static_lib, shared_lib, name): | |
| syms = get_symbol_names(shared_lib) | |
| if not syms: | |
| return | |
| cfn = os.path.join(tmpd, "%s-test.c" % (name)) | |
| with open(cfn, "wt") as F: | |
| F.write("#include <stdio.h>\n") | |
| for I in syms: | |
| F.write("extern void %s(void);\n" % (I)) | |
| F.write("int main(int argc,const char *argv[]) {\n") | |
| for I in syms: | |
| F.write('printf("%%p",&%s);\n' % (I)) | |
| F.write("return 0; }\n") | |
| compile_ninja(args, Fninja, "%s-static-out" % (name), cfn, | |
| get_cc_args_from_pkgconfig(args, name, static=True)) | |
| compile_ninja(args, Fninja, "%s-shared-out" % (name), cfn, | |
| get_cc_args_from_pkgconfig(args, name, static=False)) | |
| def check_static_providers(args, tmpd, Fninja): | |
| """Test that expected values for RDMA_STATIC_PROVIDERS are accepted and the | |
| link works""" | |
| cfn = os.path.join(tmpd, "provider-test.c") | |
| with open(cfn, "wt") as F: | |
| F.write("#include <infiniband/verbs.h>\n") | |
| F.write("int main(int argc,const char *argv[]) {\n") | |
| F.write('ibv_get_device_list(NULL);\n') | |
| F.write("return 0; }\n") | |
| opts = get_cc_args_from_pkgconfig( | |
| args, "ibverbs", static=True) | |
| providers = get_providers(args) | |
| for I in sorted(providers | { | |
| "none", | |
| "all", | |
| }): | |
| compile_ninja(args, Fninja, "providers-%s-static-out" % (I), cfn, | |
| ["-DRDMA_STATIC_PROVIDERS=%s" % (I)] + opts) | |
| compile_ninja( | |
| args, Fninja, "providers-static-out", cfn, | |
| ["-DRDMA_STATIC_PROVIDERS=%s" % (",".join(providers))] + opts) | |
| def test_static_libs(args): | |
| """Compile then link statically and dynamically a dummy program that touches | |
| every symbol in the libraries using pkgconfig output to guide the link | |
| options. This tests that pkgconfig is setup properly and that all the | |
| magic with incorporating the internal libraries for static linking has | |
| done its job.""" | |
| libd = os.path.join(args.BUILD, "lib") | |
| success = True | |
| libs = [] | |
| with inDirectory(libd): | |
| fns = set(fn for fn in os.listdir(".") if not os.path.islink(fn)) | |
| for static_lib in fns: | |
| g = re.match(r"lib(.+)\.a$", static_lib) | |
| if g: | |
| for shared_lib in fns: | |
| if re.match(r"lib%s.*\.so" % (g.group(1)), shared_lib): | |
| libs.append((os.path.join(libd, static_lib), | |
| os.path.join(libd, shared_lib), | |
| g.group(1))) | |
| break | |
| else: | |
| raise ValueError( | |
| "Failed to find matching shared library for %r" % | |
| (static_lib)) | |
| with private_tmp() as tmpd: | |
| with open(os.path.join(tmpd, "build.ninja"), "wt") as Fninja: | |
| for I in libs: | |
| check_static_lib(args, tmpd, Fninja, I[0], I[1], I[2]) | |
| check_static_providers(args, tmpd, Fninja) | |
| subprocess.check_call(["ninja"], cwd=tmpd) | |
| # ------------------------------------------------------------------------- | |
| parser = argparse.ArgumentParser(description='Run build time tests') | |
| parser.add_argument("--build",default=os.getcwd(),dest="BUILD", | |
| help="Build directory to inpsect"); | |
| parser.add_argument("--src",default=None,dest="SRC", | |
| help="Top of the source tree"); | |
| parser.add_argument("--cc",default="cc",dest="CC", | |
| help="C compiler to use"); | |
| parser.add_argument("--cxx",default="c++",dest="CXX", | |
| help="C++ compiler to use"); | |
| args = parser.parse_args(); | |
| if args.SRC is None: | |
| args.SRC = get_src_dir(); | |
| args.SRC = os.path.abspath(args.SRC); | |
| args.PACKAGE_VERSION = get_package_version(args); | |
| funcs = globals(); | |
| for k,v in list(funcs.items()): | |
| if k.startswith("test_") and inspect.isfunction(v): | |
| v(args); |