libeigen/eigen!2798 Co-authored-by: Rasmus Munk Larsen <rmlarsen@gmail.com>
257 lines
10 KiB
Python
257 lines
10 KiB
Python
#!/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 <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()
|