#!/usr/bin/env python3 # SPDX-FileCopyrightText: The Eigen Authors # SPDX-License-Identifier: MPL-2.0 # # Content-addressed pass cache for CI test jobs (driven by # test.linux.script.sh, run from inside the build directory). # # `plan` reads a `ctest --show-only=json-v1` dump (stdin via # `--tests-json -`) and keys every enumerated test by an environment # fingerprint plus the content of its definition: command elements that are # files (the test binary, and the emulator for cross-compiled jobs) are # replaced by a digest of their bytes, remaining elements and the CTest # properties are taken literally with build-directory paths normalized away. # Tests whose key the manifest already records as a first-attempt pass # become the anchored exclusion regex in --skip-regex-out; every keyable # test goes to --keys-out for the later `record` step. # # The environment fingerprint covers everything that can change a test's # outcome while its binary stays identical: the image and its C library, the # checked-in CI configuration (hashed deliberately over-broadly -- the whole # ci/ tree plus .gitlab-ci.yml -- so an MR editing QEMU_CPU, EIGEN_REPEAT, # sanitizer options or these scripts invalidates its own skips), the dpkg # version state of every lib* package (a superset of anything a test binary # can dynamically load, cross sysroots included), and KEYED_ENV_PREFIXES # variables read directly for values set outside the tree. Job-to-job # differences are already isolated by the per-job-name GitLab cache key; # the fingerprint guards against one job's environment drifting over time. # A tool that is genuinely absent (e.g. no dpkg on the hand-assembled # riscv64 image) contributes a fixed sentinel so images with and without it # never share keys; unexpected errors propagate, `plan` fails, and the # driving script then runs the full selection. # # `record` folds the run's first-attempt results back into the manifest, # using the dashboard run's Test.xml as the authoritative status source: # only tests with Status="passed" are recorded ("failed" and "notrun" cover # failures, timeouts, missing executables, and SKIP_RETURN_CODE skips). # New passes are appended, entries that proved useful again (skipped or # re-passed) move to the end, and the file is trimmed to --max-entries, so # it ages out roughly least-recently-useful first. # # A test is keyable only if its command ends in an executable inside the # build directory. Anything else (e.g. a test that invokes the compiler on # tree sources) has an outcome that depends on more than the artifact's # content and must always run. import argparse import functools import hashlib import json import os import re import subprocess import sys import xml.etree.ElementTree as ElementTree # Environment variables (matched by prefix) that can change a test's outcome # without changing its binary. Host-derived values that legitimately vary # across runners of one job (EIGEN_CI_CTEST_PARALLEL, NPROC) are deliberately # absent. KEYED_ENV_PREFIXES = ( "EIGEN_REPEAT", "EIGEN_SEED", "EIGEN_CI_CTEST_ARGS", "QEMU_", "ASAN_", "UBSAN_", "LSAN_", "TSAN_", "MSAN_", "LD_LIBRARY_PATH", "LD_PRELOAD", ) @functools.lru_cache(maxsize=None) def file_digest(path): with open(path, "rb") as f: return hashlib.file_digest(f, "sha256").hexdigest() def fingerprint(config_root): parts = ["image:" + os.environ.get("CI_JOB_IMAGE", "")] try: parts.append("libc:" + (os.confstr("CS_GNU_LIBC_VERSION") or "")) except (ValueError, OSError): parts.append("libc:unavailable") config = hashlib.sha256() for top in ("ci", ".gitlab-ci.yml"): top_path = os.path.join(config_root, top) if os.path.isfile(top_path): config.update(top.encode() + b"\0" + file_digest(top_path).encode() + b"\0") continue for dirpath, dirnames, filenames in os.walk(top_path): dirnames.sort() for filename in sorted(filenames): path = os.path.join(dirpath, filename) rel = os.path.relpath(path, config_root) config.update(rel.encode() + b"\0" + file_digest(path).encode() + b"\0") parts.append("config:" + config.hexdigest()) try: packages = subprocess.run(["dpkg-query", "-W", "lib*"], check=True, capture_output=True, text=True).stdout parts.append("libs:" + hashlib.sha256(packages.encode()).hexdigest()) except (FileNotFoundError, subprocess.CalledProcessError): parts.append("libs:unavailable") env = ("%s=%s" % (k, v) for k, v in os.environ.items() if k.startswith(KEYED_ENV_PREFIXES)) parts.append("env:" + ",".join(sorted(env))) return "|".join(parts) def normalize(text, builddir): """Strips the build-directory prefix so the key does not depend on where the checkout happens to live.""" return text.replace(builddir, ".") def test_key(test, fingerprint_bytes, builddir): """Digest of the fingerprint and the test's definition, or None if the command does not end in an executable under the build directory.""" command = test.get("command") if not command: return None last = os.path.realpath(command[-1]) if not (last.startswith(builddir + os.sep) and os.path.isfile(last) and os.access(last, os.X_OK)): return None h = hashlib.sha256() h.update(fingerprint_bytes) for element in command: path = os.path.realpath(element) if os.path.isfile(path): h.update(b"file:" + file_digest(path).encode() + b"\0") else: h.update(b"arg:" + normalize(element, builddir).encode() + b"\0") properties = sorted(test.get("properties", []), key=lambda p: str(p.get("name"))) h.update(b"props:" + normalize(json.dumps(properties, sort_keys=True), builddir).encode()) return h.hexdigest() def read_manifest(path): """The manifest as an insertion-ordered {key: testname} dict.""" entries = {} if os.path.exists(path): with open(path) as f: for line in f: fields = line.split() if fields: entries[fields[0]] = fields[1] if len(fields) > 1 else "" return entries def write_manifest(path, entries, max_entries): tmp = path + ".tmp" with open(tmp, "w") as f: f.writelines("%s %s\n" % item for item in list(entries.items())[-max_entries:]) os.replace(tmp, path) def plan(args): with (sys.stdin if args.tests_json == "-" else open(args.tests_json)) as f: tests = json.load(f)["tests"] manifest = read_manifest(args.manifest) builddir = os.path.realpath(os.getcwd()) fingerprint_bytes = fingerprint(args.config_root).encode() + b"\0" keys = {} skip = [] for test in tests: key = test_key(test, fingerprint_bytes, builddir) if key is None: continue keys[test["name"]] = key if key in manifest: skip.append(test["name"]) with open(args.keys_out, "w") as f: f.writelines("%s %s\n" % (key, name) for name, key in keys.items()) with open(args.skip_regex_out, "w") as f: if skip: f.write("^(%s)$" % "|".join(re.escape(name) for name in skip)) print( "test cache plan: %d tests selected, %d cached passes to skip, %d to run (%d not keyable)" % (len(tests), len(skip), len(tests) - len(skip), len(tests) - len(keys)) ) def record(args): keys = {} with open(args.keys) as f: for line in f: fields = line.split() if len(fields) == 2: keys[fields[1]] = fields[0] # The dashboard elements carry a Status attribute ("passed", # "failed", or "notrun"); the bare entries do not and are # ignored. Streamed with iterparse because --no-compress-output embeds # every test's stdout in the file. A missing or unparsable Test.xml # means ctest died without completing the test phase: record nothing # rather than trust it. passed = set() try: with open(os.path.join(args.testing_dir, "TAG")) as f: tag = f.readline().strip() for _, element in ElementTree.iterparse(os.path.join(args.testing_dir, tag, "Test.xml")): if element.tag != "Test": continue if element.get("Status") == "passed": name = element.find("Name") if name is not None: passed.add(name.text) element.clear() except (OSError, ElementTree.ParseError) as error: print("test cache record: cannot read test results (%s); recording nothing" % error) return manifest = read_manifest(args.manifest) new = refreshed = 0 for name, key in keys.items(): if name in passed: if manifest.pop(key, None) is None: new += 1 else: refreshed += 1 manifest[key] = name elif manifest.pop(key, None) is not None: # Not run because this entry made plan skip it; move the entry to # the back so trimming ages out unused entries first. manifest[key] = name refreshed += 1 write_manifest(args.manifest, manifest, args.max_entries) print( "test cache record: %d new first-attempt passes, %d entries refreshed, %d entries total" % (new, refreshed, len(manifest)) ) def main(): parser = argparse.ArgumentParser(description=__doc__) sub = parser.add_subparsers(dest="cmd", required=True) p = sub.add_parser("plan") p.add_argument("--tests-json", required=True, help="json-v1 dump, or - for stdin") p.add_argument("--manifest", required=True) p.add_argument("--config-root", required=True) p.add_argument("--skip-regex-out", required=True) p.add_argument("--keys-out", required=True) p.set_defaults(func=plan) p = sub.add_parser("record") p.add_argument("--manifest", required=True) p.add_argument("--keys", required=True) p.add_argument("--testing-dir", required=True) p.add_argument("--max-entries", type=int, default=20000) p.set_defaults(func=record) args = parser.parse_args() args.func(args) if __name__ == "__main__": main()