blob: ec9c9d695cd0d8b559f6f74f30b249171601d282 [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Run the focused FlatBuffers ON gates; never accept empty or skipped tests."""
import argparse
import hashlib
import json
import os
from pathlib import Path, PurePosixPath
import re
import shlex
import shutil
import signal
import subprocess
import sys
import tarfile
import time
import xml.etree.ElementTree as ET
# Keep the FlatBuffers pin synchronized with MODULE.bazel and WORKSPACE.
FLATBUFFERS_VERSION = "25.2.10"
FLATBUFFERS_SHA256 = "b9c2df49707c57a48fc0923d52b8c73beb72d675f9d44b2211e4569be40a7421"
GTEST_VERSION = "1.14.0"
GTEST_SHA256 = "8ad598c73ad796e0d8280b082cebd82a630d73e73cd3c70057938a6501bba5d7"
TESTS = {"brpc_flatbuffers_unittest": 22, "brpc_flatbuffers_protocol_unittest": 43}
LAYOUT_PROBES = ("flatbuffers_layout_probe_on", "flatbuffers_layout_probe_off")
def list_gtests(text):
names = set()
suite = None
for line in text.splitlines():
value = line.split("#", 1)[0].strip()
if not value:
continue
if not line[0].isspace() and value.endswith("."):
suite = value
elif line[0].isspace() and suite and re.fullmatch(r"[A-Za-z0-9_/]+", value):
name = suite + value
if any(part.startswith("DISABLED_") for part in re.split(r"[./]", name)):
raise ValueError("Disabled test in ON gate: " + name)
if name in names:
raise ValueError("Duplicate discovered test: " + name)
names.add(name)
if not names:
raise ValueError("No GoogleTest cases discovered")
return names
def xml_cases(path):
path = Path(path)
if not path.is_file() or path.stat().st_size > 16 * 1024 * 1024:
raise ValueError("Missing or oversized test XML: " + str(path))
root = ET.parse(path).getroot()
for suite in root.iter():
if suite.tag in ("testsuites", "testsuite"):
for attribute in ("failures", "errors", "disabled", "skipped"):
if int(suite.get(attribute, "0")) != 0:
raise ValueError("Nonzero " + attribute + " in " + str(path))
cases = list(root.iter("testcase"))
if not cases:
raise ValueError("Test XML contains no cases: " + str(path))
for case in cases:
if (case.find("failure") is not None or case.find("error") is not None or
case.find("skipped") is not None or case.get("status") == "notrun" or
case.get("result") in ("skipped", "suppressed")):
raise ValueError("Failed or unexecuted case: " + str(case.attrib))
return root, cases
def verify_gtests(xml, listing, expected_count):
expected = list_gtests(Path(listing).read_text())
root, cases = xml_cases(xml)
actual = [case.get("classname", "") + "." + case.get("name", "") for case in cases]
if len(expected) != expected_count or len(actual) != len(set(actual)) or set(actual) != expected:
raise ValueError("Executed cases differ from discovery or the expected count: " + str(xml))
if int(root.get("tests", "-1")) != len(actual):
raise ValueError("Incorrect XML test count: " + str(xml))
return {"executed": len(actual), "failed": 0, "skipped": 0}
def verify_ctest(xml, required):
_, cases = xml_cases(xml)
names = [case.get("name", "") for case in cases]
if len(names) != len(set(names)) or not set(required) <= set(names):
raise ValueError("Required CTest cases did not execute: " + str(required))
return {"executed": len(names), "names": names, "failed": 0, "skipped": 0}
def extract_archive(archive, digest, destination, prefix):
archive = Path(archive)
if hashlib.sha256(archive.read_bytes()).hexdigest() != digest:
raise ValueError("Dependency checksum mismatch: " + str(archive))
destination = Path(destination)
if destination.exists():
raise ValueError("Refusing to overwrite dependency directory")
with tarfile.open(archive, "r:gz") as source:
members = source.getmembers()
if len(members) > 30000 or sum(item.size for item in members) > 512 * 1024 * 1024:
raise ValueError("Dependency archive exceeds extraction limits")
paths = set()
links = {}
for member in members:
path = PurePosixPath(member.name)
if (not path.parts or path.is_absolute() or ".." in path.parts or
path.parts[0] != prefix or path in paths or
not (member.isdir() or member.isfile() or member.issym())):
raise ValueError("Unsafe dependency archive member: " + member.name)
paths.add(path)
if member.issym():
if len(path.parts) < 2:
raise ValueError("Archive root must not be a symlink")
links[path] = member
for path in paths:
if any(parent in links for parent in path.parents):
raise ValueError("Archive member traverses a symlink: " + str(path))
for path, member in links.items():
target = PurePosixPath(member.linkname)
if not member.linkname or target.is_absolute():
raise ValueError("Unsafe archive symlink: " + member.name)
parts = list(path.parent.parts)
for part in target.parts:
if part == "..":
if len(parts) <= 1:
raise ValueError("Escaping archive symlink: " + member.name)
parts.pop()
else:
parts.append(part)
if not parts or parts[0] != prefix or PurePosixPath(*parts) in links:
raise ValueError("Escaping or chained archive symlink: " + member.name)
destination.mkdir(parents=True)
# Create links only after all regular data, so extraction never writes
# through a symlink. The pinned archives need no hardlinks/link chains.
source.extractall(destination, members=[member for member in members if not member.issym()])
for path, member in links.items():
link = destination / str(path)
link.parent.mkdir(parents=True, exist_ok=True)
link.symlink_to(member.linkname)
return destination / prefix
def check_enabled(header):
if not re.search(r"^\s*#\s*define\s+BRPC_WITH_FLATBUFFERS\s+1\s*$",
Path(header).read_text(), re.MULTILINE):
raise ValueError("FlatBuffers is not enabled in " + str(header))
def compare_layouts(on_text, off_text):
"""Require the ON and OFF probes to report the same class layout."""
on_lines = on_text.splitlines()
off_lines = off_text.splitlines()
if (not on_lines or not off_lines or on_lines[0] != "BRPC_WITH_FLATBUFFERS=1" or
off_lines[0] != "BRPC_WITH_FLATBUFFERS=0"):
raise ValueError("Layout probes did not run with the ON and OFF configurations")
if len(on_lines) < 2 or on_lines[1:] != off_lines[1:]:
raise ValueError("Class layout differs between FlatBuffers ON and OFF builds")
return {"entries": len(on_lines) - 1}
def signal_group(process, signum):
try:
os.killpg(process.pid, signum)
return True
except ProcessLookupError:
return False
def stop_process(process, grace=5):
# The group may outlive its leader; waiting for the leader alone is not
# sufficient when a compiler/test child ignores SIGTERM.
if signal_group(process, signal.SIGTERM):
try:
process.wait(timeout=grace)
except subprocess.TimeoutExpired:
pass
signal_group(process, signal.SIGKILL)
process.wait()
class Runner:
def __init__(self, args):
self.args = args
self.source = args.source.resolve(strict=True)
self.work = args.work.resolve()
if self.work == self.source or self.source in self.work.parents:
raise ValueError("--work must be outside the source checkout")
self.work.mkdir(parents=True, exist_ok=False)
self.evidence = self.work / "evidence"
self.evidence.mkdir()
self.env = dict(os.environ)
for name in list(self.env):
if name.startswith("GTEST_"):
self.env.pop(name)
self.env.update(GTEST_FILTER="*", GTEST_REPEAT="1")
self.steps = []
self.results = {}
self.compiler = self.env.get("CC", "cc")
self.cxx = self.env.get("CXX", "c++")
self.prefixes = [path.resolve(strict=True) for path in args.dependency_prefix]
def save(self, status):
(self.evidence / "summary.json").write_text(json.dumps({
"status": status, "system": self.args.build_system,
"source": str(self.source), "steps": self.steps, "tests": self.results,
}, indent=2) + "\n")
def step(self, name, command, cwd=None, timeout=900, env=None):
command = [str(part) for part in command]
log = self.evidence / (name + ".log")
directory = Path(cwd or self.source)
(self.evidence / (name + ".command")).write_text(
"cwd=" + str(directory) + "\n" + shlex.join(command) + "\n")
print("START", name, flush=True)
start = time.monotonic()
code = 1
with log.open("w") as output:
process = subprocess.Popen(command, cwd=directory, env=env or self.env,
stdout=output, stderr=subprocess.STDOUT,
start_new_session=True)
try:
code = process.wait(timeout=timeout)
except subprocess.TimeoutExpired:
stop_process(process)
code = 124
print("Step exceeded timeout", file=output)
except BaseException:
stop_process(process)
raise
finally:
self.steps.append({"name": name, "exitcode": code,
"seconds": round(time.monotonic() - start, 3)})
(self.evidence / (name + ".exitcode")).write_text(str(code) + "\n")
self.save("running")
print("END", name, "exit=" + str(code), flush=True)
if code != 0:
raise RuntimeError(name + " failed; see " + str(log))
return log
def download(self, name, version, digest):
archive = self.work / (name + ".tar.gz")
self.step("download-" + name, ["curl", "--fail", "--location", "--retry", "2",
"--connect-timeout", "20", "--max-time", "180",
"https://github.com/google/" + name + "/archive/refs/tags/v" + version + ".tar.gz",
"--output", archive], timeout=600)
return extract_archive(archive, digest, self.work / (name + "-source"), name + "-" + version)
def prepare_dependencies(self):
if self.args.flatbuffers_prefix:
self.fb = self.args.flatbuffers_prefix.resolve(strict=True)
else:
source = self.download("flatbuffers", FLATBUFFERS_VERSION, FLATBUFFERS_SHA256)
self.fb = self.work / "dependencies"
build = self.work / "flatbuffers-build"
self.step("flatbuffers-configure", ["cmake", "-S", source, "-B", build,
"-DCMAKE_BUILD_TYPE=Release", "-DCMAKE_POSITION_INDEPENDENT_CODE=ON",
"-DCMAKE_INSTALL_LIBDIR=lib", "-DCMAKE_INSTALL_PREFIX=" + str(self.fb),
"-DFLATBUFFERS_BUILD_TESTS=OFF", "-DFLATBUFFERS_BUILD_FLATC=ON",
"-DFLATBUFFERS_BUILD_FLATLIB=ON", "-DFLATBUFFERS_BUILD_SHAREDLIB=OFF",
"-DFLATBUFFERS_INSTALL=ON", "-DFLATBUFFERS_LIBCXX_WITH_CLANG=OFF"])
self.step("flatbuffers-build", ["cmake", "--build", build, "--parallel", self.args.jobs])
self.step("flatbuffers-install", ["cmake", "--install", build])
self.flatc = self.fb / "bin/flatc"
version = self.step("flatc-version", [self.flatc, "--version"], timeout=10).read_text().strip()
base = (self.fb / "include/flatbuffers/base.h").read_text()
numbers = [re.search(r"#define\s+FLATBUFFERS_VERSION_" + part + r"\s+(\d+)", base)
for part in ("MAJOR", "MINOR", "REVISION")]
if (version != "flatc version " + FLATBUFFERS_VERSION or
not all(numbers) or ".".join(match.group(1) for match in numbers) != FLATBUFFERS_VERSION):
raise ValueError("Use matching pinned FlatBuffers headers and compiler")
self.step("rpc-meta-header", ["bash", self.source / "tools/flatbuffers/gen_rpc_meta.sh",
"--check", self.flatc], timeout=60)
self.fb_library = self.fb / "lib/libflatbuffers.a"
if not self.fb_library.is_file():
raise ValueError("The generator needs " + str(self.fb_library))
self.gtest = (self.args.gtest_source.resolve(strict=True) if self.args.gtest_source else
self.download("googletest", GTEST_VERSION, GTEST_SHA256))
self.prefixes.insert(0, self.fb)
if self.args.build_system == "make":
build = self.work / "gtest-build"
gtest_prefix = self.work / "gtest-prefix"
self.step("gtest-configure", ["cmake", "-S", self.gtest, "-B", build,
"-DCMAKE_BUILD_TYPE=Release", "-DCMAKE_INSTALL_LIBDIR=lib",
"-DBUILD_SHARED_LIBS=OFF", "-DCMAKE_INSTALL_PREFIX=" + str(gtest_prefix)])
self.step("gtest-build", ["cmake", "--build", build, "--parallel", self.args.jobs])
self.step("gtest-install", ["cmake", "--install", build])
self.prefixes.insert(0, gtest_prefix)
self.env["PATH"] = os.pathsep.join(str(prefix / "bin") for prefix in self.prefixes) + os.pathsep + self.env.get("PATH", "")
self.step("protoc-version", [self.args.protoc or shutil.which("protoc", path=self.env["PATH"]), "--version"], timeout=10)
def common_cmake(self):
options = ["-DCMAKE_BUILD_TYPE=RelWithDebInfo", "-DCMAKE_POLICY_VERSION_MINIMUM=3.5",
"-DCMAKE_PREFIX_PATH=" + ";".join(str(prefix) for prefix in self.prefixes),
"-DCMAKE_C_COMPILER=" + self.compiler, "-DCMAKE_CXX_COMPILER=" + self.cxx]
for prefix in self.prefixes:
if (prefix / "include/openssl/ssl.h").is_file():
options.append("-DOPENSSL_ROOT_DIR=" + str(prefix))
break
if self.args.protoc:
options.append("-DProtobuf_PROTOC_EXECUTABLE=" + str(self.args.protoc.resolve(strict=True)))
return options
def check_gtest(self, name, binary, ctest_build=None):
listing = self.step(name + "-list", [binary, "--gtest_list_tests"], timeout=30)
xml = self.evidence / (name + ".xml")
env = dict(self.env, GTEST_OUTPUT="xml:" + str(xml))
command = (["ctest", "--test-dir", ctest_build, "--no-tests=error", "--output-on-failure",
"--timeout", "300", "-R", "^" + name + "$"] if ctest_build else [binary])
self.step(name, command, timeout=330, env=env)
self.results[name] = verify_gtests(xml, listing, TESTS[name])
self.save("running")
def ctest(self, name, build, required):
xml = self.evidence / (name + ".xml")
self.step(name, ["ctest", "--test-dir", build, "--no-tests=error", "--output-on-failure",
"--timeout", "300", "--output-junit", xml], timeout=600)
self.results[name] = verify_ctest(xml, required)
self.save("running")
def run_cmake(self):
build = self.work / "build"
runtime = self.work / "runtime"
self.step("configure", ["cmake", "-S", self.source, "-B", build] + self.common_cmake() + [
"-DCMAKE_INSTALL_PREFIX=" + str(runtime), "-DINSTALL_GTEST=OFF", "-DWITH_FLATBUFFERS=ON", "-DBUILD_UNIT_TESTS=ON", "-DBUILD_BRPC_TOOLS=OFF",
"-DBUILD_SHARED_LIBS=ON",
"-DDOWNLOAD_GTEST=OFF", "-DBRPC_SYSTEM_GTEST_SOURCE_DIR=" + str(self.gtest),
"-DFLATBUFFERS_INCLUDE_DIR=" + str(self.fb / "include"),
"-DFLATBUFFERS_FLATC_EXECUTABLE=" + str(self.flatc)])
self.step("build", ["cmake", "--build", build, "--target", *TESTS, "brpc-shared",
*LAYOUT_PROBES, "--parallel", self.args.jobs])
self.step("install", ["cmake", "--install", build])
check_enabled(runtime / "include/butil/config.h")
layouts = [self.step(name, [build / "test" / name], timeout=30).read_text()
for name in LAYOUT_PROBES]
self.results["layout"] = compare_layouts(*layouts)
self.save("running")
for name in TESTS:
self.check_gtest(name, build / "test" / name, build)
codegen = self.work / "codegen"
shared_library = "libbrpc.dylib" if sys.platform == "darwin" else "libbrpc.so"
self.step("codegen-configure", ["cmake", "-S", self.source / "tools/flatbuffers", "-B", codegen] + self.common_cmake() + [
"-DBUILD_TESTING=ON", "-DBRPC_CODEGEN_BRPC_LIBRARY=" + str(build / "output/lib" / shared_library),
"-DFLATBUFFERS_INCLUDE_DIR=" + str(self.fb / "include"),
"-DFLATBUFFERS_LIBRARY=" + str(self.fb_library), "-DFLATC_EXECUTABLE=" + str(self.flatc)])
self.step("codegen-build", ["cmake", "--build", codegen, "--parallel", self.args.jobs])
self.ctest("codegen", codegen, {"flatbuffers_codegen_acceptance", "flatbuffers_codegen_runtime"})
example = self.work / "example"
self.step("example-configure", ["cmake", "-S", self.source / "example/benchmark_fb", "-B", example] + self.common_cmake() + [
"-DBUILD_TESTING=ON", "-DBRPC_ROOT=" + str(runtime),
"-DFLATBUFFERS_INCLUDE_DIR=" + str(self.fb / "include"),
"-DFLATC_EXECUTABLE=" + str(self.flatc),
"-DBRPC_FLATC_EXECUTABLE=" + str(codegen / "brpc_flatc")])
self.step("example-build", ["cmake", "--build", example, "--parallel", self.args.jobs])
self.ctest("example", example, {"benchmark_fb_smoke"})
def copy_source(self):
listing = self.step("source-files", ["git", "ls-files", "--cached", "--others", "--exclude-standard", "-z"])
destination = self.work / "source"
destination.mkdir()
count = 0
for name in set(listing.read_bytes().split(b"\0")):
if not name:
continue
relative = Path(os.fsdecode(name))
path = self.source / relative
if (relative.is_absolute() or ".." in relative.parts or path.is_symlink() or
self.source not in path.resolve().parents):
raise ValueError("Unsafe checkout path: " + str(relative))
if not path.exists():
continue
if not path.is_file():
raise ValueError("Only regular source files are supported: " + str(relative))
target = destination / relative
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(path, target)
count += 1
if not count:
raise ValueError("The source must be a nonempty Git checkout")
self.env["CCACHE_BASEDIR"] = str(destination)
return destination
def run_make(self):
source = self.copy_source()
headers = [str(prefix / "include") for prefix in self.prefixes] + ["/usr/include"]
libs = [str(prefix / "lib") for prefix in self.prefixes] + ["/usr/lib", "/usr/lib64"]
self.step("configure", ["sh", "config_brpc.sh", "--with-flatbuffers",
"--headers=" + " ".join(headers), "--libs=" + " ".join(libs),
"--cc=" + self.compiler, "--cxx=" + self.cxx], cwd=source)
self.step("build", ["make", "-j" + str(self.args.jobs)], cwd=source)
check_enabled(source / "output/include/butil/config.h")
self.step("test-build", ["make", "-C", "test", "-j" + str(self.args.jobs),
"FLATC=" + str(self.flatc), *TESTS], cwd=source)
variable = "DYLD_LIBRARY_PATH" if sys.platform == "darwin" else "LD_LIBRARY_PATH"
self.env[variable] = os.pathsep.join([str(source / "test")] +
[str(prefix / "lib") for prefix in self.prefixes])
for name in TESTS:
self.check_gtest(name, source / "test" / name)
def run_bazel(self):
source = self.copy_source()
command = ["bazel", "--batch", "--output_user_root=" + str(self.work / "bazel-state"),
"test", "--define=BRPC_WITH_FLATBUFFERS=true", "--jobs=" + str(self.args.jobs),
"--local_test_jobs=1", "--test_timeout=300", "--cache_test_results=no",
"--runs_per_test=1", "--flaky_test_attempts=1", "--test_output=errors",
"--test_env=GTEST_FILTER=*", "--test_env=GTEST_REPEAT=1"]
try:
self.step("bazel-tests", command + ["//test:" + name for name in TESTS], cwd=source, timeout=1800)
finally:
for name in TESTS:
for filename in ("test.xml", "test.log"):
path = source / "bazel-testlogs/test" / name / filename
if path.is_file():
shutil.copy2(path, self.evidence / (name + "." + filename))
for name in TESTS:
listing = self.step(name + "-list", [source / "bazel-bin/test" / name, "--gtest_list_tests"], cwd=source, timeout=30)
self.results[name] = verify_gtests(self.evidence / (name + ".test.xml"), listing, TESTS[name])
self.save("running")
def run(self):
self.step("compiler-version", [self.cxx, "--version"], timeout=10)
self.step("cmake-version", ["cmake", "--version"], timeout=10)
if self.args.build_system == "bazel":
self.step("bazel-version", ["bazel", "--version"], timeout=30)
self.run_bazel()
else:
self.prepare_dependencies()
getattr(self, "run_" + self.args.build_system)()
self.save("passed")
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--build-system", choices=("cmake", "make", "bazel"), required=True)
parser.add_argument("--source", type=Path, default=Path(__file__).resolve().parents[2])
parser.add_argument("--work", type=Path, required=True)
parser.add_argument("--jobs", type=int, default=2)
parser.add_argument("--flatbuffers-prefix", type=Path)
parser.add_argument("--gtest-source", type=Path)
parser.add_argument("--dependency-prefix", type=Path, action="append", default=[])
parser.add_argument("--protoc", type=Path)
args = parser.parse_args()
if not 1 <= args.jobs <= 16:
parser.error("--jobs must be in 1..16")
runner = None
try:
runner = Runner(args)
runner.run()
except (Exception, KeyboardInterrupt) as error:
if runner:
runner.save("failed")
(runner.evidence / "failure.txt").write_text(str(error) + "\n")
print("FlatBuffers ON gate failed:", error, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
sys.exit(main())