blob: eab53f9479d12e894159653d6c6c4d6fe8cd5b9d [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 pipelines based on the
pipeline files under the `trusted/` and `untrusted/` directories. Pipelines
should be updated this way, not in the UI. The pipeline configuration uploaded
is one that bootstraps the appropriate configuration from the repository. 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
from common.buildkite_utils import get_pipeline
GIT_REPO = "https://github.com/iree-org/iree"
PIPELINE_ROOT_PATH = "build_tools/buildkite/pipelines"
TRUSTED_BOOTSTRAP_PIPELINE_PATH = os.path.join(PIPELINE_ROOT_PATH, "fragment",
"bootstrap-trusted.yml")
UNTRUSTED_BOOTSTRAP_PIPELINE_PATH = os.path.join(PIPELINE_ROOT_PATH, "fragment",
"bootstrap-untrusted.yml")
IREE_TEAM_REST_UUID = "1a2cbc72-2c8e-4375-821e-e3dfa1db96b5"
# The team that just contains the postsubmit bot
PRIVILEGED_TEAM_REST_UUID = "7de7685f-7e10-4bc4-ba66-3478a5c56db4"
FORCE_UPDATE_ENV_VAR = "IREE_FORCE_BUILDKITE_PIPELINE_UPDATE"
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 prepend_header(configuration, *, organization, running_pipeline,
running_commit, running_build_number, trusted):
short_running_commit = running_commit[:10]
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/iree-org/iree/blob/{short_running_commit}/{script_relpath}"
bootstrap_pipeline_path = TRUSTED_BOOTSTRAP_PIPELINE_PATH if trusted else UNTRUSTED_BOOTSTRAP_PIPELINE_PATH
new_pipeline_file_url = f"https://github.com/iree-org/iree/blob/{short_running_commit}/{bootstrap_pipeline_path}"
header = UPDATE_INFO_HEADER.format(build_url=new_build_url,
script_url=new_script_url,
pipeline_file_url=new_pipeline_file_url)
return header + configuration
def should_update(bk, *, organization, configuration, existing_pipeline,
running_pipeline, running_build_number):
previous_configuration_lines = existing_pipeline["configuration"].splitlines()
trimmed_previous_configuration_lines = previous_configuration_lines[
len(UPDATE_INFO_HEADER.splitlines()):]
if trimmed_previous_configuration_lines == configuration.splitlines():
print("Configuration has not changed. Not updating")
return False
first_line = previous_configuration_lines[0].strip()
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 different pipeline"
f" '{previous_pipeline}' not current pipeline '{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 create_pipeline(bk, *, organization, pipeline_slug, configuration,
running_pipeline, running_build_number, running_commit,
trusted, dry_run):
configuration = prepend_header(configuration,
organization=organization,
running_pipeline=running_pipeline,
running_build_number=running_build_number,
running_commit=running_commit,
trusted=trusted)
# TODO: Update pybuildkite to allow passing provider_settings.
# see https://github.com/pyasi/pybuildkite/issues/73
pipelines_api = bk.pipelines()
data = {
"name": pipeline_slug,
"repository": GIT_REPO,
"configuration": configuration,
# With the rest API, we can only give "Full Access", so this is limited to
# the IREE team and doesn't give Read & Build access to the "Everyone" and
# "Presubmit" teams. I'm talking to Buildkite support about this
# limitation. There's a similar problem that the pipeline can't be made
# public via the REST API, which requires user intervention in the UI.
"team_uuids": [IREE_TEAM_REST_UUID, PRIVILEGED_TEAM_REST_UUID],
"provider_settings": {
# We don't want any automatic triggering from GitHub webhooks.
"trigger_mode": "none"
},
}
print(f"Creating pipeline {pipeline_slug} with payload:\n"
f"```\n"
f"{configuration}\n"
f"```\n")
if not dry_run:
pipelines_api.client.post(pipelines_api.path.format("iree"), body=data)
print("...created successfully")
def update_pipeline(bk, *, organization, pipeline_slug, configuration,
running_pipeline, running_build_number, running_commit,
trusted, dry_run):
configuration = prepend_header(configuration,
organization=organization,
running_pipeline=running_pipeline,
running_build_number=running_build_number,
running_commit=running_commit,
trusted=trusted)
print(f"Updating pipeline {pipeline_slug} with configuration:\n"
f"```\n"
f"{configuration}\n"
f"```\n")
if not dry_run:
bk.pipelines().update_pipeline(organization=organization,
pipeline=pipeline_slug,
configuration=configuration)
print("...updated successfully")
def get_slug(pipeline_file):
pipeline_slug, _ = os.path.splitext(os.path.basename(pipeline_file))
return pipeline_slug
def update_pipelines(bk, pipeline_files, *, organization, running_pipeline,
running_build_number, running_commit, trusted, force,
dry_run):
first_error = None
for pipeline_file in pipeline_files:
pipeline_slug = get_slug(pipeline_file)
try:
with open(TRUSTED_BOOTSTRAP_PIPELINE_PATH
if trusted else UNTRUSTED_BOOTSTRAP_PIPELINE_PATH) as f:
configuration = f.read()
existing_pipeline = get_pipeline(bk,
organization=organization,
pipeline_slug=pipeline_slug)
if existing_pipeline is None:
print(f"Creating for: '{pipeline_file}'...")
create_pipeline(bk,
organization=organization,
pipeline_slug=pipeline_slug,
configuration=configuration,
running_pipeline=running_pipeline,
running_build_number=running_build_number,
running_commit=running_commit,
trusted=trusted,
dry_run=dry_run)
continue
print(f"Updating for: '{pipeline_file}'...")
if force or should_update(bk,
organization=organization,
configuration=configuration,
existing_pipeline=existing_pipeline,
running_pipeline=running_pipeline,
running_build_number=running_build_number):
update_pipeline(
bk,
organization=organization,
pipeline_slug=pipeline_slug,
configuration=configuration,
running_pipeline=running_pipeline,
running_build_number=running_build_number,
running_commit=running_commit,
trusted=trusted,
dry_run=dry_run,
)
except Exception as e:
if first_error is None:
first_error = e
print(e)
return first_error
def parse_args():
force_update = os.environ.get(FORCE_UPDATE_ENV_VAR)
force_update = not (force_update is None or force_update == "0" or
force_update.lower() == "false" or force_update == "")
parser = argparse.ArgumentParser(
description="Updates the configurations for all Buildkite pipelines.")
parser.add_argument(
"--force",
action="store_true",
default=force_update,
help=(f"Force updates for all pipelines without checking the existing"
f" configuration. Use with caution. Can also be set via the"
f" environment variable {FORCE_UPDATE_ENV_VAR}."))
parser.add_argument("--dry-run",
action="store_true",
help="Don't actually update or create any pipelines.")
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["IREE_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"]
repository = os.environ["BUILDKITE_REPO"]
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)
trusted_pipeline_files = glob.iglob(
os.path.join(PIPELINE_ROOT_PATH, "trusted", "*.yml"))
untrusted_pipeline_files = glob.iglob(
os.path.join(PIPELINE_ROOT_PATH, "untrusted", "*.yml"))
if args.pipelines:
trusted_pipeline_files = (
p for p in trusted_pipeline_files if get_slug(p) in args.pipelines)
untrusted_pipeline_files = (
p for p in untrusted_pipeline_files if get_slug(p) in args.pipelines)
if args.force:
print("Was passed force, so not checking existing pipeline configurations.")
first_error = update_pipelines(bk,
trusted_pipeline_files,
organization=organization,
running_pipeline=running_pipeline,
running_build_number=running_build_number,
running_commit=running_commit,
trusted=True,
force=args.force,
dry_run=args.dry_run)
first_error = (first_error or
update_pipelines(bk,
untrusted_pipeline_files,
organization=organization,
running_pipeline=running_pipeline,
running_build_number=running_build_number,
running_commit=running_commit,
trusted=False,
force=args.force,
dry_run=args.dry_run))
if first_error is not None:
print("Encountered errors. Stack of first error:")
raise first_error
if __name__ == "__main__":
main(parse_args())