blob: 0efcaaeec9e14f25561de0ca075e53d9eb0c01c0 [file] [log] [blame]
#!/usr/bin/env python3
#
# Copyright 2019 The Bazel Authors. All rights reserved.
#
# Licensed 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.
import base64
import tempfile
import os
import uuid
import time
import subprocess
import stat
import shutil
import multiprocessing
import re
import json
from urllib.request import url2pathname
from urllib.parse import urlparse
from config import CLOUD_PROJECT, PLATFORMS
from utils import (
bazelci_builds_gs_url,
create_label,
eprint,
execute_command,
gcloud_command,
gsutil_command,
print_collapsed_group,
print_expanded_group,
)
ENCRYPTED_SAUCELABS_TOKEN = """
CiQAry63sOlZtTNtuOT5DAOLkum0rGof+DOweppZY1aOWbat8zwSTQAL7Hu+rgHSOr6P4S1cu4YG
/I1BHsWaOANqUgFt6ip9/CUGGJ1qggsPGXPrmhSbSPqNAIAkpxYzabQ3mfSIObxeBmhKg2dlILA/
EDql
""".strip()
def saucelabs_token():
return (
subprocess.check_output(
[
gcloud_command(),
"kms",
"decrypt",
"--project",
"bazel-untrusted",
"--location",
"global",
"--keyring",
"buildkite",
"--key",
"saucelabs-access-key",
"--ciphertext-file",
"-",
"--plaintext-file",
"-",
],
input=base64.b64decode(ENCRYPTED_SAUCELABS_TOKEN),
env=os.environ,
)
.decode("utf-8")
.strip()
)
def downstream_projects_root(platform):
downstream_projects_dir = os.path.expandvars(
"${BUILDKITE_ORGANIZATION_SLUG}-downstream-projects"
)
agent_directory = os.path.expandvars(PLATFORMS[platform]["agent-directory"])
path = os.path.join(agent_directory, downstream_projects_dir)
if not os.path.exists(path):
os.makedirs(path)
return path
def clone_git_repository(git_repository, platform, git_commit=None):
root = downstream_projects_root(platform)
project_name = re.search(r"/([^/]+)\.git$", git_repository).group(1)
clone_path = os.path.join(root, project_name)
print_collapsed_group(
"Fetching %s sources at %s" % (project_name, git_commit if git_commit else "HEAD")
)
if not os.path.exists(clone_path):
if platform in ["ubuntu1404", "ubuntu1604", "ubuntu1804", "rbe_ubuntu1604"]:
execute_command(
["git", "clone", "--reference", "/var/lib/bazelbuild", git_repository, clone_path]
)
elif platform in ["macos"]:
execute_command(
[
"git",
"clone",
"--reference",
"/usr/local/var/bazelbuild",
git_repository,
clone_path,
]
)
elif platform in ["windows"]:
execute_command(
[
"git",
"clone",
"--reference",
"c:\\buildkite\\bazelbuild",
git_repository,
clone_path,
]
)
else:
execute_command(["git", "clone", git_repository, clone_path])
os.chdir(clone_path)
execute_command(["git", "remote", "set-url", "origin", git_repository])
execute_command(["git", "clean", "-fdqx"])
execute_command(["git", "submodule", "foreach", "--recursive", "git", "clean", "-fdqx"])
execute_command(["git", "fetch", "origin"])
if git_commit:
# sync to a specific commit of this repository
execute_command(["git", "reset", git_commit, "--hard"])
else:
# sync to the latest commit of HEAD. Unlikely git pull this also works after a force push.
remote_head = (
subprocess.check_output(["git", "symbolic-ref", "refs/remotes/origin/HEAD"])
.decode("utf-8")
.rstrip()
)
execute_command(["git", "reset", remote_head, "--hard"])
execute_command(["git", "submodule", "sync", "--recursive"])
execute_command(["git", "submodule", "update", "--init", "--recursive", "--force"])
execute_command(["git", "submodule", "foreach", "--recursive", "git", "reset", "--hard"])
execute_command(["git", "clean", "-fdqx"])
execute_command(["git", "submodule", "foreach", "--recursive", "git", "clean", "-fdqx"])
return clone_path
def common_startup_flags(platform):
return ["--output_user_root=D:/b"] if platform == "windows" else []
def print_bazel_version_info(bazel_binary, platform):
print_collapsed_group(":information_source: Bazel Info")
execute_command(
[bazel_binary]
+ common_startup_flags(platform)
+ ["--nomaster_bazelrc", "--bazelrc=/dev/null", "version"]
)
execute_command(
[bazel_binary]
+ common_startup_flags(platform)
+ ["--nomaster_bazelrc", "--bazelrc=/dev/null", "info"]
)
def download_bazel_binary_at_commit(dest_dir, platform, bazel_git_commit):
# We only build bazel binary on ubuntu14.04 for every bazel commit.
# It should be OK to use it on other ubuntu platforms.
if "ubuntu" in PLATFORMS[platform].get("host-platform", platform):
platform = "ubuntu1404"
bazel_binary_path = os.path.join(dest_dir, "bazel.exe" if platform == "windows" else "bazel")
try:
execute_command(
[
gsutil_command(),
"cp",
bazelci_builds_gs_url(platform, bazel_git_commit),
bazel_binary_path,
]
)
except subprocess.CalledProcessError as e:
raise Exception(
"Failed to download Bazel binary at %s, error message:\n%s" % (bazel_git_commit, str(e))
)
st = os.stat(bazel_binary_path)
os.chmod(bazel_binary_path, st.st_mode | stat.S_IEXEC)
return bazel_binary_path
def execute_batch_commands(commands):
if not commands:
return
print_collapsed_group(":batch: Setup (Batch Commands)")
batch_commands = "&".join(commands)
subprocess.run(batch_commands, shell=True, check=True, env=os.environ)
def execute_shell_commands(commands):
if not commands:
return
print_collapsed_group(":bash: Setup (Shell Commands)")
shell_command = "\n".join(commands)
execute_command([shell_command], shell=True)
def execute_bazel_run(bazel_binary, platform, targets, incompatible_flags):
if not targets:
return
print_collapsed_group("Setup (Run Targets)")
for target in targets:
execute_command(
[bazel_binary]
+ common_startup_flags(platform)
+ ["run"]
+ common_build_flags(None, platform)
+ (incompatible_flags or [])
+ [target]
)
def print_environment_variables_info():
print_collapsed_group(":information_source: Environment Variables")
for key, value in os.environ.items():
eprint("%s=(%s)" % (key, value))
def execute_command_background(args):
eprint(" ".join(args))
return subprocess.Popen(args, env=os.environ)
def execute_bazel_clean(bazel_binary, platform):
print_expanded_group(":bazel: Clean")
try:
execute_command([bazel_binary] + common_startup_flags(platform) + ["clean", "--expunge"])
except subprocess.CalledProcessError as e:
raise Exception("bazel clean failed with exit code {}".format(e.returncode))
def concurrent_jobs(platform):
return "75" if platform.startswith("rbe_") else str(multiprocessing.cpu_count())
def common_build_flags(bep_file, platform):
flags = [
"--show_progress_rate_limit=5",
"--curses=yes",
"--color=yes",
"--verbose_failures",
"--keep_going",
"--jobs=" + concurrent_jobs(platform),
"--announce_rc",
"--experimental_multi_threaded_digest",
]
if platform != "windows":
flags += ["--sandbox_tmpfs_path=/tmp"]
if bep_file:
flags += [
"--experimental_build_event_json_file_path_conversion=false",
"--build_event_json_file=" + bep_file,
]
return flags
def remote_enabled(flags):
# Detect if the project configuration enabled its own remote caching / execution.
remote_flags = ["--remote_executor", "--remote_cache", "--remote_http_cache"]
for flag in flags:
for remote_flag in remote_flags:
if flag.startswith(remote_flag):
return True
return False
def rbe_flags(original_flags, accept_cached):
# Enable remote execution via RBE.
flags = [
"--remote_executor=remotebuildexecution.googleapis.com",
"--remote_instance_name=projects/bazel-untrusted/instances/default_instance",
"--remote_timeout=3600",
"--spawn_strategy=remote",
"--strategy=Javac=remote",
"--strategy=Closure=remote",
"--genrule_strategy=remote",
"--experimental_strict_action_env",
"--tls_enabled=true",
"--google_default_credentials",
]
# Enable BES / Build Results reporting.
flags += [
"--bes_backend=buildeventservice.googleapis.com",
"--bes_timeout=360s",
"--project_id=bazel-untrusted",
]
if not accept_cached:
flags += ["--noremote_accept_cached"]
# Copied from https://github.com/bazelbuild/bazel-toolchains/blob/master/configs/ubuntu16_04_clang/1.0/toolchain.bazelrc
flags += [
# These should NOT be modified before @bazel_toolchains repo pin is
# updated in projects' WORKSPACE files.
#
# Toolchain related flags to append at the end of your .bazelrc file.
"--host_javabase=@bazel_toolchains//configs/ubuntu16_04_clang/latest:javabase",
"--javabase=@bazel_toolchains//configs/ubuntu16_04_clang/latest:javabase",
"--host_java_toolchain=@bazel_tools//tools/jdk:toolchain_hostjdk8",
"--java_toolchain=@bazel_tools//tools/jdk:toolchain_hostjdk8",
"--crosstool_top=@bazel_toolchains//configs/ubuntu16_04_clang/latest:crosstool_top_default",
"--action_env=BAZEL_DO_NOT_DETECT_CPP_TOOLCHAIN=1",
]
# Platform flags:
# The toolchain container used for execution is defined in the target indicated
# by "extra_execution_platforms", "host_platform" and "platforms".
# If you are using your own toolchain container, you need to create a platform
# target with "constraint_values" that allow for the toolchain specified with
# "extra_toolchains" to be selected (given constraints defined in
# "exec_compatible_with").
# More about platforms: https://docs.bazel.build/versions/master/platforms.html
# Don't add platform flags if they are specified already.
platform_flags = {
"--extra_toolchains": "@bazel_toolchains//configs/ubuntu16_04_clang/latest:toolchain_default",
"--extra_execution_platforms": "@bazel_toolchains//configs/ubuntu16_04_clang/latest:platform",
"--host_platform": "@bazel_toolchains//configs/ubuntu16_04_clang/latest:platform",
"--platforms": "@bazel_toolchains//configs/ubuntu16_04_clang/latest:platform",
}
for platform_flag, value in list(platform_flags.items()):
found = False
for original_flag in original_flags:
if original_flag.startswith(platform_flag):
found = True
break
if not found:
flags += [platform_flag + "=" + value]
return flags
def concurrent_test_jobs(platform):
if platform.startswith("rbe_"):
return "75"
if platform == "windows":
return "8"
if platform == "macos":
return "8"
return "12"
def remote_caching_flags(platform):
if platform not in [
"ubuntu1404",
"ubuntu1604",
"ubuntu1804",
"ubuntu1804_nojava",
"ubuntu1804_java9",
"ubuntu1804_java10",
"ubuntu1804_java11",
"macos",
# "windows",
]:
return []
flags = [
"--remote_timeout=60",
"--disk_cache=",
"--remote_max_connections=200",
'--host_platform_remote_properties_override=properties:{name:"platform" value:"%s"}'
% platform,
]
if platform == "macos":
if CLOUD_PROJECT == "bazel-public":
# Use a local trusted cache server for our macOS machines.
flags += ["--remote_http_cache=http://100.107.67.248:8081"]
else:
# Use a local untrusted cache server for our macOS machines.
flags += ["--remote_http_cache=http://100.107.67.248:8080"]
else:
flags += ["--google_default_credentials"]
if CLOUD_PROJECT == "bazel-public":
flags += [
"--remote_http_cache=https://storage.googleapis.com/bazel-trusted-buildkite-cache"
]
else:
flags += [
"--remote_http_cache=https://storage.googleapis.com/bazel-untrusted-buildkite-cache"
]
return flags
def compute_flags(platform, flags, incompatible_flags, bep_file, enable_remote_cache=False):
aggregated_flags = common_build_flags(bep_file, platform)
if not remote_enabled(flags):
if platform.startswith("rbe_"):
aggregated_flags += rbe_flags(flags, accept_cached=enable_remote_cache)
elif enable_remote_cache:
aggregated_flags += remote_caching_flags(platform)
aggregated_flags += flags
if incompatible_flags:
aggregated_flags += incompatible_flags
return aggregated_flags
def execute_bazel_build(bazel_binary, platform, flags, targets, bep_file, incompatible_flags):
print_expanded_group(":bazel: Build")
aggregated_flags = compute_flags(
platform, flags, incompatible_flags, bep_file, enable_remote_cache=True
)
try:
execute_command(
[bazel_binary] + common_startup_flags(platform) + ["build"] + aggregated_flags + targets
)
except subprocess.CalledProcessError as e:
raise Exception("bazel build failed with exit code {}".format(e.returncode))
def execute_bazel_test(
bazel_binary, platform, flags, targets, bep_file, monitor_flaky_tests, incompatible_flags
):
print_expanded_group(":bazel: Test")
aggregated_flags = [
"--flaky_test_attempts=3",
"--build_tests_only",
"--local_test_jobs=" + concurrent_test_jobs(platform),
]
# Don't enable remote caching if the user enabled remote execution / caching themselves
# or flaky test monitoring is enabled, as remote caching makes tests look less flaky than
# they are.
aggregated_flags += compute_flags(
platform, flags, incompatible_flags, bep_file, enable_remote_cache=not monitor_flaky_tests
)
try:
execute_command(
[bazel_binary] + common_startup_flags(platform) + ["test"] + aggregated_flags + targets
)
except subprocess.CalledProcessError as e:
raise Exception("bazel test failed with exit code {}".format(e.returncode))
def upload_bazel_binary(platform):
print_collapsed_group(":gcloud: Uploading Bazel Under Test")
binary_path = "bazel-bin/src/bazel"
if platform == "windows":
binary_path = r"bazel-bin\src\bazel"
execute_command(["buildkite-agent", "artifact", "upload", binary_path])
def test_logs_for_status(bep_file, status):
targets = []
raw_data = ""
with open(bep_file, encoding="utf-8") as f:
raw_data = f.read()
decoder = json.JSONDecoder()
pos = 0
while pos < len(raw_data):
bep_obj, size = decoder.raw_decode(raw_data[pos:])
if "testSummary" in bep_obj:
test_target = bep_obj["id"]["testSummary"]["label"]
test_status = bep_obj["testSummary"]["overallStatus"]
if test_status == status:
outputs = bep_obj["testSummary"]["failed"]
test_logs = []
for output in outputs:
test_logs.append(url2pathname(urlparse(output["uri"]).path))
targets.append((test_target, test_logs))
pos += size + 1
return targets
def has_flaky_tests(bep_file):
return len(test_logs_for_status(bep_file, status="FLAKY")) > 0
def test_label_to_path(tmpdir, label, attempt):
# remove leading //
path = label[2:]
path = path.replace("/", os.sep)
path = path.replace(":", os.sep)
if attempt == 0:
path = os.path.join(path, "test.log")
else:
path = os.path.join(path, "attempt_" + str(attempt) + ".log")
return os.path.join(tmpdir, path)
def test_logs_to_upload(bep_file, tmpdir):
failed = test_logs_for_status(bep_file, status="FAILED")
timed_out = test_logs_for_status(bep_file, status="TIMEOUT")
flaky = test_logs_for_status(bep_file, status="FLAKY")
# Rename the test.log files to the target that created them
# so that it's easy to associate test.log and target.
new_paths = []
for label, test_logs in failed + timed_out + flaky:
attempt = 0
if len(test_logs) > 1:
attempt = 1
for test_log in test_logs:
try:
new_path = test_label_to_path(tmpdir, label, attempt)
os.makedirs(os.path.dirname(new_path), exist_ok=True)
shutil.copyfile(test_log, new_path)
new_paths.append(new_path)
attempt += 1
except IOError as err:
# Log error and ignore.
eprint(err)
return new_paths
def upload_test_logs(bep_file, tmpdir):
if not os.path.exists(bep_file):
return
test_logs = test_logs_to_upload(bep_file, tmpdir)
if test_logs:
cwd = os.getcwd()
try:
os.chdir(tmpdir)
test_logs = [os.path.relpath(test_log, tmpdir) for test_log in test_logs]
test_logs = sorted(test_logs)
print_collapsed_group(":gcloud: Uploading Test Logs")
execute_command(["buildkite-agent", "artifact", "upload", ";".join(test_logs)])
finally:
os.chdir(cwd)
def download_bazel_binary(dest_dir, platform):
host_platform = PLATFORMS[platform].get("host-platform", platform)
binary_path = "bazel-bin/src/bazel"
if platform == "windows":
binary_path = r"bazel-bin\src\bazel"
source_step = create_label(host_platform, "Bazel", build_only=True)
execute_command(
["buildkite-agent", "artifact", "download", binary_path, dest_dir, "--step", source_step]
)
bazel_binary_path = os.path.join(dest_dir, binary_path)
st = os.stat(bazel_binary_path)
os.chmod(bazel_binary_path, st.st_mode | stat.S_IEXEC)
return bazel_binary_path
def main(
config,
platform,
git_repository,
git_commit,
git_repo_location,
use_bazel_at_commit,
use_but,
save_but,
needs_clean,
build_only,
test_only,
monitor_flaky_tests,
incompatible_flags,
):
build_only = build_only or "test_targets" not in config
test_only = test_only or "build_targets" not in config
if build_only and test_only:
raise Exception("build_only and test_only cannot be true at the same time")
if use_bazel_at_commit and use_but:
raise Exception("use_bazel_at_commit cannot be set when use_but is true")
tmpdir = tempfile.mkdtemp()
sc_process = None
try:
if git_repo_location:
os.chdir(git_repo_location)
elif git_repository:
clone_git_repository(git_repository, platform, git_commit)
else:
git_repository = os.getenv("BUILDKITE_REPO")
if use_bazel_at_commit:
print_collapsed_group(":gcloud: Downloading Bazel built at " + use_bazel_at_commit)
bazel_binary = download_bazel_binary_at_commit(tmpdir, platform, use_bazel_at_commit)
elif use_but:
print_collapsed_group(":gcloud: Downloading Bazel Under Test")
bazel_binary = download_bazel_binary(tmpdir, platform)
else:
bazel_binary = "bazel"
print_bazel_version_info(bazel_binary, platform)
print_environment_variables_info()
if incompatible_flags:
print_expanded_group("Build and test with the following incompatible flags:")
for flag in incompatible_flags:
eprint(flag + "\n")
if platform == "windows":
execute_batch_commands(config.get("batch_commands", None))
else:
execute_shell_commands(config.get("shell_commands", None))
execute_bazel_run(
bazel_binary, platform, config.get("run_targets", None), incompatible_flags
)
if config.get("sauce", None):
print_collapsed_group(":saucelabs: Starting Sauce Connect Proxy")
os.environ["SAUCE_USERNAME"] = "bazel_rules_webtesting"
os.environ["SAUCE_ACCESS_KEY"] = saucelabs_token()
os.environ["TUNNEL_IDENTIFIER"] = str(uuid.uuid4())
os.environ["BUILD_TAG"] = str(uuid.uuid4())
readyfile = os.path.join(tmpdir, "sc_is_ready")
if platform == "windows":
cmd = ["sauce-connect.exe", "/i", os.environ["TUNNEL_IDENTIFIER"], "/f", readyfile]
else:
cmd = ["sc", "-i", os.environ["TUNNEL_IDENTIFIER"], "-f", readyfile]
sc_process = execute_command_background(cmd)
wait_start = time.time()
while not os.path.exists(readyfile):
if time.time() - wait_start > 30:
raise Exception(
"Sauce Connect Proxy is still not ready after 30 seconds, aborting!"
)
time.sleep(1)
print("Sauce Connect Proxy is ready, continuing...")
if needs_clean:
execute_bazel_clean(bazel_binary, platform)
if not test_only:
execute_bazel_build(
bazel_binary,
platform,
config.get("build_flags", []),
config.get("build_targets", None),
None,
incompatible_flags,
)
if save_but:
upload_bazel_binary(platform)
if not build_only:
test_bep_file = os.path.join(tmpdir, "test_bep.json")
try:
execute_bazel_test(
bazel_binary,
platform,
config.get("test_flags", []),
config.get("test_targets", None),
test_bep_file,
monitor_flaky_tests,
incompatible_flags,
)
if has_flaky_tests(test_bep_file):
if monitor_flaky_tests:
# Upload the BEP logs from Bazel builds for later analysis on flaky tests
build_id = os.getenv("BUILDKITE_BUILD_ID")
pipeline_slug = os.getenv("BUILDKITE_PIPELINE_SLUG")
execute_command(
[
gsutil_command(),
"cp",
test_bep_file,
"gs://bazel-buildkite-stats/flaky-tests-bep/"
+ pipeline_slug
+ "/"
+ build_id
+ ".json",
]
)
finally:
upload_test_logs(test_bep_file, tmpdir)
finally:
if sc_process:
sc_process.terminate()
try:
sc_process.wait(timeout=10)
except subprocess.TimeoutExpired:
sc_process.kill()
if tmpdir:
shutil.rmtree(tmpdir)