blob: 186b1e02b81bbe2f5da93e446635a8907c35a389 [file] [log] [blame]
# Copyright 2016 The ChromiumOS Authors
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Bootstrap for cbuildbot.
This script is intended to checkout chromite on the branch specified by -b or
--branch (as normally accepted by cbuildbot), and then invoke cbuildbot. Most
arguments are not parsed, only passed along. If a branch is not specified, this
script will use 'main'.
Among other things, this allows us to invoke build configs that exist on a given
branch, but not on TOT.
"""
import base64
import functools
import logging
import os
from pathlib import Path
import time
from chromite.cbuildbot import cbuildbot_alerts
from chromite.cbuildbot import repository
from chromite.cbuildbot.stages import sync_stages
from chromite.lib import boto_compat
from chromite.lib import build_summary
from chromite.lib import chroot_lib
from chromite.lib import config_lib
from chromite.lib import constants
from chromite.lib import cros_build_lib
from chromite.lib import cros_sdk_lib
from chromite.lib import metrics
from chromite.lib import osutils
from chromite.lib import timeout_util
from chromite.lib import ts_mon_config
from chromite.scripts import cbuildbot
# This number should be incremented when we change the layout of the buildroot
# in a non-backwards compatible way. This wipes all buildroots.
BUILDROOT_BUILDROOT_LAYOUT = 2
_DISTFILES_CACHE_EXPIRY_HOURS = 8 * 24
# Metrics reported to Monarch.
METRIC_PREFIX = "chromeos/chromite/cbuildbot_launch/"
METRIC_ACTIVE = METRIC_PREFIX + "active"
METRIC_INVOKED = METRIC_PREFIX + "invoked"
METRIC_COMPLETED = METRIC_PREFIX + "completed"
METRIC_PREP = METRIC_PREFIX + "prep_completed"
METRIC_CLEAN = METRIC_PREFIX + "clean_buildroot_durations"
METRIC_INITIAL = METRIC_PREFIX + "initial_checkout_durations"
METRIC_CBUILDBOT = METRIC_PREFIX + "cbuildbot_durations"
METRIC_CBUILDBOT_INSTANCE = METRIC_PREFIX + "cbuildbot_instance_durations"
METRIC_CLOBBER = METRIC_PREFIX + "clobber"
METRIC_BRANCH_CLEANUP = METRIC_PREFIX + "branch_cleanup"
METRIC_DISTFILES_CLEANUP = METRIC_PREFIX + "distfiles_cleanup"
METRIC_CHROOT_CLEANUP = METRIC_PREFIX + "chroot_cleanup"
# Builder state
BUILDER_STATE_FILENAME = ".cbuildbot_build_state.json"
def StageDecorator(functor):
"""A Decorator that adds buildbot stage tags around a method.
It uses the method name as the stage name, and assumes failure on a true
return value, or an exception.
"""
@functools.wraps(functor)
def wrapped_functor(*args, **kwargs):
try:
cbuildbot_alerts.PrintBuildbotStepName(functor.__name__)
result = functor(*args, **kwargs)
except Exception:
cbuildbot_alerts.PrintBuildbotStepFailure()
raise
if result:
cbuildbot_alerts.PrintBuildbotStepFailure()
return result
return wrapped_functor
def field(fields, **kwargs):
"""Helper for inserting more fields into a metrics fields dictionary.
Args:
fields: Dictionary of metrics fields.
**kwargs: Each argument is a key/value pair to insert into dict.
Returns:
Copy of original dictionary with kwargs set as fields.
"""
f = fields.copy()
f.update(kwargs)
return f
def PrependPath(prepend):
"""Generate path with new directory at the beginning.
Args:
prepend: Directory to add at the beginning of the path.
Returns:
Extended path as a string.
"""
return os.pathsep.join([prepend, os.environ.get("PATH", os.defpath)])
def PreParseArguments(argv):
"""Extract the branch name from cbuildbot command line arguments.
Args:
argv: The command line arguments to parse.
Returns:
Branch as a string ('main' if nothing is specified).
"""
parser = cbuildbot.CreateParser()
options = cbuildbot.ParseCommandLine(parser, argv)
if not options.cache_dir:
options.cache_dir = os.path.join(
options.buildroot, "repository", ".cache"
)
options.Freeze()
# This option isn't required for cbuildbot, but is for us.
if not options.buildroot:
cros_build_lib.Die("--buildroot is a required option.")
return options
def GetCurrentBuildState(options, branch):
"""Extract information about the current build state from command-line args.
Args:
options: A parsed options object from a cbuildbot parser.
branch: The name of the branch this builder was called with.
Returns:
A BuildSummary object describing the current build.
"""
build_state = build_summary.BuildSummary(
status=constants.BUILDER_STATUS_INFLIGHT,
buildroot_layout=BUILDROOT_BUILDROOT_LAYOUT,
branch=branch,
)
if options.buildnumber:
build_state.build_number = options.buildnumber
if options.buildbucket_id:
build_state.buildbucket_id = options.buildbucket_id
if options.master_build_id:
build_state.master_build_id = options.master_build_id
return build_state
def GetLastBuildState(root):
"""Fetch the state of the last build run from |root|.
If the saved state file can't be read or doesn't contain valid JSON, a
default state will be returned.
Args:
root: Root of the working directory tree as a string.
Returns:
A BuildSummary object representing the previous build.
"""
state_file = os.path.join(root, BUILDER_STATE_FILENAME)
state = build_summary.BuildSummary()
try:
state_raw = osutils.ReadFile(state_file)
state.from_json(state_raw)
except IOError as e:
logging.info(
"Unable to read %s: %s. Expected for first task on bot.",
state_file,
e,
)
return state
except ValueError as e:
logging.warning(
"Saved state file %s is not valid JSON: %s", state_file, e
)
return state
if not state.is_valid():
logging.warning("Previous build state is not valid. Ignoring.")
state = build_summary.BuildSummary()
return state
def SetLastBuildState(root, new_state):
"""Save the state of the last build under |root|.
Args:
root: Root of the working directory tree as a string.
new_state: BuildSummary object containing the state to be saved.
"""
state_file = os.path.join(root, BUILDER_STATE_FILENAME)
osutils.WriteFile(state_file, new_state.to_json())
# Remove old state file. Its contents have been migrated into the new file.
old_state_file = os.path.join(root, ".cbuildbot_launch_state")
osutils.SafeUnlink(old_state_file)
def _MaybeCleanDistfiles(cache_dir, distfiles_ts):
"""Cleans the distfiles directory if too old.
Args:
cache_dir: Directory of the cache, as a string.
distfiles_ts: A timestamp str for the last time distfiles was cleaned.
May be None.
Returns:
The new distfiles_ts to persist in state.
"""
# distfiles_ts can be None for a fresh environment, which means clean.
if distfiles_ts is None:
return time.time()
distfiles_age = (time.time() - distfiles_ts) / 3600.0
if distfiles_age < _DISTFILES_CACHE_EXPIRY_HOURS:
return distfiles_ts
logging.info(
"Remove old distfiles cache (cache expiry %d hours)",
_DISTFILES_CACHE_EXPIRY_HOURS,
)
osutils.RmDir(
os.path.join(cache_dir, "distfiles"), ignore_missing=True, sudo=True
)
metrics.Counter(METRIC_DISTFILES_CLEANUP).increment(
fields=field({}, reason="cache_expired")
)
# Cleaned cache, so reset distfiles_ts
return time.time()
def SanitizeCacheDir(cache_dir):
"""Make certain the .cache directory is valid.
Args:
cache_dir: Directory of the cache, as a string.
"""
logging.info("Cleaning up cache dir at %s", cache_dir)
# Verify that .cache is writable by the current user.
try:
osutils.Touch(
os.path.join(cache_dir, ".cbuildbot_launch"), makedirs=True
)
except IOError:
logging.info("Bad Permissions for cache dir, wiping: %s", cache_dir)
osutils.RmDir(cache_dir, sudo=True)
osutils.Touch(
os.path.join(cache_dir, ".cbuildbot_launch"), makedirs=True
)
osutils.RmDir(
os.path.join(cache_dir, "paygen_cache"), ignore_missing=True, sudo=True
)
logging.info("Finished cleaning cache_dir.")
@StageDecorator
def CleanBuildRoot(root, repo, cache_dir, build_state, source_cache=False):
"""Some kinds of branch transitions break builds.
This method ensures that cbuildbot's buildroot is a clean checkout on the
given branch when it starts. If necessary (a branch transition) it will wipe
assorted state that cannot be safely reused from the previous build.
Args:
root: Root directory owned by cbuildbot_launch.
repo: repository.RepoRepository instance.
cache_dir: Cache directory.
build_state: BuildSummary object containing the current build state that
will be saved into the cleaned root. The distfiles_ts property will
be updated if the distfiles cache is cleaned.
source_cache: Bool whether to use source cache mounts.
"""
previous_state = GetLastBuildState(root)
SetLastBuildState(root, build_state)
SanitizeCacheDir(cache_dir)
build_state.distfiles_ts = _MaybeCleanDistfiles(
cache_dir, previous_state.distfiles_ts
)
if not source_cache:
if previous_state.buildroot_layout != BUILDROOT_BUILDROOT_LAYOUT:
cbuildbot_alerts.PrintBuildbotStepText(
"Unknown layout: Wiping buildroot."
)
metrics.Counter(METRIC_CLOBBER).increment(
fields=field({}, reason="layout_change")
)
chroot = chroot_lib.Chroot(
path=root / Path(constants.DEFAULT_CHROOT_DIR),
out_path=root / constants.DEFAULT_OUT_DIR,
)
if os.path.exists(chroot.path):
cros_sdk_lib.CleanupChrootMount(chroot, delete=True)
osutils.RmDir(root, ignore_missing=True, sudo=True)
osutils.RmDir(cache_dir, ignore_missing=True, sudo=True)
else:
if previous_state.branch != repo.branch:
cbuildbot_alerts.PrintBuildbotStepText(
"Branch change: Cleaning buildroot."
)
logging.info(
"Unmatched branch: %s -> %s",
previous_state.branch,
repo.branch,
)
metrics.Counter(METRIC_BRANCH_CLEANUP).increment(
fields=field({}, old_branch=previous_state.branch)
)
logging.info("Remove Chroot.")
chroot = chroot_lib.Chroot(
path=repo.directory / Path(constants.DEFAULT_CHROOT_DIR),
out_path=repo.directory / constants.DEFAULT_OUT_DIR,
)
if os.path.exists(chroot.path):
cros_sdk_lib.CleanupChrootMount(chroot, delete=True)
logging.info("Remove Chrome checkout.")
osutils.RmDir(
os.path.join(repo.directory, ".cache", "distfiles"),
ignore_missing=True,
sudo=True,
)
try:
# If there is any failure doing the cleanup, wipe everything. The
# previous run might have been killed in the middle leaving stale git
# locks. Clean those up, first.
if not source_cache:
repo.PreLoad()
# If the previous build didn't exit normally, run an expensive step to
# clean up abandoned git locks.
if previous_state.status not in (
constants.BUILDER_STATUS_FAILED,
constants.BUILDER_STATUS_PASSED,
):
repo.CleanStaleLocks()
if not source_cache:
repo.BuildRootGitCleanup(prune_all=True)
except Exception:
logging.info(
"Checkout cleanup failed, wiping buildroot:", exc_info=True
)
metrics.Counter(METRIC_CLOBBER).increment(
fields=field({}, reason="repo_cleanup_failure")
)
repository.ClearBuildRoot(repo.directory)
if not source_cache:
# Ensure buildroot exists. Save the state we are prepped for.
osutils.SafeMakedirs(repo.directory)
SetLastBuildState(root, build_state)
@StageDecorator
def InitialCheckout(repo, options):
"""Preliminary ChromeOS checkout.
Perform a complete checkout of ChromeOS on the specified branch. This does
NOT match what the build needs, but ensures the buildroot both has a 'hot'
checkout, and is close enough that the branched cbuildbot can successfully
get the right checkout.
This checks out full ChromeOS, even if a ChromiumOS build is going to be
performed. This is because we have no knowledge of the build config to be
used.
Args:
repo: repository.RepoRepository instance.
options: A parsed options object from a cbuildbot parser.
"""
cbuildbot_alerts.PrintBuildbotStepText("Branch: %s" % repo.branch)
if not options.source_cache:
logging.info(
"Bootstrap script starting initial sync on branch: %s", repo.branch
)
repo.PreLoad("/preload/chromeos")
repo.Sync(
jobs=32, detach=True, downgrade_repo=_ShouldDowngradeRepo(options)
)
def ShouldFixBotoCerts(options):
"""Decide if FixBotoCerts should be applied for this branch."""
try:
# Only apply to factory and firmware branches.
branch = options.branch or ""
prefix = branch.split("-")[0]
if prefix not in ("factory", "firmware"):
return False
# Only apply to "old" branches.
if branch.endswith(".B"):
version = branch[:-2].split("-")[-1]
major = int(version.split(".")[0])
return major <= 9667 # This is the newest known to be failing.
return False
except Exception as e:
logging.warning(" failed: %s", e)
# Conservatively continue without the fix.
return False
def _ShouldDowngradeRepo(options):
"""Determine which repo version to set for the branch.
Repo version is set at cache creation time, in the nightly builder,
which means we are typically at the latest version. Older branches
are incompatible with newer version of ToT, therefore we downgrade
repo to a known working version.
Args:
options: A parsed options object from a cbuildbot parser.
Returns:
bool of whether to downgrade repo version based on branch.
"""
try:
branch = options.branch or ""
# Only apply to "old" branches.
if branch.endswith(".B"):
branch_num = branch[:-2].split("-")[1][1:3]
return branch_num <= 79 # This is the newest known to be failing.
return False
except Exception as e:
logging.warning(" failed: %s", e)
# Conservatively continue without the fix.
return False
@StageDecorator
def Cbuildbot(buildroot, depot_tools_path, argv):
"""Start cbuildbot in specified directory with all arguments.
Args:
buildroot: Directory to be passed to cbuildbot with --buildroot.
depot_tools_path: Directory for depot_tools to be used by cbuildbot.
argv: Command line options passed to cbuildbot_launch.
Returns:
Return code of cbuildbot as an integer.
"""
logging.info("Bootstrap cbuildbot in: %s", buildroot)
# Fixup buildroot parameter.
argv = argv[:]
for i, arg in enumerate(argv):
if arg in ("-r", "--buildroot"):
argv[i + 1] = buildroot
# Source_cache flag is only used to indicate a transition to cache disks
# and doesn't need to be passed back to Cbuildbot.
if "--source_cache" in argv:
argv.remove("--source_cache")
logging.info("Cbuildbot Args: %s", argv)
# This filters out command line arguments not supported by older versions
# of cbuildbot.
parser = cbuildbot.CreateParser()
options = cbuildbot.ParseCommandLine(parser, argv)
cbuildbot_path = os.path.join(buildroot, "chromite", "bin", "cbuildbot")
cmd = sync_stages.BootstrapStage.FilterArgsForTargetCbuildbot(
buildroot, cbuildbot_path, options
)
# We want cbuildbot to use branched depot_tools scripts from our manifest,
# so that depot_tools is branched to match cbuildbot.
logging.info("Adding depot_tools into PATH: %s", depot_tools_path)
extra_env = {"PATH": PrependPath(depot_tools_path)}
# TODO(crbug.com/845304): Remove once underlying boto issues are resolved.
fix_boto = ShouldFixBotoCerts(options)
with boto_compat.FixBotoCerts(activate=fix_boto):
result = cros_build_lib.run(
cmd, extra_env=extra_env, check=False, cwd=buildroot
)
return result.returncode
@StageDecorator
def CleanupChroot(buildroot):
"""Unmount/cleanup an image-based chroot without deleting the backing image.
Args:
buildroot: Directory containing the chroot to be cleaned up.
"""
chroot = chroot_lib.Chroot(
path=buildroot / Path(constants.DEFAULT_CHROOT_DIR),
out_path=buildroot / constants.DEFAULT_OUT_DIR,
)
logging.info("Cleaning up chroot at %s", chroot.path)
if os.path.exists(chroot.path):
try:
cros_sdk_lib.CleanupChrootMount(chroot, delete=False)
except timeout_util.TimeoutError:
logging.exception("Cleaning up chroot timed out")
# Dump debug info to help https://crbug.com/1000034.
cros_build_lib.run(["mount"], check=True)
cros_build_lib.run(["uname", "-a"], check=True)
cros_build_lib.sudo_run(["losetup", "-a"], check=True)
cros_build_lib.run(["dmesg"], check=True)
logging.warning(
"Assuming the bot is going to reboot, so ignoring this "
"failure; see https://crbug.com/1000034"
)
# NB: We ignore errors at this point because this stage runs last. If the
# chroot failed to unmount, we're going to reboot the system once we're
# done, and that will implicitly take care of cleaning things up. If the
# bots stop rebooting after every run, we'll need to make this fatal all the
# time.
#
# TODO(crbug.com/1000034): This should be fatal all the time.
def ConfigureGlobalEnvironment():
"""Setup process wide environmental changes."""
# Set umask to 022 so files created by buildbot are readable.
os.umask(0o22)
# These variables can interfere with LANG / locale behavior.
unwanted_local_vars = [
"LC_ALL",
"LC_CTYPE",
"LC_COLLATE",
"LC_TIME",
"LC_NUMERIC",
"LC_MONETARY",
"LC_MESSAGES",
"LC_PAPER",
"LC_NAME",
"LC_ADDRESS",
"LC_TELEPHONE",
"LC_MEASUREMENT",
"LC_IDENTIFICATION",
]
for v in unwanted_local_vars:
os.environ.pop(v, None)
# This variable is required for repo sync's to work in all cases.
os.environ["LANG"] = "en_US.UTF-8"
def _main(options, argv):
"""main method of script.
Args:
options: preparsed options object for the build.
argv: All command line arguments to pass as list of strings.
Returns:
Return code of cbuildbot as an integer.
"""
branchname = options.branch or "main"
root = options.buildroot
buildroot = os.path.join(root, "repository")
workspace = os.path.join(root, "workspace")
if options.source_cache:
buildroot = options.buildroot
if options.workspace:
workspace = options.workspace
depot_tools_path = os.path.join(buildroot, constants.DEPOT_TOOLS_SUBPATH)
# Does the entire build pass or fail.
with metrics.Presence(METRIC_ACTIVE), metrics.SuccessCounter(
METRIC_COMPLETED
) as s_fields:
# Preliminary set, mostly command line parsing.
with metrics.SuccessCounter(METRIC_INVOKED):
if options.enable_buildbot_tags:
cbuildbot_alerts.EnableBuildbotMarkers()
ConfigureGlobalEnvironment()
# Prepare the buildroot with source for the build.
with metrics.SuccessCounter(METRIC_PREP):
manifest_url = config_lib.GetSiteParams().MANIFEST_INT_URL
repo = repository.RepoRepository(
manifest_url,
buildroot,
branch=branchname,
git_cache_dir=options.git_cache_dir,
)
previous_build_state = GetLastBuildState(root)
# Clean up the buildroot to a safe state.
with metrics.SecondsTimer(METRIC_CLEAN):
build_state = GetCurrentBuildState(options, branchname)
CleanBuildRoot(
root,
repo,
options.cache_dir,
build_state,
options.source_cache,
)
# Get a checkout close enough to the branch that cbuildbot can
# handle it.
if options.sync:
with metrics.SecondsTimer(METRIC_INITIAL):
InitialCheckout(repo, options)
# Run cbuildbot inside the full ChromeOS checkout, on the specified
# branch.
with metrics.SecondsTimer(
METRIC_CBUILDBOT
), metrics.SecondsInstanceTimer(METRIC_CBUILDBOT_INSTANCE):
if previous_build_state.is_valid():
argv.append("--previous-build-state")
argv.append(
base64.b64encode(
previous_build_state.to_json().encode("utf-8")
).decode("utf-8")
)
argv.extend(["--workspace", workspace])
if not options.cache_dir_specified:
argv.extend(["--cache-dir", options.cache_dir])
result = Cbuildbot(buildroot, depot_tools_path, argv)
s_fields["success"] = result == 0
build_state.status = (
constants.BUILDER_STATUS_PASSED
if result == 0
else constants.BUILDER_STATUS_FAILED
)
SetLastBuildState(root, build_state)
with metrics.SecondsTimer(METRIC_CHROOT_CLEANUP):
CleanupChroot(buildroot)
return result
def main(argv):
options = PreParseArguments(argv)
metric_fields = {
"branch_name": options.branch or "main",
"build_config": options.build_config_name,
"tryjob": options.remote_trybot,
}
# Enable Monarch metrics gathering.
with ts_mon_config.SetupTsMonGlobalState(
"cbuildbot_launch", common_metric_fields=metric_fields, indirect=True
):
return _main(options, argv)