| #!/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 |
| # CONFIG_PATHS subtrees of the checked-in CI configuration, 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 |
| |
| # Paths under the config root whose content can change a test's outcome while |
| # its binary stays identical. Deliberately not the whole ci/ tree: the |
| # *.gitlab-ci.yml files are orchestration, and everything in them that reaches |
| # a test outcome reaches this key by value already -- job variables through |
| # KEYED_ENV_PREFIXES, the image through CI_JOB_IMAGE, compiler flags and the |
| # cross emulator through the digests of the files in the test's command, ctest |
| # timeouts through the properties hash. Hashing the file that sets them only |
| # means every CI-maintenance merge request throws away every job's manifest. |
| # |
| # ci/docker/ stays: those images are referenced by a moving :latest tag, so |
| # CI_JOB_IMAGE cannot see a rebuild, and the dpkg-query below covers only lib* |
| # packages, not a non-lib* one such as qemu-user. |
| # |
| # This is a whole-directory walk rather than a list of the scripts on the test |
| # path, because a list is complete only until someone adds a script and forgets |
| # to name it here -- and that failure mode is a silently reused stale pass. |
| # |
| # The exchange for the narrowing: a job's tags: are now invisible to the key. |
| # Moving a job to a runner pool whose CPU differs (an AVX-512 job off the |
| # avx512 pool, say) will not invalidate its manifest, so pair such a move with |
| # a cache clear. Nothing today distinguishes two hosts within one tag pool |
| # either, so this widens an accepted hole rather than opening a new one. |
| CONFIG_PATHS = ("ci/scripts", "ci/docker") |
| |
| # Environment variables (matched by prefix) that can change a test's outcome |
| # without changing its binary. Since the CI YAML is no longer hashed, this |
| # tuple is load-bearing: a new job variable that can change a test's outcome |
| # must be added here, because setting it in the YAML alone no longer keys it. |
| KEYED_ENV_PREFIXES = ( |
| "EIGEN_REPEAT", |
| "EIGEN_SEED", |
| "EIGEN_CI_CTEST_ARGS", |
| "QEMU_", |
| "ASAN_", |
| "UBSAN_", |
| "LSAN_", |
| "TSAN_", |
| "MSAN_", |
| "LD_LIBRARY_PATH", |
| "LD_PRELOAD", |
| # Host-derived values that legitimately vary across runners of one job |
| # (EIGEN_CI_CTEST_PARALLEL, NPROC) are deliberately absent. |
| ) |
| |
| |
| @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 CONFIG_PATHS: |
| 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 <Test> elements carry a Status attribute ("passed", |
| # "failed", or "notrun"); the bare <TestList> 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() |