1#!/usr/bin/python2 2 3# Copyright (c) 2016 The Chromium OS Authors. All rights reserved. 4# Use of this source code is governed by a BSD-style license that can be 5# found in the LICENSE file. 6 7"""Module to automate the process of deploying to production. 8 9Example usage of this script: 10 1. Update both autotest and chromite to the lastest commit that has passed 11 the test instance. 12 $ ./site_utils/automated_deploy.py 13 2. Skip updating a repo, e.g. autotest 14 $ ./site_utils/automated_deploy.py --skip_autotest 15 3. Update a given repo to a specific commit 16 $ ./site_utils/automated_deploy.py --autotest_hash='1234' 17""" 18 19import argparse 20import filecmp 21import os 22import re 23import sys 24import subprocess 25 26import common 27from autotest_lib.client.common_lib import revision_control 28from autotest_lib.site_utils.lib import infra 29 30INFRA_DATA = 'infradata' 31AUTOTEST_DIR = common.autotest_dir 32GIT_URL = {'autotest': 33 'https://chromium.googlesource.com/chromiumos/third_party/autotest', 34 'chromite': 35 'https://chromium.googlesource.com/chromiumos/chromite', 36 INFRA_DATA: 37 'https://chrome-internal.googlesource.com/infradata/config'} 38PROD_BRANCH = 'prod' 39MASTER_AFE = 'cautotest' 40NOTIFY_GROUP = 'chromeos-infra-discuss@google.com' 41 42# CIPD packages whose prod refs should be updated. 43_CIPD_PACKAGES = ( 44 'chromiumos/infra/lucifer', 45 'chromiumos/infra/skylab/linux-amd64', 46 'chromiumos/infra/skylab-inventory', 47 'chromiumos/infra/skylab_swarming_worker/linux-amd64', 48 'chromiumos/infra/autotest_status_parser/linux-amd64', 49) 50 51 52class AutoDeployException(Exception): 53 """Raised when any deploy step fails.""" 54 55 56def parse_arguments(): 57 """Parse command line arguments. 58 59 @returns An argparse.Namespace populated with argument values. 60 """ 61 parser = argparse.ArgumentParser( 62 description=('Command to update prod branch for autotest, chromite ' 63 'repos. Then deploy new changes to all lab servers.')) 64 parser.add_argument('--skip_autotest', action='store_true', default=False, 65 help='Skip updating autotest prod branch. Default is False.') 66 parser.add_argument('--skip_chromite', action='store_true', default=False, 67 help='Skip updating chromite prod branch. Default is False.') 68 parser.add_argument('--skip_prod_config_check', action='store_true', 69 default=False, 70 help='Skip checking prod config matches dev. ' 71 'Default is False.') 72 parser.add_argument('--force_update', action='store_true', default=False, 73 help=('Force a deployment without updating both autotest and ' 74 'chromite prod branch')) 75 parser.add_argument('--autotest_hash', type=str, default=None, 76 help='Update autotest prod branch to the given hash. If it is not' 77 ' specified, autotest prod branch will be rebased to ' 78 'prod-next branch, which is the latest commit that has ' 79 'passed our test instance.') 80 parser.add_argument('--chromite_hash', type=str, default=None, 81 help='Same as autotest_hash option.') 82 83 results = parser.parse_args(sys.argv[1:]) 84 85 # Verify the validity of the options. 86 if ((results.skip_autotest and results.autotest_hash) or 87 (results.skip_chromite and results.chromite_hash)): 88 parser.print_help() 89 print 'Cannot specify skip_* and *_hash options at the same time.' 90 sys.exit(1) 91 if results.force_update: 92 results.skip_autotest = True 93 results.skip_chromite = True 94 return results 95 96 97def verify_dev_infradata_config_matches_prod(): 98 """Checks that dev config matches prod config before push 99 100 Based on crbug.com/949696, this checks to make sure that dev 101 config changes have been cloned to prod before a prod push. 102 103 @raises subprocess.CalledProcessError on a command failure. 104 @raised revision_control.GitCloneError when git clone fails. 105 """ 106 107 repo_dir = os.path.join('/tmp', INFRA_DATA) 108 git_url = GIT_URL[INFRA_DATA] 109 print 'Checking PROD matches DEV config for %s repo' % git_url 110 print 'Cloning %s master branch under %s' % (git_url, repo_dir) 111 if os.path.exists(repo_dir): 112 infra.local_runner('rm -rf %s' % repo_dir) 113 git_repo = revision_control.GitRepo(repo_dir, git_url) 114 git_repo.clone(shallow=True) 115 116 dev_to_prod_files = { 117 'configs/chromium-swarm-dev/scripts/skylab.py': 118 'configs/chromeos-swarming/scripts/skylab.py', 119 } 120 121 for dev_rel_path, prod_rel_path in dev_to_prod_files.items(): 122 if filecmp.cmp(os.path.join(repo_dir, dev_rel_path), 123 os.path.join(repo_dir, prod_rel_path), 124 shallow=False): 125 continue 126 127 master_url = git_url + '/+/refs/heads/master/' 128 raise AutoDeployException( 129 '\n\n%s\nDOES NOT MATCH\n%s' 130 '\n\nCopy DEV config to PROD config before performing release' % 131 (master_url + prod_rel_path, master_url + dev_rel_path)) 132 133 print 'Successfully verified PROD config matches DEV config' 134 135 136def clone_prod_branch(repo): 137 """Method to clone the prod branch for a given repo under /tmp/ dir. 138 139 @param repo: Name of the git repo to be cloned. 140 141 @returns path to the cloned repo. 142 @raises subprocess.CalledProcessError on a command failure. 143 @raised revision_control.GitCloneError when git clone fails. 144 """ 145 repo_dir = '/tmp/%s' % repo 146 print 'Cloning %s prod branch under %s' % (repo, repo_dir) 147 if os.path.exists(repo_dir): 148 infra.local_runner('rm -rf %s' % repo_dir) 149 git_repo = revision_control.GitRepo(repo_dir, GIT_URL[repo]) 150 git_repo.clone(remote_branch=PROD_BRANCH) 151 print 'Successfully cloned %s prod branch' % repo 152 return repo_dir 153 154 155def update_prod_branch(repo, repo_dir, hash_to_rebase): 156 """Method to update the prod branch of the given repo to the given hash. 157 158 @param repo: Name of the git repo to be updated. 159 @param repo_dir: path to the cloned repo. 160 @param hash_to_rebase: Hash to rebase the prod branch to. If it is None, 161 prod branch will rebase to prod-next branch. 162 163 @returns the range of the pushed commits as a string. E.g 123...345. If the 164 prod branch is already up-to-date, return None. 165 @raises subprocess.CalledProcessError on a command failure. 166 """ 167 with infra.chdir(repo_dir): 168 print 'Updating %s prod branch.' % repo 169 rebase_to = hash_to_rebase if hash_to_rebase else 'origin/prod-next' 170 # Check whether prod branch is already up-to-date, which means there is 171 # no changes since last push. 172 print 'Detecting new changes since last push...' 173 diff = infra.local_runner('git log prod..%s --oneline' % rebase_to, 174 stream_output=True) 175 if diff: 176 print 'Find new changes, will update prod branch...' 177 infra.local_runner('git rebase %s prod' % rebase_to, 178 stream_output=True) 179 result = infra.local_runner('git push origin prod', 180 stream_output=True) 181 print 'Successfully pushed %s prod branch!\n' % repo 182 183 # Get the pushed commit range, which is used to get pushed commits 184 # using git log E.g. 123..456, then run git log --oneline 123..456. 185 grep = re.search('(\w)*\.\.(\w)*', result) 186 187 if not grep: 188 raise AutoDeployException( 189 'Fail to get pushed commits for repo %s from git log: %s' % 190 (repo, result)) 191 return grep.group(0) 192 else: 193 print 'No new %s changes found since last push.' % repo 194 return None 195 196 197def get_pushed_commits(repo, repo_dir, pushed_commits_range): 198 """Method to get the pushed commits. 199 200 @param repo: Name of the updated git repo. 201 @param repo_dir: path to the cloned repo. 202 @param pushed_commits_range: The range of the pushed commits. E.g 123...345 203 @return: the commits that are pushed to prod branch. The format likes this: 204 "git log --oneline A...B | grep autotest 205 A xxxx 206 B xxxx" 207 @raises subprocess.CalledProcessError on a command failure. 208 """ 209 print 'Getting pushed CLs for %s repo.' % repo 210 if not pushed_commits_range: 211 return '\n%s:\nNo new changes since last push.' % repo 212 213 with infra.chdir(repo_dir): 214 get_commits_cmd = 'git log --oneline %s' % pushed_commits_range 215 216 pushed_commits = infra.local_runner( 217 get_commits_cmd, stream_output=True) 218 if repo == 'autotest': 219 autotest_commits = '' 220 for cl in pushed_commits.splitlines(): 221 if 'autotest' in cl: 222 autotest_commits += '%s\n' % cl 223 224 pushed_commits = autotest_commits 225 226 print 'Successfully got pushed CLs for %s repo!\n' % repo 227 displayed_cmd = get_commits_cmd 228 if repo == 'autotest': 229 displayed_cmd += ' | grep autotest' 230 return '\n%s:\n%s\n%s\n' % (repo, displayed_cmd, pushed_commits) 231 232 233def kick_off_deploy(): 234 """Method to kick off deploy script to deploy changes to lab servers. 235 236 @raises subprocess.CalledProcessError on a repo command failure. 237 """ 238 print 'Start deploying changes to all lab servers...' 239 with infra.chdir(AUTOTEST_DIR): 240 # Then kick off the deploy script. 241 deploy_cmd = ('runlocalssh ./site_utils/deploy_server.py -x --afe=%s' % 242 MASTER_AFE) 243 infra.local_runner(deploy_cmd, stream_output=True) 244 print 'Successfully deployed changes to all lab servers.' 245 246 247def main(args): 248 """Main entry""" 249 options = parse_arguments() 250 repos = dict() 251 if not options.skip_autotest: 252 repos.update({'autotest': options.autotest_hash}) 253 if not options.skip_chromite: 254 repos.update({'chromite': options.chromite_hash}) 255 256 if not options.skip_prod_config_check: 257 verify_dev_infradata_config_matches_prod() 258 259 print 'Moving CIPD prod refs to prod-next' 260 for pkg in _CIPD_PACKAGES: 261 subprocess.check_call(['cipd', 'set-ref', pkg, '-version', 'prod-next', 262 '-ref', 'prod']) 263 try: 264 # update_log saves the git log of the updated repo. 265 update_log = '' 266 for repo, hash_to_rebase in repos.iteritems(): 267 repo_dir = clone_prod_branch(repo) 268 push_commits_range = update_prod_branch( 269 repo, repo_dir, hash_to_rebase) 270 update_log += get_pushed_commits(repo, repo_dir, push_commits_range) 271 272 kick_off_deploy() 273 except revision_control.GitCloneError as e: 274 print 'Fail to clone prod branch. Error:\n%s\n' % e 275 raise 276 except subprocess.CalledProcessError as e: 277 print ('Deploy fails when running a subprocess cmd :\n%s\n' 278 'Below is the push log:\n%s\n' % (e.output, update_log)) 279 raise 280 except Exception as e: 281 print 'Deploy fails with error:\n%s\nPush log:\n%s\n' % (e, update_log) 282 raise 283 284 # When deploy succeeds, print the update_log. 285 print ('Deploy succeeds!!! Below is the push log of the updated repo:\n%s' 286 'Please email this to %s.'% (update_log, NOTIFY_GROUP)) 287 288 289if __name__ == '__main__': 290 sys.exit(main(sys.argv)) 291