blob: 90e5e4846e39f283fa97e633e510ae0d7d746abc [file] [log] [blame]
# Copyright 2017 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""cros tryjob: Schedule a tryjob."""
import logging
import os
import time
from chromite.third_party.google.protobuf import json_format
from chromite.cbuildbot import trybot_patch_pool
from chromite.cli import command
from chromite.lib import config_lib
from chromite.lib import constants
from chromite.lib import cros_build_lib
from chromite.lib import git
from chromite.lib import request_build
from chromite.scripts import cbuildbot as cbuildbot_lib
from chromite.utils import pformat
REMOTE = "remote"
INFRA_TESTING = "infra-testing"
LOCAL = "local"
CBUILDBOT = "cbuildbot"
def ConfigsToPrint(site_config, production, build_config_fragments):
"""Select a list of buildbot configs to print out.
Args:
site_config: config_lib.SiteConfig containing all config info.
production: Display tryjob or production configs?.
build_config_fragments: List of strings to filter config names with.
Returns:
List of config_lib.BuildConfig objects.
"""
configs = site_config.values()
def optionsMatch(config):
# In this case, build_configs are config name fragments. If the config
# name doesn't contain any of the fragments, filter it out.
for build_config in build_config_fragments:
if build_config not in config.name:
return False
return config_lib.isTryjobConfig(config) != production
# All configs, filtered by optionsMatch.
configs = [config for config in configs if optionsMatch(config)]
# Sort build type, then board.
# 'daisy-paladin-tryjob' -> ['tryjob', 'paladin', 'daisy']
configs.sort(key=lambda c: list(reversed(c.name.split("-"))))
return configs
def PrintKnownConfigs(site_config, production, build_config_fragments):
"""Print a list of known buildbot configs.
Args:
site_config: config_lib.SiteConfig containing all config info.
production: Display tryjob or production configs?.
build_config_fragments: List of strings to filter config names with.
"""
configs = ConfigsToPrint(site_config, production, build_config_fragments)
COLUMN_WIDTH = max([0] + [len(c.name) for c in configs]) + 1
if production:
print("Production configs:")
else:
print("Tryjob configs:")
print("config".ljust(COLUMN_WIDTH), "description")
print("------".ljust(COLUMN_WIDTH), "-----------")
for config in configs:
desc = config.description or ""
print(config.name.ljust(COLUMN_WIDTH), desc)
def CbuildbotArgs(options):
"""Function to generate cbuildbot command line args.
This are pre-api version filtering.
Args:
options: Parsed cros tryjob tryjob arguments.
Returns:
List of strings in ['arg1', 'arg2'] format
"""
args = []
if options.where in (REMOTE, INFRA_TESTING):
if options.production:
args.append("--buildbot")
else:
args.append("--remote-trybot")
elif options.where == LOCAL:
args.extend(
(
"--buildroot",
options.buildroot,
"--git-cache-dir",
options.git_cache_dir,
"--no-buildbot-tags",
)
)
if options.production:
# This is expected to fail on workstations without an explicit
# --debug.
args.append("--buildbot")
else:
args.append("--debug")
elif options.where == CBUILDBOT:
args.extend(
(
"--buildroot",
os.path.join(options.buildroot, "repository"),
"--workspace",
os.path.join(options.buildroot, "workspace"),
"--git-cache-dir",
options.git_cache_dir,
"--debug",
"--nobootstrap",
"--noreexec",
"--no-buildbot-tags",
)
)
if options.production:
# This is expected to fail on workstations without an explicit
# --debug.
args.append("--buildbot")
else:
raise Exception("Unknown options.where: %s" % (options.where,))
if options.branch:
args.extend(("-b", options.branch))
for g in options.gerrit_patches:
args.extend(("-g", g))
if options.hwtest_dut_dimensions:
args.extend(
("--hwtest_dut_dimensions", " ".join(options.hwtest_dut_dimensions))
)
if options.passthrough:
args.extend(options.passthrough)
if options.passthrough_raw:
args.extend(options.passthrough_raw)
return args
def CreateBuildrootIfNeeded(buildroot):
"""Create the buildroot is it doesn't exist with confirmation prompt.
Args:
buildroot: The buildroot path to create as a string.
Returns:
boolean: Does the buildroot now exist?
"""
if os.path.exists(buildroot):
return True
prompt = "Create %s as buildroot" % buildroot
if not cros_build_lib.BooleanPrompt(prompt=prompt, default=False):
print(
"Please specify a different buildroot via the --buildroot option."
)
return False
os.makedirs(buildroot)
return True
def RunLocal(options):
"""Run a local tryjob.
Args:
options: Parsed cros tryjob tryjob arguments.
Returns:
Exit code of build as an int.
"""
if cros_build_lib.IsInsideChroot():
cros_build_lib.Die("Local tryjobs cannot be started inside the chroot.")
args = CbuildbotArgs(options)
if not CreateBuildrootIfNeeded(options.buildroot):
return 1
# Define the command to run.
launcher = constants.CHROMITE_DIR / "scripts" / "cbuildbot_launch"
cmd = [launcher] + args + options.build_configs
# Run the tryjob.
result = cros_build_lib.run(
cmd, debug_level=logging.CRITICAL, check=False, cwd=options.buildroot
)
return result.returncode
def RunCbuildbot(options):
"""Run a cbuildbot build.
Args:
options: Parsed cros tryjob tryjob arguments.
Returns:
Exit code of build as an int.
"""
if cros_build_lib.IsInsideChroot():
cros_build_lib.Die(
"cbuildbot tryjobs cannot be started inside the chroot."
)
args = CbuildbotArgs(options)
if not CreateBuildrootIfNeeded(options.buildroot):
return 1
# Define the command to run.
cbuildbot = constants.CHROMITE_BIN_DIR / "cbuildbot"
cmd = [cbuildbot] + args + options.build_configs
# Run the tryjob.
result = cros_build_lib.run(
cmd, debug_level=logging.CRITICAL, check=False, cwd=options.buildroot
)
return result.returncode
def DisplayLabel(site_config, options, build_config_name):
"""Decide which display_label to use.
Args:
site_config: config_lib.SiteConfig containing all config info.
options: Parsed command line options for cros tryjob.
build_config_name: Name of the build config we are scheduling.
Returns:
String to use as the cbb_build_label value.
"""
# Production tryjobs always display as production tryjobs.
if options.production:
return config_lib.DISPLAY_LABEL_PRODUCTION_TRYJOB
# Our site_config is only valid for the current branch. If the build
# config is known and has an explicit display_label, use it.
# to be 'main'.
if (
options.branch == "main"
and build_config_name in site_config
and site_config[build_config_name].display_label
):
return site_config[build_config_name].display_label
# Fall back to default.
return config_lib.DISPLAY_LABEL_TRYJOB
def FindUserEmail(options):
"""Decide which email address is submitting the job.
Args:
options: Parsed command line options for cros tryjob.
Returns:
Email address for the tryjob as a string.
"""
if options.committer_email:
return options.committer_email
cwd = os.path.dirname(os.path.realpath(__file__))
return git.GetProjectUserEmail(cwd)
def PushLocalPatches(local_patches, user_email, dryrun=False):
"""Push local changes to a remote ref, and generate args to send.
Args:
local_patches: patch_pool.local_patches from verified patch_pool.
user_email: Unique id for user submitting this tryjob.
dryrun: Is this a dryrun? If so, don't really push.
Returns:
List of strings to pass to builder to include these patches.
"""
manifest = git.ManifestCheckout.Cached(constants.SOURCE_ROOT)
current_time = str(int(time.time()))
ref_base = os.path.join("refs/tryjobs", user_email, current_time)
extra_args = []
for patch in local_patches:
# Isolate the name; if it's a tag or a remote, let through.
# Else if it's a branch, get the full branch name minus refs/heads.
local_branch = git.StripRefsHeads(patch.ref, False)
ref_final = os.path.join(ref_base, local_branch, patch.sha1)
checkout = patch.GetCheckout(manifest)
checkout.AssertPushable()
print("Uploading patch %s" % patch)
patch.Upload(checkout["push_url"], ref_final, dryrun=dryrun)
# TODO(crbug/216095): Pass in the remote instead of tag.
tag = constants.EXTERNAL_PATCH_TAG
if checkout["remote"] == config_lib.GetSiteParams().INTERNAL_REMOTE:
tag = constants.INTERNAL_PATCH_TAG
extra_args.append(
"--remote-patches=%s:%s:%s:%s:%s"
% (
patch.project,
local_branch,
ref_final,
patch.tracking_branch,
tag,
)
)
return extra_args
def RunRemote(
site_config, options, patch_pool, infra_testing=False, production=False
):
"""Schedule remote tryjobs."""
logging.info(
"Scheduling remote tryjob(s): %s", ", ".join(options.build_configs)
)
luci_builder = None
if infra_testing:
luci_builder = config_lib.LUCI_BUILDER_INFRA_TESTING
# Production tryjobs actually execute in the Release group
elif production:
luci_builder = config_lib.LUCI_BUILDER_RELEASE
user_email = FindUserEmail(options)
# Figure out the cbuildbot command line to pass in.
args = CbuildbotArgs(options)
args += PushLocalPatches(patch_pool.local_patches, user_email)
if options.debug:
# default_debug template used to test email templates before they go
# live.
email_template = "default_debug"
else:
email_template = "tryjob"
logging.info("Submitting tryjob...")
results = []
for build_config in options.build_configs:
tryjob = request_build.RequestBuild(
build_config=build_config,
luci_builder=luci_builder,
display_label=DisplayLabel(site_config, options, build_config),
branch=options.branch,
extra_args=args,
user_email=user_email,
email_template=email_template,
)
results.append(tryjob.Submit(dryrun=False))
if options.json:
# Convert Buildbucket Build object to dict, output as json.
print(pformat.json([json_format.MessageToDict(r) for r in results]))
else:
print("Tryjob submitted!")
print("To view your tryjobs, visit:")
for r in results:
print(f"{constants.CHROMEOS_MILO_HOST}{r.id}")
def VerifyOptions(options, site_config):
"""Verify that our command line options make sense.
Args:
options: Parsed cros tryjob tryjob arguments.
site_config: config_lib.SiteConfig containing all config info.
"""
# Handle --list before checking that everything else is valid.
if options.list:
PrintKnownConfigs(
site_config, options.production, options.build_configs
)
raise cros_build_lib.DieSystemExit(0) # Exit with success code.
# Validate specified build_configs.
if not options.build_configs:
cros_build_lib.Die("At least one build_config is required.")
on_branch = options.branch != "main"
if not (options.yes or on_branch):
unknown_build_configs = [
b for b in options.build_configs if b not in site_config
]
if unknown_build_configs:
prompt = (
"Unknown build configs; are you sure you want to schedule "
"for %s?" % ", ".join(unknown_build_configs)
)
if not cros_build_lib.BooleanPrompt(prompt=prompt, default=False):
cros_build_lib.Die("No confirmation.")
unsupported_tryjobs = []
for build_config in options.build_configs:
for suffix in ("-release", "-payloads", "-full"):
if suffix in build_config:
unsupported_tryjobs.append(build_config)
break
# The user may have specified a --version but not a --branch, need to check
# both to determine `cros tryjob` support.
specified_version = None
if options.passthrough:
for i, val in enumerate(options.passthrough):
if val.startswith("--version"):
if "=" in val:
specified_version = val.split("=", 1)[-1]
else:
specified_version = options.passthrough[i + 1]
if unsupported_tryjobs:
unsupported_branch = False
version = specified_version
if not version and on_branch:
tokens = options.branch.split("-")
if tokens[0] in ("release", "stabilize", "firmware"):
version = tokens[-1]
if version:
if int(version.split(".")[0]) >= 15183:
unsupported_branch = True
if (not on_branch and not specified_version) or unsupported_branch:
cros_build_lib.Die(
"`cros tryjob` is unsupported for %s on milestones >= 108, "
"please use `cros try`.",
",".join(unsupported_tryjobs),
)
# Ensure that production configs are only run with --production.
if not (on_branch or options.production or options.where == CBUILDBOT):
# We can't know if branched configs are tryjob safe.
# It should always be safe to run a tryjob config with --production.
prod_configs = []
for b in options.build_configs:
if b in site_config and not config_lib.isTryjobConfig(
site_config[b]
):
prod_configs.append(b)
if prod_configs:
# Die, and explain why.
alternative_configs = ["%s-tryjob" % b for b in prod_configs]
msg = (
"These configs are not tryjob safe:\n"
" %s\n"
"Consider these configs instead:\n"
" %s\n"
"See go/cros-explicit-tryjob-build-configs-psa."
% (", ".join(prod_configs), ", ".join(alternative_configs))
)
if options.branch == "main":
# On main branch, we know the status of configs for sure.
cros_build_lib.Die(msg)
elif not options.yes:
# On branches, we are just guessing. Let people override.
prompt = "%s\nAre you sure you want to continue?" % msg
if not cros_build_lib.BooleanPrompt(
prompt=prompt, default=False
):
cros_build_lib.Die("No confirmation.")
patches_given = options.gerrit_patches or options.local_patches
if options.production:
# Make sure production builds don't have patches.
if patches_given and not options.debug:
cros_build_lib.Die(
"Patches cannot be included in production builds."
)
elif options.where != CBUILDBOT:
# Ask for confirmation if there are no patches to test.
if not patches_given and not options.yes:
prompt = (
"No patches were provided; are you sure you want to just "
"run a build of %s?"
% (options.branch if options.branch else "ToT")
)
if not cros_build_lib.BooleanPrompt(prompt=prompt, default=False):
cros_build_lib.Die("No confirmation.")
if options.where in (REMOTE, INFRA_TESTING):
if options.buildroot:
cros_build_lib.Die("--buildroot is not used for remote tryjobs.")
if options.git_cache_dir:
cros_build_lib.Die(
"--git-cache-dir is not used for remote tryjobs."
)
else:
if options.json:
cros_build_lib.Die("--json can only be used for remote tryjobs.")
if options.hwtest_dut_dimensions:
has_board = has_model = has_pool = False
for dim in options.hwtest_dut_dimensions:
if dim.startswith(cbuildbot_lib.BOARD_DIM_LABEL):
has_board = True
elif dim.startswith(cbuildbot_lib.MODEL_DIM_LABEL):
has_model = True
elif dim.startswith(cbuildbot_lib.POOL_DIM_LABEL):
has_pool = True
if not (has_board and has_model and has_pool):
cros_build_lib.Die(
"HWTest DUT dimensions must include board, model, and "
"pool (given %s)." % options.hwtest_dut_dimensions
)
@command.command_decorator("tryjob")
class TryjobCommand(command.CliCommand):
"""Schedule a tryjob."""
EPILOG = """
Remote Examples:
cros tryjob -g 123 lumpy-compile-only-pre-cq
cros tryjob -g 123 -g 456 lumpy-compile-only-pre-cq daisy-pre-cq
cros tryjob -g *123 --hwtest daisy-paladin-tryjob
cros tryjob -p chromiumos/chromite lumpy-compile-only-pre-cq
cros tryjob -p chromiumos/chromite:foo_branch lumpy-paladin-tryjob
Local Examples:
cros tryjob --local -g 123 daisy-paladin-tryjob
cros tryjob --local --buildroot /my/cool/path -g 123 daisy-paladin-tryjob
Production Examples (danger, can break production if misused):
cros tryjob --production --branch release-R61-9765.B asuka-release
cros tryjob --production --version 9795.0.0 --channel canary lumpy-payloads
List Examples:
cros tryjob --list
cros tryjob --production --list
cros tryjob --list lumpy
cros tryjob --list lumpy vmtest
"""
@classmethod
def AddParser(cls, parser):
"""Adds a parser."""
super(cls, TryjobCommand).AddParser(parser)
parser.add_argument(
"build_configs", nargs="*", help="One or more configs to build."
)
parser.add_argument(
"-b",
"--branch",
default="main",
help="The manifest branch to test. The branch to "
"check the buildroot out to.",
)
parser.add_argument(
"--profile",
dest="passthrough",
action="append_option_value",
help="Name of profile to sub-specify board variant.",
)
parser.add_argument(
"--yes",
action="store_true",
default=False,
help="Never prompt to confirm.",
)
parser.add_argument(
"--production",
action="store_true",
default=False,
help="This is a production build, NOT a test build. "
"Confirm with Chrome OS deputy before use.",
)
parser.add_argument(
"--pass-through",
dest="passthrough_raw",
action="append",
help="Arguments to pass to cbuildbot. To be avoided."
"Confirm with Chrome OS deputy before use.",
)
parser.add_argument(
"--json",
action="store_true",
default=False,
help="Return details of remote tryjob in script friendly output.",
)
# Do we build locally, on on a trybot builder?
where_group = parser.add_argument_group(
"Where", description="Where do we run the tryjob?"
)
where_ex = where_group.add_mutually_exclusive_group()
where_ex.add_argument(
"--remote",
dest="where",
action="store_const",
const=REMOTE,
default=REMOTE,
help="Run the tryjob on a remote builder. (default)",
)
where_ex.add_argument(
"--infra-testing",
dest="where",
action="store_const",
const=INFRA_TESTING,
help="Run the tryjob against the infra-testing swarming pool.",
)
where_ex.add_argument(
"--swarming",
dest="where",
action="store_const",
const=REMOTE,
help="Run the tryjob on a swarming builder. (deprecated)",
)
where_ex.add_argument(
"--local",
dest="where",
action="store_const",
const=LOCAL,
help="Run the tryjob on your local machine.",
)
where_ex.add_argument(
"--cbuildbot",
dest="where",
action="store_const",
const=CBUILDBOT,
help="Run special local build from current checkout in buildroot.",
)
where_group.add_argument(
"-r",
"--buildroot",
type="path",
help="Root directory to use for the local tryjob. "
"NOT the current checkout.",
)
where_group.add_argument(
"--git-cache-dir",
type="path",
help="Git cache directory to use for local tryjobs.",
)
# What patches do we include in the build?
what_group = parser.add_argument_group(
"Patch",
description="Which patches should be included with the tryjob?",
)
what_group.add_argument(
"-g",
"--gerrit-patches",
action="split_extend",
default=[],
metavar="Id1 *int_Id2...IdN",
help="Space-separated list of short-form Gerrit "
"Change-Id's or change numbers to patch. "
"Please prepend '*' to internal Change-Id's",
)
# We have to format metavar poorly to workaround an argparse bug.
# https://bugs.python.org/issue11874
what_group.add_argument(
"-p",
"--local-patches",
action="split_extend",
default=[],
metavar="'<project1>[:<branch1>] ... <projectN>[:<branchN>] '",
help="Space-separated list of project branches with "
"patches to apply. Projects are specified by name. "
"If no branch is specified the current branch of the "
"project will be used. NOTE: -p is known to be buggy; "
"prefer using -g instead (see https://crbug.com/806963 "
"and https://crbug.com/807834).",
)
# Identifing the request.
who_group = parser.add_argument_group(
"Requestor", description="Who is submitting the jobs?"
)
who_group.add_argument(
"--committer-email", help="Override default git committer email."
)
# Modify the build.
how_group = parser.add_argument_group(
"Modifiers", description="How do we modify build behavior?"
)
how_group.add_argument(
"--latest-toolchain",
dest="passthrough",
action="append_option",
help="Use the latest toolchain.",
)
how_group.add_argument(
"--nochromesdk",
dest="passthrough",
action="append_option",
help="Don't run the ChromeSDK stage which builds "
"Chrome outside of the chroot.",
)
how_group.add_argument(
"--timeout",
dest="passthrough",
action="append_option_value",
help="Specify the maximum amount of time this job "
"can run for, at which point the build will be "
"aborted. If set to zero, then there is no "
"timeout.",
)
how_group.add_argument(
"--confidence-check-build",
dest="passthrough",
action="append_option",
help="Run the build as a confidence check build.",
)
how_group.add_argument(
"--chrome_version",
dest="passthrough",
action="append_option_value",
help="Used with SPEC logic to force a particular "
"git revision of chrome rather than the latest. "
"HEAD is a valid value.",
)
how_group.add_argument(
"--debug-cidb",
dest="passthrough",
action="append_option",
help="Force Debug CIDB to be used.",
)
how_group.add_argument(
"--no-publish-prebuilt-confs",
dest="passthrough",
action="append_option",
help="Force the tryjob to not publish commits to prebuilt.conf or "
"sdk_version.conf, even if run in production.",
)
# Overrides for the build configs testing behaviors.
test_group = parser.add_argument_group(
"Testing Flags", description="How do we change testing behavior?"
)
test_group.add_argument(
"--hwtest",
dest="passthrough",
action="append_option",
help="Enable hwlab testing. Default false.",
)
test_group.add_argument(
"--hwtest_dut_dimensions",
action="split_extend",
default=None,
help="Space-separated list of key:val Swarming bot "
"dimensions to run each builders SkylabHWTest "
"stages against (this overrides the configured "
"DUT dimensions for each test). Requires at least "
'"label-board", "label-model", and "label-pool".',
)
test_group.add_argument(
"--notests",
dest="passthrough",
action="append_option",
help="Override values from buildconfig, run no "
"tests, and build no autotest artifacts.",
)
test_group.add_argument(
"--novmtests",
dest="passthrough",
action="append_option",
help="Override values from buildconfig, run no vmtests.",
)
test_group.add_argument(
"--noimagetests",
dest="passthrough",
action="append_option",
help="Override values from buildconfig and run no image tests.",
)
# <board>-payloads tryjob specific options.
payloads_group = parser.add_argument_group(
"Payloads", description="Options only used by payloads tryjobs."
)
payloads_group.add_argument(
"--version",
dest="passthrough",
action="append_option_value",
help="Specify the release version for payload regeneration. "
"Ex: 9799.0.0",
)
payloads_group.add_argument(
"--channel",
dest="passthrough",
action="append_option_value",
help="Specify a channel for a payloads trybot. Can "
"be specified multiple times. No valid for "
"non-payloads configs.",
)
configs_group = parser.add_argument_group(
"Configs",
description="Options for displaying available build configs.",
)
configs_group.add_argument(
"-l",
"--list",
action="store_true",
dest="list",
default=False,
help="List the trybot configs (adjusted by --production).",
)
@classmethod
def ProcessOptions(cls, parser, options):
"""Post process options."""
if options.where == CBUILDBOT:
options.buildroot = options.buildroot or os.path.join(
os.path.dirname(constants.SOURCE_ROOT), "cbuild"
)
if options.where == LOCAL:
options.buildroot = options.buildroot or os.path.join(
os.path.dirname(constants.SOURCE_ROOT), "tryjob"
)
if options.buildroot:
options.git_cache_dir = options.git_cache_dir or os.path.join(
options.buildroot, ".git_cache"
)
def Run(self):
"""Runs `cros tryjob`."""
site_config = config_lib.GetConfig()
VerifyOptions(self.options, site_config)
logging.info("Verifying patches...")
patch_pool = trybot_patch_pool.TrybotPatchPool.FromOptions(
gerrit_patches=self.options.gerrit_patches,
local_patches=self.options.local_patches,
sourceroot=constants.SOURCE_ROOT,
remote_patches=[],
)
if self.options.where == REMOTE:
return RunRemote(site_config, self.options, patch_pool)
elif self.options.where == INFRA_TESTING:
return RunRemote(
site_config, self.options, patch_pool, infra_testing=True
)
elif self.options.production:
return RunRemote(
site_config, self.options, patch_pool, production=True
)
elif self.options.where == LOCAL:
return RunLocal(self.options)
elif self.options.where == CBUILDBOT:
return RunCbuildbot(self.options)
else:
raise Exception("Unknown options.where: %s" % (self.options.where,))