This is an automated email from the ASF dual-hosted git repository. tqchen pushed a commit to branch jenkins-sccache-diagnostics in repository https://gitbox.apache.org/repos/asf/tvm.git
commit d7ed6808067305935ca8f33b06425f302fc2ece6 Author: Tianqi Chen <[email protected]> AuthorDate: Fri Oct 16 01:27:23 2026 +0000 [CI] Add isolated Jenkins sccache diagnostics --- ci/README.md | 56 +++++ ci/jenkins/generated/cpu_jenkinsfile.groovy | 61 ++---- tests/python/ci/test_sccache_diagnostics.py | 150 +++++++++++++ tests/scripts/task_sccache_diagnostics.py | 316 ++++++++++++++++++++++++++++ 4 files changed, 541 insertions(+), 42 deletions(-) diff --git a/ci/README.md b/ci/README.md index e617d7af91..aa9b7a98c0 100644 --- a/ci/README.md +++ b/ci/README.md @@ -93,3 +93,59 @@ Other jobs run in GitHub Actions for Windows and MacOS jobs. The native Groovy entry points in `ci/jenkins/generated/` load shared helpers from `common.groovy`. Edit these sources directly; no generation step is needed. See comments in [common.groovy](jenkins/generated/common.groovy) for shared utility options and trust constraints. + +## Sccache diagnostic branch + +The CPU job on `jenkins-sccache-diagnostics` runs a host IMDS probe and then +runs IMDS plus a small S3 cache roundtrip in the configured CPU image through +`docker/bash.sh`. It uses the wrapper's existing network settings; it does not +change instance metadata options, IAM policies, or Docker networking. + +On the affected worker, the equivalent commands are: + +```bash +python3 tests/scripts/task_sccache_diagnostics.py --scope host +docker/bash.sh <current-ci-cpu-image> python3 tests/scripts/task_sccache_diagnostics.py \ + --scope container --bucket tvm-sccache-prod --region us-west-2 +``` + +The probe requests an IMDSv2 token, lists the instance role, and checks that a +credential response contains the required fields. Only HTTP statuses, elapsed +times, and availability booleans are printed. If the token request fails, an +unauthenticated role-list request records whether IMDS returns an HTTP response +(such as 401) or also times out. No token, role name, credential value, raw +subprocess output, or raw error log is printed or archived. + +The cache probe requires sccache with Unix-domain socket support (the image +installer pins 0.14.0) and `/usr/bin/gcc`. It resets sccache-specific settings, +selects the given S3 bucket and region, and retains the current AWS credential +sources. It does not forward host credentials into Docker. A private temporary +directory and socket isolate its server from other builds. A unique tiny C +source causes a cache write; a second compile after restarting that server must +record an S3 hit. This leaves small cache/probe objects in the bucket, subject to its existing +lifecycle policy. It does not delete cache objects. + +Each metadata request has a three-second socket timeout, each sccache command +has a 30-second process timeout, and the Jenkins stage has a ten-minute bound. +Temporary error logs are reduced to fixed error categories and then removed. +The final `s3_roundtrip` value is true only when the backend is S3, the first +compile writes successfully, and the second server reports a cache hit without +cache read/write errors. Compile success alone does not establish cache access. + +Interpret the host/container results separately from the S3 result: + +- Host token success and container token timeout indicate a difference in the + metadata network path. An IMDSv2 response hop limit is one possible cause; + this probe does not read or establish the configured hop limit. +- HTTP 401/403 responses show that an HTTP path exists. Missing role metadata + or unavailable credential fields are separate from a network timeout. +- Successful metadata credentials followed by an S3 authorization error marker + points toward credential/bucket policy or signing problems. If other AWS + credential sources are present, they may take precedence over IMDS. +- S3 network timeouts and authorization markers are reported separately. Error + categories are recognized markers, not a full root-cause diagnosis; an + unclassified failure remains unresolved. + +A failed probe makes the diagnostic CPU job unstable. The other platform jobs +retain their upstream behavior. A local non-EC2 run cannot establish the +production Jenkins worker's metadata or S3 configuration. diff --git a/ci/jenkins/generated/cpu_jenkinsfile.groovy b/ci/jenkins/generated/cpu_jenkinsfile.groovy index 44aceb116d..c262483bc1 100644 --- a/ci/jenkins/generated/cpu_jenkinsfile.groovy +++ b/ci/jenkins/generated/cpu_jenkinsfile.groovy @@ -27,49 +27,26 @@ try { ci = evaluate(helper_source) ci.start() -stage('Build') { - ci.with_node(instance: 'CPU-SPOT', retry_instance: 'CPU', - name: 'BUILD: CPU', folder: 'tvm/build-cpu', variables: ['PLATFORM=cpu']) { - ci.docker_init(images.ci_cpu) - sh( - script: "${docker_run} ${images.ci_cpu} ./tests/scripts/task_config_build_cpu.sh build", - label: 'Create CPU CMake config', - ) - sh ( - script: "${docker_run} --env CI_NUM_EXECUTORS ${images.ci_cpu} ./tests/scripts/task_build.py --sccache-bucket tvm-sccache-prod --sccache-region us-west-2 --build-dir build", - label: 'Run CMake build', - ) - sh ( - script: """ - set -eux - ${docker_run} ${images.ci_cpu} python3 ./tests/scripts/task_build.py \ - --sccache-bucket tvm-sccache-prod \ - --sccache-region us-west-2 \ - --cmake-target cpptest \ - --cmake-target tirx_cpptest \ - --build-dir build - """, - label: 'Make C++ tests', - ) - ci.upload_artifacts('cpu', '--artifact tvm_lib --artifact cpptest') - } -} - -stage('Test') { - parallel('unittest: CPU': { - ci.with_node(instance: 'CPU-SPOT', retry_instance: 'CPU', - name: 'unittest: CPU', folder: 'tvm/ut-python-cpu', - variables: ['PLATFORM=cpu', 'TEST_STEP_NAME=unittest: CPU']) { - ci.docker_init(images.ci_cpu) - ci.download_artifacts('cpu') - sh ( - script: "${docker_run} --env CI_NUM_EXECUTORS ${images.ci_cpu} ./tests/scripts/task_cpp_unittest.sh", - label: 'Run C++ tests', +// This diagnostics branch runs only probes in the CPU job. +stage('sccache diagnostics') { + ci.with_node(instance: 'CPU', name: 'sccache diagnostics', + folder: 'tvm/sccache-diagnostics', variables: ['PLATFORM=cpu']) { + timeout(time: 10, unit: 'MINUTES') { + def host_status = sh( + script: 'python3 tests/scripts/task_sccache_diagnostics.py --scope host', + label: 'Host IMDS status (no token or credential output)', + returnStatus: true, ) - sh ( - script: "${docker_run} ${images.ci_cpu} ./tests/scripts/task_python_unittest.sh", - label: 'Run Python unit tests', + ci.docker_init(images.ci_cpu) + def container_status = sh( + script: "${docker_run} ${images.ci_cpu} python3 tests/scripts/task_sccache_diagnostics.py --scope container --bucket tvm-sccache-prod --region us-west-2", + label: 'Current Docker network IMDS and isolated S3 cache roundtrip', + returnStatus: true, ) + echo "Diagnostic exit codes: host=${host_status}, container=${container_status}" + if (host_status != 0 || container_status != 0) { + unstable('sccache diagnostic probes did not all pass; inspect the status summaries') + } } - }) + } } diff --git a/tests/python/ci/test_sccache_diagnostics.py b/tests/python/ci/test_sccache_diagnostics.py new file mode 100644 index 0000000000..16fbd63ae5 --- /dev/null +++ b/tests/python/ci/test_sccache_diagnostics.py @@ -0,0 +1,150 @@ +# 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. +"""Exercise diagnostic failure paths without contacting metadata or S3.""" + +import importlib.util +import json +import subprocess +import urllib.error +from pathlib import Path +from unittest import mock + +import pytest + +SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "task_sccache_diagnostics.py" +spec = importlib.util.spec_from_file_location("diagnostics", SCRIPT) +diag = importlib.util.module_from_spec(spec) +spec.loader.exec_module(diag) +SECRET = "sentinel-token-and-credential" + + +def test_metadata_success_never_prints_secrets(capsys): + responses = [ + (200, SECRET.encode()), + (200, b"role-name"), + ( + 200, + json.dumps( + { + "Code": "Success", + "AccessKeyId": SECRET, + "SecretAccessKey": SECRET, + "Token": SECRET, + } + ).encode(), + ), + ] + opener = mock.MagicMock() + opener.open.return_value.__enter__.side_effect = [ + mock.Mock(status=code, read=mock.Mock(return_value=body)) for code, body in responses + ] + with mock.patch.object(diag.urllib.request, "build_opener", return_value=opener): + assert diag.metadata_probe() + requests = [call.args[0] for call in opener.open.call_args_list] + assert requests[0].method == "PUT" + assert requests[-1].get_header("X-aws-ec2-metadata-token") == SECRET + assert SECRET not in capsys.readouterr().out + + +def test_metadata_timeout_is_not_authorization(capsys): + opener = mock.MagicMock() + opener.open.side_effect = [ + TimeoutError(SECRET), + urllib.error.HTTPError(diag.IMDS, 401, SECRET, {}, None), + ] + with mock.patch.object(diag.urllib.request, "build_opener", return_value=opener): + assert not diag.metadata_probe() + output = capsys.readouterr().out + assert "network_timeout" in output + assert '"http_status": 401' in output + assert SECRET not in output + + +def test_stats_and_errors_only_emit_allowlisted_values(): + summary = diag.stats_summary( + json.dumps( + { + "stats": { + "cache_hits": {"counts": {SECRET: 1}}, + "cache_writes": 1, + "cache_read_errors": SECRET, + "not_cached": {SECRET: 4}, + }, + "cache_location": "s3 " + SECRET, + "version": SECRET, + "basedirs": [SECRET], + } + ) + ) + assert summary["cache_hits"] == 1 + assert summary["s3_backend"] + assert SECRET not in json.dumps(summary) + assert diag.error_categories("AccessDenied " + SECRET) == ["authorization"] + assert diag.error_categories("metadata connect error timed out " + SECRET) == [ + "network_timeout", + "network_error", + ] + + +def test_process_timeout_does_not_echo_output(capsys, tmp_path): + with mock.patch.object( + diag.subprocess, + "run", + side_effect=subprocess.TimeoutExpired(SECRET, 30, output=SECRET, stderr=SECRET), + ): + assert diag.run_sccache("sccache", [], {}, tmp_path, "compile")[0] == 1 + output = capsys.readouterr().out + assert "process_timeout" in output + assert SECRET not in output + + [email protected]( + "writes,hits,write_errors,expected", [(1, 1, 0, True), (0, 0, 1, False), (1, 0, 0, False)] +) +def test_s3_roundtrip_restarts_server_and_keeps_credentials_private( + capsys, monkeypatch, writes, hits, write_errors, expected +): + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", SECRET) + monkeypatch.setenv("SCCACHE_SERVER_PORT", "4226") + monkeypatch.setenv("SCCACHE_IGNORE_SERVER_IO_ERROR", "1") + calls = [] + + def run(command, *, env, cwd, **kwargs): + calls.append(command[1:]) + assert env["AWS_SECRET_ACCESS_KEY"] == SECRET + assert "SCCACHE_SERVER_PORT" not in env + assert "SCCACHE_IGNORE_SERVER_IO_ERROR" not in env + assert env["SCCACHE_SERVER_UDS"].startswith(str(cwd)) + assert Path(env["SCCACHE_CONF"]).read_text() == "" + Path(env["SCCACHE_ERROR_LOG"]).write_text(SECRET) + stats = { + "cache_writes": writes, + "cache_hits": {"counts": {"C/C++": hits}}, + "cache_write_errors": write_errors, + } + output = json.dumps({"stats": stats, "cache_location": "s3 " + SECRET}).encode() + return subprocess.CompletedProcess(command, 0, output, SECRET.encode()) + + with ( + mock.patch.object(diag.shutil, "which", return_value="sccache"), + mock.patch.object(diag.subprocess, "run", side_effect=run), + ): + assert diag.cache_probe("test-bucket", "us-west-2", "/usr/bin/gcc") is expected + assert calls[3] == ["--stop-server"] + assert calls[4] == ["--start-server"] + assert calls[-1] == ["--stop-server"] + assert SECRET not in capsys.readouterr().out diff --git a/tests/scripts/task_sccache_diagnostics.py b/tests/scripts/task_sccache_diagnostics.py new file mode 100644 index 0000000000..a9335f93d1 --- /dev/null +++ b/tests/scripts/task_sccache_diagnostics.py @@ -0,0 +1,316 @@ +#!/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. +"""Bounded IMDS and S3 probes. Only allowlisted summaries reach the console.""" + +import argparse +import json +import os +import re +import shutil +import subprocess +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid +from pathlib import Path + +IMDS = "http://169.254.169.254/latest/" +COUNTERS = ( + "compile_requests", + "requests_executed", + "cache_timeouts", + "cache_read_errors", + "cache_write_errors", + "cache_writes", + "compile_fails", + "non_cacheable_compilations", +) + + +def emit(probe, **fields): + print(json.dumps({"probe": probe, **fields}, sort_keys=True), flush=True) + + +def metadata_request(probe, path, method="GET", headers=None): + # Metadata is link-local: bypass HTTP_PROXY without printing its value. + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + request = urllib.request.Request(IMDS + path, method=method, headers=headers or {}) + start = time.monotonic() + body = b"" + try: + with opener.open(request, timeout=3) as response: + body = response.read(16384) + result = {"http_status": response.status, "result": "response"} + except urllib.error.HTTPError as error: + result = {"http_status": error.code, "result": "http_error"} + except TimeoutError: + result = {"result": "network_timeout"} + except urllib.error.URLError as error: + result = { + "result": "network_timeout" + if isinstance(error.reason, TimeoutError) + else "network_error" + } + result["elapsed_ms"] = round((time.monotonic() - start) * 1000) + emit(probe, **result) + return result.get("http_status"), body + + +def metadata_probe(): + status, token = metadata_request( + "imds_token", "api/token", "PUT", {"X-aws-ec2-metadata-token-ttl-seconds": "60"} + ) + if status != 200 or not token: + # A 401 here proves an HTTP path exists even if the token PUT timed out. + metadata_request("imds_without_token", "meta-data/iam/security-credentials/") + return False + headers = {"X-aws-ec2-metadata-token": token.decode("ascii")} + status, roles = metadata_request( + "imds_role_list", "meta-data/iam/security-credentials/", headers=headers + ) + if status != 200 or not roles.strip(): + emit("imds_role", available=False) + return False + role = urllib.parse.quote(roles.decode("utf-8").splitlines()[0], safe="") + status, body = metadata_request( + "imds_credentials_http", "meta-data/iam/security-credentials/" + role, headers=headers + ) + valid = False + if status == 200: + try: + credentials = json.loads(body) + valid = credentials.get("Code") == "Success" and all( + credentials.get(key) for key in ("AccessKeyId", "SecretAccessKey", "Token") + ) + except (ValueError, AttributeError): + pass + emit("imds_credentials", available=bool(valid)) + return bool(valid) + + +def error_categories(output): + # Raw sccache errors may contain credentials, headers, or URLs. Never emit them. + text = output.lower() + categories = [] + for name, patterns in ( + ( + "authorization", + ( + "accessdenied", + "permissiondenied", + "forbidden", + "status: 403", + "invalidaccesskeyid", + "signaturedoesnotmatch", + "expiredtoken", + ), + ), + ( + "credentials_unavailable", + ( + "no credentials", + "no valid credential", + "credentials not found", + "credential not found", + "credentials are empty", + ), + ), + ("network_timeout", ("timed out", "timeout")), + ("network_error", ("connection refused", "connection reset", "dns", "connect error")), + ): + if any(pattern in text for pattern in patterns): + categories.append(name) + return categories + + +def run_sccache(executable, arguments, env, directory, phase): + try: + result = subprocess.run( + [executable, *arguments], + env=env, + cwd=directory, + capture_output=True, + timeout=30, + check=False, + ) + output = (result.stdout + result.stderr).decode("utf-8", errors="replace") + emit( + phase, + exit_code=result.returncode, + error_categories=error_categories(output) if result.returncode else [], + ) + return result.returncode, result.stdout + except subprocess.TimeoutExpired: + emit(phase, result="process_timeout") + except OSError: + emit(phase, result="process_error") + return 1, b"" + + +def stats_summary(output): + info = json.loads(output) + stats = info["stats"] + summary = {key: stats[key] for key in COUNTERS if type(stats.get(key)) is int} + for key in ("cache_hits", "cache_misses", "cache_errors"): + counts = stats.get(key, {}).get("counts", {}) + summary[key] = sum(value for value in counts.values() if type(value) is int) + version = str(info.get("version", "")) + if re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", version): + summary["version"] = version + summary["s3_backend"] = str(info.get("cache_location", "")).lower().startswith("s3") + return summary + + +def cache_probe(bucket, region, compiler): + executable = shutil.which("sccache") + if not executable or not Path(compiler).is_file(): + emit("sccache", result="missing_sccache_or_compiler") + return False + # A private UDS prevents changing/stopping an existing build's cache server. + # Keep AWS credential sources intact; reset only sccache-specific settings. + with tempfile.TemporaryDirectory(prefix="sccache-probe-", dir="/tmp") as temporary: + directory = Path(temporary) + config = directory / "config" + config.write_text("") + env = {key: value for key, value in os.environ.items() if not key.startswith("SCCACHE_")} + env.update( + SCCACHE_CONF=str(config), + SCCACHE_CACHED_CONF=str(directory / "cached-config"), + SCCACHE_SERVER_UDS=str(directory / "server.sock"), + SCCACHE_IDLE_TIMEOUT="60", + SCCACHE_DIR=str(directory / "cache"), + SCCACHE_BUCKET=bucket, + SCCACHE_REGION=region, + SCCACHE_S3_USE_SSL="true", + SCCACHE_LOG="debug", + SCCACHE_ERROR_LOG=str(directory / "errors"), + SCCACHE_DIRECT="false", + ) + # A fresh source forces a miss without deleting existing cache objects. + (directory / "probe.c").write_text( + "int sccache_probe_" + uuid.uuid4().hex + "(void) { return 0; }\n" + ) + results = [] + server_may_be_running = False + try: + for phase in ("write", "read"): + server_may_be_running = True + code, _ = run_sccache( + executable, ["--start-server"], env, directory, phase + "_start" + ) + if code: + return False + code, _ = run_sccache( + executable, + [compiler, "-c", "probe.c", "-o", "probe.o"], + env, + directory, + phase + "_compile", + ) + stats_code, output = run_sccache( + executable, + ["--show-stats", "--stats-format=json"], + env, + directory, + phase + "_stats_command", + ) + if code or stats_code: + return False + summary = stats_summary(output) + emit(phase + "_stats", **summary) + results.append(summary) + stop_code, _ = run_sccache( + executable, ["--stop-server"], env, directory, phase + "_stop" + ) + if stop_code: + return False + server_may_be_running = False + (directory / "probe.o").unlink(missing_ok=True) + return ( + all(item["s3_backend"] for item in results) + and results[0].get("cache_writes", 0) >= 1 + and results[1].get("cache_hits", 0) >= 1 + and all( + item.get(key, 0) == 0 + for item in results + for key in ( + "cache_errors", + "cache_read_errors", + "cache_write_errors", + "compile_fails", + ) + ) + ) + finally: + if server_may_be_running: + run_sccache(executable, ["--stop-server"], env, directory, "cleanup") + log = directory / "errors" + if log.exists(): + emit( + "sccache_log", + error_categories=error_categories( + "\n".join( + line + for line in log.read_text(errors="replace").splitlines() + if re.search(r"\b(?:ERROR|WARN)\b|Error executing cache write", line) + ) + ), + ) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--scope", choices=("host", "container"), required=True) + parser.add_argument("--bucket", help="enable the small S3 write/read probe") + parser.add_argument("--region", default="us-west-2") + parser.add_argument("--compiler", default="/usr/bin/gcc") + args = parser.parse_args() + emit( + "context", + scope=args.scope, + credential_environment={ + key: bool(os.environ.get(key)) + for key in ( + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_PROFILE", + "AWS_WEB_IDENTITY_TOKEN_FILE", + "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI", + "AWS_EC2_METADATA_DISABLED", + "HTTP_PROXY", + "HTTPS_PROXY", + ) + }, + ) + try: + metadata_ok = metadata_probe() + cache_ok = cache_probe(args.bucket, args.region, args.compiler) if args.bucket else None + emit("result", metadata_credentials_available=metadata_ok, s3_roundtrip=cache_ok) + # Metadata may be intentionally unavailable when other credentials work. + return 0 if (cache_ok if args.bucket else metadata_ok) else 1 + except Exception: + # Even unexpected exceptions must not print a credential-bearing traceback. + emit("result", result="probe_error") + return 1 + + +if __name__ == "__main__": + raise SystemExit(main())
