| #!/usr/bin/python2 |
| |
| # Copyright (c) 2016 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Module to automate the process of deploying to production. |
| |
| Example usage of this script: |
| 1. Update both autotest and chromite to the lastest commit that has passed |
| the test instance. |
| $ ./site_utils/automated_deploy.py |
| 2. Skip updating a repo, e.g. autotest |
| $ ./site_utils/automated_deploy.py --skip_autotest |
| 3. Update a given repo to a specific commit |
| $ ./site_utils/automated_deploy.py --autotest_hash='1234' |
| """ |
| |
| import argparse |
| import filecmp |
| import os |
| import re |
| import sys |
| import subprocess |
| |
| import common |
| from autotest_lib.client.common_lib import revision_control |
| from autotest_lib.site_utils.lib import infra |
| |
| INFRA_DATA = 'infradata' |
| AUTOTEST_DIR = common.autotest_dir |
| GIT_URL = {'autotest': |
| 'https://chromium.googlesource.com/chromiumos/third_party/autotest', |
| 'chromite': |
| 'https://chromium.googlesource.com/chromiumos/chromite', |
| INFRA_DATA: |
| 'https://chrome-internal.googlesource.com/infradata/config'} |
| PROD_BRANCH = 'prod' |
| MASTER_AFE = 'cautotest' |
| NOTIFY_GROUP = 'chromeos-infra-discuss@google.com' |
| |
| # CIPD packages whose prod refs should be updated. |
| _CIPD_PACKAGES = ( |
| 'chromiumos/infra/lucifer', |
| 'chromiumos/infra/skylab/linux-amd64', |
| 'chromiumos/infra/skylab-inventory', |
| 'chromiumos/infra/skylab_swarming_worker/linux-amd64', |
| 'chromiumos/infra/autotest_status_parser/linux-amd64', |
| ) |
| |
| |
| class AutoDeployException(Exception): |
| """Raised when any deploy step fails.""" |
| |
| |
| def parse_arguments(): |
| """Parse command line arguments. |
| |
| @returns An argparse.Namespace populated with argument values. |
| """ |
| parser = argparse.ArgumentParser( |
| description=('Command to update prod branch for autotest, chromite ' |
| 'repos. Then deploy new changes to all lab servers.')) |
| parser.add_argument('--skip_autotest', action='store_true', default=False, |
| help='Skip updating autotest prod branch. Default is False.') |
| parser.add_argument('--skip_chromite', action='store_true', default=False, |
| help='Skip updating chromite prod branch. Default is False.') |
| parser.add_argument('--skip_prod_config_check', action='store_true', |
| default=False, |
| help='Skip checking prod config matches dev. ' |
| 'Default is False.') |
| parser.add_argument('--force_update', action='store_true', default=False, |
| help=('Force a deployment without updating both autotest and ' |
| 'chromite prod branch')) |
| parser.add_argument('--autotest_hash', type=str, default=None, |
| help='Update autotest prod branch to the given hash. If it is not' |
| ' specified, autotest prod branch will be rebased to ' |
| 'prod-next branch, which is the latest commit that has ' |
| 'passed our test instance.') |
| parser.add_argument('--chromite_hash', type=str, default=None, |
| help='Same as autotest_hash option.') |
| |
| results = parser.parse_args(sys.argv[1:]) |
| |
| # Verify the validity of the options. |
| if ((results.skip_autotest and results.autotest_hash) or |
| (results.skip_chromite and results.chromite_hash)): |
| parser.print_help() |
| print 'Cannot specify skip_* and *_hash options at the same time.' |
| sys.exit(1) |
| if results.force_update: |
| results.skip_autotest = True |
| results.skip_chromite = True |
| return results |
| |
| |
| def verify_dev_infradata_config_matches_prod(): |
| """Checks that dev config matches prod config before push |
| |
| Based on crbug.com/949696, this checks to make sure that dev |
| config changes have been cloned to prod before a prod push. |
| |
| @raises subprocess.CalledProcessError on a command failure. |
| @raised revision_control.GitCloneError when git clone fails. |
| """ |
| |
| repo_dir = os.path.join('/tmp', INFRA_DATA) |
| git_url = GIT_URL[INFRA_DATA] |
| print 'Checking PROD matches DEV config for %s repo' % git_url |
| print 'Cloning %s master branch under %s' % (git_url, repo_dir) |
| if os.path.exists(repo_dir): |
| infra.local_runner('rm -rf %s' % repo_dir) |
| git_repo = revision_control.GitRepo(repo_dir, git_url) |
| git_repo.clone(shallow=True) |
| |
| dev_to_prod_files = { |
| 'configs/chromium-swarm-dev/scripts/skylab.py': |
| 'configs/chromeos-swarming/scripts/skylab.py', |
| } |
| |
| for dev_rel_path, prod_rel_path in dev_to_prod_files.items(): |
| if filecmp.cmp(os.path.join(repo_dir, dev_rel_path), |
| os.path.join(repo_dir, prod_rel_path), |
| shallow=False): |
| continue |
| |
| master_url = git_url + '/+/refs/heads/master/' |
| raise AutoDeployException( |
| '\n\n%s\nDOES NOT MATCH\n%s' |
| '\n\nCopy DEV config to PROD config before performing release' % |
| (master_url + prod_rel_path, master_url + dev_rel_path)) |
| |
| print 'Successfully verified PROD config matches DEV config' |
| |
| |
| def clone_prod_branch(repo): |
| """Method to clone the prod branch for a given repo under /tmp/ dir. |
| |
| @param repo: Name of the git repo to be cloned. |
| |
| @returns path to the cloned repo. |
| @raises subprocess.CalledProcessError on a command failure. |
| @raised revision_control.GitCloneError when git clone fails. |
| """ |
| repo_dir = '/tmp/%s' % repo |
| print 'Cloning %s prod branch under %s' % (repo, repo_dir) |
| if os.path.exists(repo_dir): |
| infra.local_runner('rm -rf %s' % repo_dir) |
| git_repo = revision_control.GitRepo(repo_dir, GIT_URL[repo]) |
| git_repo.clone(remote_branch=PROD_BRANCH) |
| print 'Successfully cloned %s prod branch' % repo |
| return repo_dir |
| |
| |
| def update_prod_branch(repo, repo_dir, hash_to_rebase): |
| """Method to update the prod branch of the given repo to the given hash. |
| |
| @param repo: Name of the git repo to be updated. |
| @param repo_dir: path to the cloned repo. |
| @param hash_to_rebase: Hash to rebase the prod branch to. If it is None, |
| prod branch will rebase to prod-next branch. |
| |
| @returns the range of the pushed commits as a string. E.g 123...345. If the |
| prod branch is already up-to-date, return None. |
| @raises subprocess.CalledProcessError on a command failure. |
| """ |
| with infra.chdir(repo_dir): |
| print 'Updating %s prod branch.' % repo |
| rebase_to = hash_to_rebase if hash_to_rebase else 'origin/prod-next' |
| # Check whether prod branch is already up-to-date, which means there is |
| # no changes since last push. |
| print 'Detecting new changes since last push...' |
| diff = infra.local_runner('git log prod..%s --oneline' % rebase_to, |
| stream_output=True) |
| if diff: |
| print 'Find new changes, will update prod branch...' |
| infra.local_runner('git rebase %s prod' % rebase_to, |
| stream_output=True) |
| result = infra.local_runner('git push origin prod', |
| stream_output=True) |
| print 'Successfully pushed %s prod branch!\n' % repo |
| |
| # Get the pushed commit range, which is used to get pushed commits |
| # using git log E.g. 123..456, then run git log --oneline 123..456. |
| grep = re.search('(\w)*\.\.(\w)*', result) |
| |
| if not grep: |
| raise AutoDeployException( |
| 'Fail to get pushed commits for repo %s from git log: %s' % |
| (repo, result)) |
| return grep.group(0) |
| else: |
| print 'No new %s changes found since last push.' % repo |
| return None |
| |
| |
| def get_pushed_commits(repo, repo_dir, pushed_commits_range): |
| """Method to get the pushed commits. |
| |
| @param repo: Name of the updated git repo. |
| @param repo_dir: path to the cloned repo. |
| @param pushed_commits_range: The range of the pushed commits. E.g 123...345 |
| @return: the commits that are pushed to prod branch. The format likes this: |
| "git log --oneline A...B | grep autotest |
| A xxxx |
| B xxxx" |
| @raises subprocess.CalledProcessError on a command failure. |
| """ |
| print 'Getting pushed CLs for %s repo.' % repo |
| if not pushed_commits_range: |
| return '\n%s:\nNo new changes since last push.' % repo |
| |
| with infra.chdir(repo_dir): |
| get_commits_cmd = 'git log --oneline %s' % pushed_commits_range |
| |
| pushed_commits = infra.local_runner( |
| get_commits_cmd, stream_output=True) |
| if repo == 'autotest': |
| autotest_commits = '' |
| for cl in pushed_commits.splitlines(): |
| if 'autotest' in cl: |
| autotest_commits += '%s\n' % cl |
| |
| pushed_commits = autotest_commits |
| |
| print 'Successfully got pushed CLs for %s repo!\n' % repo |
| displayed_cmd = get_commits_cmd |
| if repo == 'autotest': |
| displayed_cmd += ' | grep autotest' |
| return '\n%s:\n%s\n%s\n' % (repo, displayed_cmd, pushed_commits) |
| |
| |
| def kick_off_deploy(): |
| """Method to kick off deploy script to deploy changes to lab servers. |
| |
| @raises subprocess.CalledProcessError on a repo command failure. |
| """ |
| print 'Start deploying changes to all lab servers...' |
| with infra.chdir(AUTOTEST_DIR): |
| # Then kick off the deploy script. |
| deploy_cmd = ('runlocalssh ./site_utils/deploy_server.py -x --afe=%s' % |
| MASTER_AFE) |
| infra.local_runner(deploy_cmd, stream_output=True) |
| print 'Successfully deployed changes to all lab servers.' |
| |
| |
| def main(args): |
| """Main entry""" |
| options = parse_arguments() |
| repos = dict() |
| if not options.skip_autotest: |
| repos.update({'autotest': options.autotest_hash}) |
| if not options.skip_chromite: |
| repos.update({'chromite': options.chromite_hash}) |
| |
| if not options.skip_prod_config_check: |
| verify_dev_infradata_config_matches_prod() |
| |
| print 'Moving CIPD prod refs to prod-next' |
| for pkg in _CIPD_PACKAGES: |
| subprocess.check_call(['cipd', 'set-ref', pkg, '-version', 'prod-next', |
| '-ref', 'prod']) |
| try: |
| # update_log saves the git log of the updated repo. |
| update_log = '' |
| for repo, hash_to_rebase in repos.iteritems(): |
| repo_dir = clone_prod_branch(repo) |
| push_commits_range = update_prod_branch( |
| repo, repo_dir, hash_to_rebase) |
| update_log += get_pushed_commits(repo, repo_dir, push_commits_range) |
| |
| kick_off_deploy() |
| except revision_control.GitCloneError as e: |
| print 'Fail to clone prod branch. Error:\n%s\n' % e |
| raise |
| except subprocess.CalledProcessError as e: |
| print ('Deploy fails when running a subprocess cmd :\n%s\n' |
| 'Below is the push log:\n%s\n' % (e.output, update_log)) |
| raise |
| except Exception as e: |
| print 'Deploy fails with error:\n%s\nPush log:\n%s\n' % (e, update_log) |
| raise |
| |
| # When deploy succeeds, print the update_log. |
| print ('Deploy succeeds!!! Below is the push log of the updated repo:\n%s' |
| 'Please email this to %s.'% (update_log, NOTIFY_GROUP)) |
| |
| |
| if __name__ == '__main__': |
| sys.exit(main(sys.argv)) |