blob: 8543bc3a933d8e85ad473ca5d223124f01cbc0f5 [file] [log] [blame]
#!/usr/bin/env python3
# Copyright 2022 The IREE Authors
#
# Licensed under the Apache License v2.0 with LLVM Exceptions.
# See https://llvm.org/LICENSE.txt for license information.
# SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
"""Updates all Buildkite pipelines with the Buildkite API.
This overwrites the configuration of all the Buildkite pipeline. Pipelines
should be updated this way, not in the UI. Before updating, this checks for a
specific header in the configuration stored in the API that indicates which
Buildkite build previously updated it. If the updater was not this same pipeline
the script fails. If it was a later build in this pipeline, it skips updating
unless the `--force` flag is passed. This is to avoid situations where an
earlier build is for some reason running a step after a later build due to race
conditions or a retry. Buildkite concurrency groups should be used to prevent
builds from trying to update pipelines simultaneously.
"""
import argparse
import glob
import os
import subprocess
import sys
import urllib
import requests
from pybuildkite import buildkite
PIPELINE_ROOT_PATH = "build_tools/buildkite/pipelines"
BUILD_URL_PREAMBLE = "# Automatically updated by Buildkite pipeline"
UPDATE_INFO_HEADER = f"""{BUILD_URL_PREAMBLE} {{build_url}}
# from pipeline file {{pipeline_file_url}}
# with script {{script_url}}
"""
def get_git_root():
return subprocess.run(["git", "rev-parse", "--show-toplevel"],
check=True,
stdout=subprocess.PIPE,
text=True).stdout.strip()
def should_update(bk, *, organization, pipeline_file, running_pipeline,
running_build_number):
pipeline_to_update, _ = os.path.splitext(os.path.basename(pipeline_file))
previous_pipeline_configuration = bk.pipelines().get_pipeline(
organization, pipeline_to_update)["configuration"]
first_line, _ = previous_pipeline_configuration.split("\n", 1)
if not first_line.startswith(BUILD_URL_PREAMBLE):
print(f"Did not find build url preamble string '{BUILD_URL_PREAMBLE}' in"
f" pipeline configuration from Buildkite API. Aborting.")
sys.exit(3)
previous_build_url = first_line[len(BUILD_URL_PREAMBLE):].strip()
parsed_url = urllib.parse.urlparse(previous_build_url)
path_components = parsed_url.path.split("/")
# We're just going to be super picky here. If these invariants end up being a
# problem, it's easy to relax them.
if any((
parsed_url.scheme != "https",
parsed_url.netloc != "buildkite.com",
parsed_url.params != "",
parsed_url.query != "",
parsed_url.fragment != "",
len(path_components) != 5,
# Path starts with a slash, so the first component is empty
path_components[0] != "",
path_components[3] != "builds",
)):
print(f"URL of build that previously updated the pipeline is not in"
f" expected format. Got URL '{previous_build_url}'. Aborting")
sys.exit(4)
previous_organization = path_components[1]
previous_pipeline = path_components[2]
previous_build_number = int(path_components[4])
if previous_organization != organization:
print(f"Build was previously updated by a pipeline from a different"
f"organization '{previous_organization}' not current organization"
f" '{organization}'")
sys.exit(5)
if previous_pipeline != running_pipeline:
print(f"Build was previously updated by a pipeline from a different"
f"organization '{previous_pipeline}' not current organization"
f" '{running_pipeline}'")
sys.exit(5)
if previous_build_number > running_build_number:
print(f"...pipeline was already updated by later build"
f" ({previous_build_number}) of this pipeline. Skipping update.")
return False
return True
def update_pipeline(bk, *, organization, pipeline_file, running_pipeline,
running_build_number, running_commit):
pipeline_to_update, _ = os.path.splitext(os.path.basename(pipeline_file))
short_running_commit = running_commit[:10]
with open(pipeline_file) as f:
new_pipeline_configuration = f.read()
new_build_url = f"https://buildkite.com/{organization}/{running_pipeline}/builds/{running_build_number}"
script_relpath = os.path.relpath(__file__)
new_script_url = f"https://github.com/google/iree/blob/{short_running_commit}/{script_relpath}"
new_pipeline_file_url = f"https://github.com/google/iree/blob/{short_running_commit}/{pipeline_file}"
header = UPDATE_INFO_HEADER.format(build_url=new_build_url,
script_url=new_script_url,
pipeline_file_url=new_pipeline_file_url)
new_pipeline_configuration = header + new_pipeline_configuration
bk.pipelines().update_pipeline(organization=organization,
pipeline=pipeline_to_update,
configuration=new_pipeline_configuration)
print("...updated successfully")
def parse_args():
parser = argparse.ArgumentParser(
description="Updates the configurations for all Buildkite pipeline.")
parser.add_argument(
"--force",
action="store_true",
default=False,
help=("Force updates for all pipelines without checking the existing"
" configuration. Use with caution."))
parser.add_argument(
"pipelines",
type=str,
nargs="*",
help="Pipelines to update. Default is all of them.",
)
return parser.parse_args()
def main(args):
# A token for the Buildkite API. Needs read/write privileges on builds to
# watch and create builds. Within our pipelines we fetch this from secret
# manager: https://cloud.google.com/secret-manager. Users can create a
# personal token for running this script locally:
# https://buildkite.com/docs/apis/managing-api-tokens
access_token = os.environ["BUILDKITE_ACCESS_TOKEN"]
# Buildkite sets these environment variables. See
# https://buildkite.com/docs/pipelines/environment-variables. If running
# locally you can set locally or use the simulate_buildkite.sh script.
organization = os.environ["BUILDKITE_ORGANIZATION_SLUG"]
running_pipeline = os.environ["BUILDKITE_PIPELINE_SLUG"]
running_build_number = int(os.environ["BUILDKITE_BUILD_NUMBER"])
running_commit = os.environ["BUILDKITE_COMMIT"]
bk = buildkite.Buildkite()
bk.set_access_token(access_token)
git_root = get_git_root()
os.chdir(git_root)
glob_pattern = os.path.join(PIPELINE_ROOT_PATH, "*.yml")
pipeline_files = ((
os.path.join(PIPELINE_ROOT_PATH, f"{p}.yml") for p in args.pipelines)
if args.pipelines else glob.iglob(glob_pattern))
first_error = None
if args.force:
print("Was passed force, so not checking existing pipeline configurations.")
for pipeline_file in pipeline_files:
# TODO: Support creating a new pipeline.
print(f"Updating from: '{pipeline_file}'...")
try:
if args.force or should_update(bk,
organization=organization,
pipeline_file=pipeline_file,
running_pipeline=running_pipeline,
running_build_number=running_build_number):
update_pipeline(
bk,
organization=organization,
pipeline_file=pipeline_file,
running_pipeline=running_pipeline,
running_build_number=running_build_number,
running_commit=running_commit,
)
except Exception as e:
if first_error is None:
first_error = e
print(e)
if first_error is not None:
print("Encountered errors. Stack of first error:")
raise first_error
if __name__ == "__main__":
main(parse_args())