| #!/usr/bin/env python3 | |
| # | |
| # Copyright 2018 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 argparse | |
| import collections | |
| import os | |
| import re | |
| import sys | |
| import threading | |
| import bazelci | |
| BUILDKITE_ORG = os.environ["BUILDKITE_ORGANIZATION_SLUG"] | |
| PIPELINE = os.environ["BUILDKITE_PIPELINE_SLUG"] | |
| FAIL_IF_MIGRATION_REQUIRED = os.environ.get("USE_BAZELISK_MIGRATE", "").upper() == "FAIL" | |
| FLAG_LINE_PATTERN = re.compile(r"\s*(?P<flag>--\S+)\s*") | |
| MODULE_VERSION_PATTERN = re.compile(r'(?P<module_version>[a-z](?:[a-z0-9._-]*[a-z0-9])?@[^\s]+)') | |
| BAZEL_TEAM_OWNED_MODULES = frozenset([ | |
| "bazel-skylib", | |
| "rules_android", | |
| "rules_android_ndk", | |
| "rules_cc", | |
| "rules_java", | |
| "rules_license", | |
| "rules_pkg", | |
| "rules_platform", | |
| "rules_shell", | |
| "rules_testing", | |
| ]) | |
| PROJECT = "module" if PIPELINE == "bcr-bazel-compatibility-test" else "project" | |
| MAX_LOG_FETCHER_THREADS = 10 | |
| LOG_FETCHER_SEMAPHORE = threading.Semaphore(MAX_LOG_FETCHER_THREADS) | |
| class LogFetcher(threading.Thread): | |
| def __init__(self, job, client): | |
| threading.Thread.__init__(self) | |
| self.job = job | |
| self.client = client | |
| self.log = None | |
| def run(self): | |
| with LOG_FETCHER_SEMAPHORE: | |
| self.log = self.client.get_build_log(self.job, retries = 10) | |
| def process_build_log(failed_jobs_per_flag, already_failing_jobs, log, job): | |
| if job["state"] == "passed" and "Success: No migration needed." in log: | |
| return | |
| if "Failure: Command failed, even without incompatible flags." in log: | |
| already_failing_jobs.append(job) | |
| return | |
| def handle_failing_flags(line): | |
| flag = extract_flag(line) | |
| if flag: | |
| failed_jobs_per_flag[flag][job["id"]] = job | |
| # bazelisk --migrate might run for multiple times for run / build / test, | |
| # so there could be several "+++ Result" sections. | |
| found_result = False | |
| while "+++ Result" in log: | |
| found_result = True | |
| index_success = log.rfind("Command was successful with the following flags:") | |
| index_failure = log.rfind("Migration is needed for the following flags:") | |
| if index_success == -1 or index_failure == -1: | |
| raise bazelci.BuildkiteException("Cannot recognize log of " + job["web_url"]) | |
| for line in log[index_failure:].split("\n"): | |
| # Strip out BuildKite timestamp prefix | |
| line = re.sub(r'\x1b.*?\x07', '', line.strip()) | |
| if not line: | |
| break | |
| handle_failing_flags(line) | |
| log = log[0 : log.rfind("+++ Result")] | |
| # If no "+++ Result" was found, the job must have failed for other reasons | |
| if job["state"] == "failed" and not found_result: | |
| already_failing_jobs.append(job) | |
| def extract_module_version(line): | |
| match = MODULE_VERSION_PATTERN.search(line) | |
| if match: | |
| return match.group("module_version") | |
| def extract_flag(line): | |
| match = FLAG_LINE_PATTERN.match(line) | |
| if match: | |
| return match.group("flag") | |
| def get_html_link_text(content, link): | |
| return f'<a href="{link}" target="_blank">{content}</a>' | |
| def is_project_owned_by_bazel_team(project): | |
| if bazelci.is_downstream_pipeline() and project in bazelci.DOWNSTREAM_PROJECTS and bazelci.DOWNSTREAM_PROJECTS[project].get( | |
| "owned_by_bazel" | |
| ): | |
| # Check the downstream projects definition. | |
| return True | |
| elif project.split("@")[0] in BAZEL_TEAM_OWNED_MODULES: | |
| # Parse the module name and check if it's bazel team owned. | |
| return True | |
| return False | |
| # Check if any of the given jobs needs to be migrated by the Bazel team | |
| def needs_bazel_team_migrate(jobs): | |
| for job in jobs: | |
| project = get_project_name(job) | |
| if is_project_owned_by_bazel_team(project): | |
| return True | |
| return False | |
| def print_flags_ready_to_flip(failed_jobs_per_flag, incompatible_flags): | |
| info_text1 = [f"#### The following flags didn't break any passing {PROJECT}s"] | |
| for flag in sorted(list(incompatible_flags.keys())): | |
| if flag not in failed_jobs_per_flag: | |
| html_link_text = get_html_link_text(":github:", incompatible_flags[flag]) | |
| info_text1.append(f"* **{flag}** {html_link_text}") | |
| if len(info_text1) == 1: | |
| info_text1 = [] | |
| info_text2 = [ | |
| f"#### The following flags didn't break any passing Bazel team owned/co-owned {PROJECT}s" | |
| ] | |
| for flag, jobs in failed_jobs_per_flag.items(): | |
| if flag not in incompatible_flags: | |
| continue | |
| if not needs_bazel_team_migrate(jobs.values()): | |
| failed_cnt = len(jobs) | |
| s1 = "" if failed_cnt == 1 else "s" | |
| s2 = "s" if failed_cnt == 1 else "" | |
| html_link_text = get_html_link_text(":github:", incompatible_flags[flag]) | |
| info_text2.append( | |
| f"* **{flag}** {html_link_text} ({failed_cnt} other job{s1} need{s2} migration)" | |
| ) | |
| if len(info_text2) == 1: | |
| info_text2 = [] | |
| print_info("flags_ready_to_flip", "success", info_text1 + info_text2) | |
| def print_already_fail_jobs(already_failing_jobs): | |
| info_text = ["#### The following jobs already fail without incompatible flags"] | |
| info_text += merge_and_format_jobs(already_failing_jobs, "* **{}**: {}") | |
| if len(info_text) == 1: | |
| return | |
| print_info("already_fail_jobs", "warning", info_text) | |
| def print_projects_need_to_migrate(failed_jobs_per_flag): | |
| info_text = [f"#### The following {PROJECT}s need migration"] | |
| jobs_need_migration = {} | |
| for jobs in failed_jobs_per_flag.values(): | |
| for job in jobs.values(): | |
| jobs_need_migration[job["name"]] = job | |
| job_list = jobs_need_migration.values() | |
| job_num = len(job_list) | |
| if job_num == 0: | |
| return | |
| projects = set() | |
| for job in job_list: | |
| project = get_project_name(job) | |
| projects.add(project) | |
| project_num = len(projects) | |
| s1 = "" if project_num == 1 else "s" | |
| s2 = "s" if project_num == 1 else "" | |
| info_text.append( | |
| f"<details><summary>{project_num} {PROJECT}{s1} need{s2} migration, click to see details</summary><ul>" | |
| ) | |
| entries = merge_and_format_jobs(job_list, " <li><strong>{}</strong>: {}</li>") | |
| info_text += entries | |
| info_text.append("</ul></details>") | |
| info_str = "\n".join(info_text) | |
| bazelci.execute_command( | |
| [ | |
| "buildkite-agent", | |
| "annotate", | |
| "--append", | |
| "--context=projects_need_migration", | |
| "--style=error", | |
| f"\n{info_str}\n", | |
| ] | |
| ) | |
| def print_flags_need_to_migrate(failed_jobs_per_flag, incompatible_flags): | |
| # The info box printed later is above info box printed before, | |
| # so reverse the flag list to maintain the same order. | |
| printed_flag_boxes = False | |
| for flag in sorted(list(failed_jobs_per_flag.keys()), reverse=True): | |
| if flag not in incompatible_flags: | |
| continue | |
| jobs = failed_jobs_per_flag[flag] | |
| if jobs: | |
| github_url = incompatible_flags[flag] | |
| info_text = [f"* **{flag}** " + get_html_link_text(":github:", github_url)] | |
| jobs_per_project = merge_jobs(jobs.values()) | |
| for project, platforms in jobs_per_project.items(): | |
| bazel_mark = "" | |
| if is_project_owned_by_bazel_team(project): | |
| bazel_mark = ":bazel:" | |
| platforms_text = ", ".join(platforms) | |
| info_text.append(f" - {bazel_mark}**{project}**: {platforms_text}") | |
| # Use flag as the context so that each flag gets a different info box. | |
| print_info(flag, "error", info_text) | |
| printed_flag_boxes = True | |
| if not printed_flag_boxes: | |
| return | |
| info_text = [ | |
| "#### Projects need to migrate for the following flags:", | |
| "Projects marked with :bazel: need to be migrated by the Bazel team.", | |
| ] | |
| print_info("flags_need_to_migrate", "error", info_text) | |
| def merge_jobs(jobs): | |
| jobs_per_project = collections.defaultdict(list) | |
| for job in sorted(jobs, key=lambda s: s["name"].lower()): | |
| project = get_project_name(job) | |
| platform_label = get_platform_emoji_name(job) | |
| jobs_per_project[project].append(get_html_link_text(platform_label, job["web_url"])) | |
| return jobs_per_project | |
| def merge_and_format_jobs(jobs, line_pattern): | |
| # Merges all jobs for a single project into one line. | |
| # Example: | |
| # project (platform1) | |
| # project (platform2) | |
| # project (platform3) | |
| # with line_pattern ">> {}: {}" becomes | |
| # >> project: platform1, platform2, platform3 | |
| jobs_per_project = merge_jobs(jobs) | |
| return [ | |
| line_pattern.format(project, ", ".join(platforms)) | |
| for project, platforms in jobs_per_project.items() | |
| ] | |
| def get_project_name(job): | |
| # Strip out platform label from job name | |
| name = job["name"].replace(get_platform_emoji_name(job), "") | |
| if bazelci.is_downstream_pipeline(): | |
| # This is for downstream pipeline, parse the pipeline name | |
| return name.partition("-")[0].partition("(")[0].strip() | |
| else: | |
| # This is for BCR compatibility test pipeline, parse the module name + version | |
| return extract_module_version(name) | |
| def get_platform_emoji_name(job): | |
| # By search for the platform label in the job name. | |
| name = job["name"] | |
| for p in bazelci.PLATFORMS.values(): | |
| platform_label = p.get("emoji-name") | |
| if platform_label in name: | |
| return platform_label | |
| raise bazelci.BuildkiteException("Cannot detect platform name for: " + job["web_url"]) | |
| def print_info(context, style, info): | |
| # CHUNK_SIZE is to prevent buildkite-agent "argument list too long" error | |
| CHUNK_SIZE = 20 | |
| for i in range(0, len(info), CHUNK_SIZE): | |
| info_str = "\n".join(info[i : i + CHUNK_SIZE]) | |
| bazelci.execute_command( | |
| [ | |
| "buildkite-agent", | |
| "annotate", | |
| "--append", | |
| f"--context={context}", | |
| f"--style={style}", | |
| f"\n{info_str}\n", | |
| ] | |
| ) | |
| def analyze_logs(build_number, client): | |
| build_info = client.get_build_info(build_number) | |
| already_failing_jobs = [] | |
| # dict(flag name -> dict(job id -> job)) | |
| failed_jobs_per_flag = collections.defaultdict(dict) | |
| threads = [] | |
| for job in build_info["jobs"]: | |
| # Some irrelevant job has no "state" or "raw_log_url" field | |
| if "state" in job and "raw_log_url" in job: | |
| thread = LogFetcher(job, client) | |
| threads.append(thread) | |
| thread.start() | |
| for thread in threads: | |
| thread.join() | |
| process_build_log( | |
| failed_jobs_per_flag, already_failing_jobs, thread.log, thread.job | |
| ) | |
| return already_failing_jobs, failed_jobs_per_flag | |
| def print_result_info(already_failing_jobs, failed_jobs_per_flag): | |
| # key: flag name, value: Github Issue URL | |
| incompatible_flags = bazelci.fetch_incompatible_flags() | |
| print_flags_need_to_migrate(failed_jobs_per_flag, incompatible_flags) | |
| print_projects_need_to_migrate(failed_jobs_per_flag) | |
| print_already_fail_jobs(already_failing_jobs) | |
| print_flags_ready_to_flip(failed_jobs_per_flag, incompatible_flags) | |
| return bool(failed_jobs_per_flag) | |
| def main(argv=None): | |
| if argv is None: | |
| argv = sys.argv[1:] | |
| parser = argparse.ArgumentParser( | |
| description="Script to aggregate `bazelisk --migrate` test result for incompatible flags and generate pretty Buildkite info messages." | |
| ) | |
| parser.add_argument("--build_number", type=str) | |
| parser.add_argument("--notify", type=bool, nargs="?", const=True) | |
| args = parser.parse_args(argv) | |
| try: | |
| if args.build_number: | |
| client = bazelci.BuildkiteClient(org=BUILDKITE_ORG, pipeline=PIPELINE) | |
| already_failing_jobs, failed_jobs_per_flag = analyze_logs( | |
| args.build_number, client | |
| ) | |
| migration_required = print_result_info( | |
| already_failing_jobs, failed_jobs_per_flag | |
| ) | |
| if migration_required and FAIL_IF_MIGRATION_REQUIRED: | |
| bazelci.eprint("Exiting with code 3 since a migration is required.") | |
| return 3 | |
| else: | |
| parser.print_help() | |
| return 2 | |
| except bazelci.BuildkiteException as e: | |
| bazelci.eprint(str(e)) | |
| return 1 | |
| return 0 | |
| if __name__ == "__main__": | |
| sys.exit(main()) |