1#!/usr/bin/python 2# Copyright (c) 2014 The Chromium OS Authors. All rights reserved. 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5 6"""Runs on autotest servers from a cron job to self update them. 7 8This script is designed to run on all autotest servers to allow them to 9automatically self-update based on the manifests used to create their (existing) 10repos. 11""" 12 13from __future__ import print_function 14 15import ConfigParser 16import argparse 17import os 18import re 19import subprocess 20import sys 21import time 22 23import common 24 25from autotest_lib.client.common_lib import global_config 26from autotest_lib.server import utils as server_utils 27from autotest_lib.server.cros.dynamic_suite import frontend_wrappers 28 29 30# How long after restarting a service do we watch it to see if it's stable. 31SERVICE_STABILITY_TIMER = 60 32 33# A dict to map update_commands defined in config file to repos or files that 34# decide whether need to update these commands. E.g. if no changes under 35# frontend repo, no need to update afe. 36COMMANDS_TO_REPOS_DICT = {'afe': 'frontend/', 37 'tko': 'tko/'} 38BUILD_EXTERNALS_COMMAND = 'build_externals' 39# Services present on all hosts. 40UNIVERSAL_SERVICES = ['sysmon'] 41 42AFE = frontend_wrappers.RetryingAFE( 43 server=server_utils.get_global_afe_hostname(), timeout_min=5, 44 delay_sec=10) 45 46class DirtyTreeException(Exception): 47 """Raised when the tree has been modified in an unexpected way.""" 48 49 50class UnknownCommandException(Exception): 51 """Raised when we try to run a command name with no associated command.""" 52 53 54class UnstableServices(Exception): 55 """Raised if a service appears unstable after restart.""" 56 57 58def strip_terminal_codes(text): 59 """This function removes all terminal formatting codes from a string. 60 61 @param text: String of text to cleanup. 62 @returns String with format codes removed. 63 """ 64 ESC = '\x1b' 65 return re.sub(ESC+r'\[[^m]*m', '', text) 66 67 68def verify_repo_clean(): 69 """This function cleans the current repo then verifies that it is valid. 70 71 @raises DirtyTreeException if the repo is still not clean. 72 @raises subprocess.CalledProcessError on a repo command failure. 73 """ 74 subprocess.check_output(['git', 'reset', '--hard']) 75 # Forcefully blow away any non-gitignored files in the tree. 76 subprocess.check_output(['git', 'clean', '-fd']) 77 out = subprocess.check_output(['repo', 'status'], stderr=subprocess.STDOUT) 78 out = strip_terminal_codes(out).strip() 79 80 if not 'working directory clean' in out: 81 raise DirtyTreeException(out) 82 83 84def repo_versions(): 85 """This function collects the versions of all git repos in the general repo. 86 87 @returns A dictionary mapping project names to git hashes for HEAD. 88 @raises subprocess.CalledProcessError on a repo command failure. 89 """ 90 cmd = ['repo', 'forall', '-p', '-c', 'pwd && git log -1 --format=%h'] 91 output = strip_terminal_codes(subprocess.check_output(cmd)) 92 93 # The expected output format is: 94 95 # project chrome_build/ 96 # /dir/holding/chrome_build 97 # 73dee9d 98 # 99 # project chrome_release/ 100 # /dir/holding/chrome_release 101 # 9f3a5d8 102 103 lines = output.splitlines() 104 105 PROJECT_PREFIX = 'project ' 106 107 project_heads = {} 108 for n in range(0, len(lines), 4): 109 project_line = lines[n] 110 project_dir = lines[n+1] 111 project_hash = lines[n+2] 112 # lines[n+3] is a blank line, but doesn't exist for the final block. 113 114 # Convert 'project chrome_build/' -> 'chrome_build' 115 assert project_line.startswith(PROJECT_PREFIX) 116 name = project_line[len(PROJECT_PREFIX):].rstrip('/') 117 118 project_heads[name] = (project_dir, project_hash) 119 120 return project_heads 121 122 123def repo_versions_to_decide_whether_run_cmd_update(): 124 """Collect versions of repos/files defined in COMMANDS_TO_REPOS_DICT. 125 126 For the update_commands defined in config files, no need to run the command 127 every time. Only run it when the repos/files related to the commands have 128 been changed. 129 130 @returns A set of tuples: {(cmd, repo_version), ()...} 131 """ 132 results = set() 133 for cmd, repo in COMMANDS_TO_REPOS_DICT.iteritems(): 134 version = subprocess.check_output( 135 ['git', 'log', '-1', '--pretty=tformat:%h', 136 '%s/%s' % (common.autotest_dir, repo)]) 137 results.add((cmd, version.strip())) 138 return results 139 140 141def repo_sync(update_push_servers=False): 142 """Perform a repo sync. 143 144 @param update_push_servers: If True, then update test_push servers to ToT. 145 Otherwise, update server to prod branch. 146 @raises subprocess.CalledProcessError on a repo command failure. 147 """ 148 subprocess.check_output(['repo', 'sync']) 149 if update_push_servers: 150 print('Updating push servers, checkout cros/master') 151 subprocess.check_output(['git', 'checkout', 'cros/master'], 152 stderr=subprocess.STDOUT) 153 else: 154 print('Updating server to prod branch') 155 subprocess.check_output(['git', 'checkout', 'cros/prod'], 156 stderr=subprocess.STDOUT) 157 # Remove .pyc files via pyclean, which is a package on all ubuntu server. 158 print('Removing .pyc files') 159 try: 160 subprocess.check_output(['pyclean', '.', '-q']) 161 except Exception as e: 162 print('Warning: fail to remove .pyc! %s' % e) 163 164def discover_update_commands(): 165 """Lookup the commands to run on this server. 166 167 These commonly come from shadow_config.ini, since they vary by server type. 168 169 @returns List of command names in string format. 170 """ 171 try: 172 return global_config.global_config.get_config_value( 173 'UPDATE', 'commands', type=list) 174 175 except (ConfigParser.NoSectionError, global_config.ConfigError): 176 return [] 177 178 179def discover_restart_services(): 180 """Find the services that need restarting on the current server. 181 182 These commonly come from shadow_config.ini, since they vary by server type. 183 184 @returns List of service names in string format. 185 """ 186 services = list(UNIVERSAL_SERVICES) 187 try: 188 # Look up services from shadow_config.ini. 189 extra_services = global_config.global_config.get_config_value( 190 'UPDATE', 'services', type=list) 191 services.extend(extra_services) 192 except (ConfigParser.NoSectionError, global_config.ConfigError): 193 pass 194 return services 195 196 197def update_command(cmd_tag, dryrun=False, use_chromite_master=False): 198 """Restart a command. 199 200 The command name is looked up in global_config.ini to find the full command 201 to run, then it's executed. 202 203 @param cmd_tag: Which command to restart. 204 @param dryrun: If true print the command that would have been run. 205 @param use_chromite_master: True if updating chromite to master, rather 206 than prod. 207 208 @raises UnknownCommandException If cmd_tag can't be looked up. 209 @raises subprocess.CalledProcessError on a command failure. 210 """ 211 # Lookup the list of commands to consider. They are intended to be 212 # in global_config.ini so that they can be shared everywhere. 213 cmds = dict(global_config.global_config.config.items( 214 'UPDATE_COMMANDS')) 215 216 if cmd_tag not in cmds: 217 raise UnknownCommandException(cmd_tag, cmds) 218 219 expanded_command = cmds[cmd_tag].replace('AUTOTEST_REPO', 220 common.autotest_dir) 221 # When updating push servers, pass an arg to build_externals to update 222 # chromite to master branch for testing 223 if use_chromite_master and cmd_tag == BUILD_EXTERNALS_COMMAND: 224 expanded_command += ' --use_chromite_master' 225 226 print('Running: %s: %s' % (cmd_tag, expanded_command)) 227 if dryrun: 228 print('Skip: %s' % expanded_command) 229 else: 230 try: 231 subprocess.check_output(expanded_command, shell=True, 232 stderr=subprocess.STDOUT) 233 except subprocess.CalledProcessError as e: 234 print('FAILED:') 235 print(e.output) 236 raise 237 238 239def restart_service(service_name, dryrun=False): 240 """Restart a service. 241 242 Restarts the standard service with "service <name> restart". 243 244 @param service_name: The name of the service to restart. 245 @param dryrun: Don't really run anything, just print out the command. 246 247 @raises subprocess.CalledProcessError on a command failure. 248 """ 249 cmd = ['sudo', 'service', service_name, 'restart'] 250 print('Restarting: %s' % service_name) 251 if dryrun: 252 print('Skip: %s' % ' '.join(cmd)) 253 else: 254 subprocess.check_call(cmd, stderr=subprocess.STDOUT) 255 256 257def service_status(service_name): 258 """Return the results "status <name>" for a given service. 259 260 This string is expected to contain the pid, and so to change is the service 261 is shutdown or restarted for any reason. 262 263 @param service_name: The name of the service to check on. 264 265 @returns The output of the external command. 266 Ex: autofs start/running, process 1931 267 268 @raises subprocess.CalledProcessError on a command failure. 269 """ 270 return subprocess.check_output(['sudo', 'status', service_name]) 271 272 273def restart_services(service_names, dryrun=False, skip_service_status=False): 274 """Restart services as needed for the current server type. 275 276 Restart the listed set of services, and watch to see if they are stable for 277 at least SERVICE_STABILITY_TIMER. It restarts all services quickly, 278 waits for that delay, then verifies the status of all of them. 279 280 @param service_names: The list of service to restart and monitor. 281 @param dryrun: Don't really restart the service, just print out the command. 282 @param skip_service_status: Set to True to skip service status check. 283 Default is False. 284 285 @raises subprocess.CalledProcessError on a command failure. 286 @raises UnstableServices if any services are unstable after restart. 287 """ 288 service_statuses = {} 289 290 if dryrun: 291 for name in service_names: 292 restart_service(name, dryrun=True) 293 return 294 295 # Restart each, and record the status (including pid). 296 for name in service_names: 297 restart_service(name) 298 299 # Skip service status check if --skip-service-status is specified. Used for 300 # servers in backup status. 301 if skip_service_status: 302 print('--skip-service-status is specified, skip checking services.') 303 return 304 305 # Wait for a while to let the services settle. 306 time.sleep(SERVICE_STABILITY_TIMER) 307 service_statuses = {name: service_status(name) for name in service_names} 308 time.sleep(SERVICE_STABILITY_TIMER) 309 # Look for any services that changed status. 310 unstable_services = [n for n in service_names 311 if service_status(n) != service_statuses[n]] 312 313 # Report any services having issues. 314 if unstable_services: 315 raise UnstableServices(unstable_services) 316 317 318def run_deploy_actions(cmds_skip=set(), dryrun=False, 319 skip_service_status=False, use_chromite_master=False): 320 """Run arbitrary update commands specified in global.ini. 321 322 @param cmds_skip: cmds no need to run since the corresponding repo/file 323 does not change. 324 @param dryrun: Don't really restart the service, just print out the command. 325 @param skip_service_status: Set to True to skip service status check. 326 Default is False. 327 @param use_chromite_master: True if updating chromite to master, rather 328 than prod. 329 330 @raises subprocess.CalledProcessError on a command failure. 331 @raises UnstableServices if any services are unstable after restart. 332 """ 333 defined_cmds = set(discover_update_commands()) 334 cmds = defined_cmds - cmds_skip 335 if cmds: 336 print('Running update commands:', ', '.join(cmds)) 337 for cmd in cmds: 338 update_command(cmd, dryrun=dryrun, 339 use_chromite_master=use_chromite_master) 340 341 services = discover_restart_services() 342 if services: 343 print('Restarting Services:', ', '.join(services)) 344 restart_services(services, dryrun=dryrun, 345 skip_service_status=skip_service_status) 346 347 348def report_changes(versions_before, versions_after): 349 """Produce a report describing what changed in all repos. 350 351 @param versions_before: Results of repo_versions() from before the update. 352 @param versions_after: Results of repo_versions() from after the update. 353 354 @returns string containing a human friendly changes report. 355 """ 356 result = [] 357 358 if versions_after: 359 for project in sorted(set(versions_before.keys() + versions_after.keys())): 360 result.append('%s:' % project) 361 362 _, before_hash = versions_before.get(project, (None, None)) 363 after_dir, after_hash = versions_after.get(project, (None, None)) 364 365 if project not in versions_before: 366 result.append('Added.') 367 368 elif project not in versions_after: 369 result.append('Removed.') 370 371 elif before_hash == after_hash: 372 result.append('No Change.') 373 374 else: 375 hashes = '%s..%s' % (before_hash, after_hash) 376 cmd = ['git', 'log', hashes, '--oneline'] 377 out = subprocess.check_output(cmd, cwd=after_dir, 378 stderr=subprocess.STDOUT) 379 result.append(out.strip()) 380 381 result.append('') 382 else: 383 for project in sorted(versions_before.keys()): 384 _, before_hash = versions_before[project] 385 result.append('%s: %s' % (project, before_hash)) 386 result.append('') 387 388 return '\n'.join(result) 389 390 391def parse_arguments(args): 392 """Parse command line arguments. 393 394 @param args: The command line arguments to parse. (ususally sys.argsv[1:]) 395 396 @returns An argparse.Namespace populated with argument values. 397 """ 398 parser = argparse.ArgumentParser( 399 description='Command to update an autotest server.') 400 parser.add_argument('--skip-verify', action='store_false', 401 dest='verify', default=True, 402 help='Disable verification of a clean repository.') 403 parser.add_argument('--skip-update', action='store_false', 404 dest='update', default=True, 405 help='Skip the repository source code update.') 406 parser.add_argument('--skip-actions', action='store_false', 407 dest='actions', default=True, 408 help='Skip the post update actions.') 409 parser.add_argument('--skip-report', action='store_false', 410 dest='report', default=True, 411 help='Skip the git version report.') 412 parser.add_argument('--actions-only', action='store_true', 413 help='Run the post update actions (restart services).') 414 parser.add_argument('--dryrun', action='store_true', 415 help='Don\'t actually run any commands, just log.') 416 parser.add_argument('--skip-service-status', action='store_true', 417 help='Skip checking the service status.') 418 parser.add_argument('--update_push_servers', action='store_true', 419 help='Indicate to update test_push server. If not ' 420 'specify, then update server to production.') 421 parser.add_argument('--force_update', action='store_true', 422 help='Force to run the update commands for afe, tko ' 423 'and build_externals') 424 425 results = parser.parse_args(args) 426 427 if results.actions_only: 428 results.verify = False 429 results.update = False 430 results.report = False 431 432 # TODO(dgarrett): Make these behaviors support dryrun. 433 if results.dryrun: 434 results.verify = False 435 results.update = False 436 437 return results 438 439 440class ChangeDir(object): 441 442 """Context manager for changing to a directory temporarily.""" 443 444 def __init__(self, dir): 445 self.new_dir = dir 446 self.old_dir = None 447 448 def __enter__(self): 449 self.old_dir = os.getcwd() 450 os.chdir(self.new_dir) 451 452 def __exit__(self, exc_type, exc_val, exc_tb): 453 os.chdir(self.old_dir) 454 455 456def _sync_chromiumos_repo(): 457 """Update ~chromeos-test/chromiumos repo.""" 458 print('Updating ~chromeos-test/chromiumos') 459 with ChangeDir(os.path.expanduser('~chromeos-test/chromiumos')): 460 ret = subprocess.call(['repo', 'sync'], stderr=subprocess.STDOUT) 461 # Remove .pyc files via pyclean, which is a package on all ubuntu server 462 print('Removing .pyc files') 463 try: 464 subprocess.check_output(['pyclean', '.', '-q']) 465 except Exception as e: 466 print('Warning: fail to remove .pyc! %s' % e) 467 if ret != 0: 468 print('Update failed, exited with status: %d' % ret) 469 470 471def main(args): 472 """Main method.""" 473 os.chdir(common.autotest_dir) 474 global_config.global_config.parse_config_file() 475 476 behaviors = parse_arguments(args) 477 478 if behaviors.verify: 479 print('Checking tree status:') 480 verify_repo_clean() 481 print('Tree status: clean') 482 483 versions_before = repo_versions() 484 versions_after = set() 485 cmd_versions_before = repo_versions_to_decide_whether_run_cmd_update() 486 cmd_versions_after = set() 487 488 if behaviors.update: 489 print('Updating Repo.') 490 repo_sync(behaviors.update_push_servers) 491 versions_after = repo_versions() 492 cmd_versions_after = repo_versions_to_decide_whether_run_cmd_update() 493 494 _sync_chromiumos_repo() 495 496 if behaviors.actions: 497 # If the corresponding repo/file not change, no need to run the cmd. 498 cmds_skip = (set() if behaviors.force_update else 499 {t[0] for t in cmd_versions_before & cmd_versions_after}) 500 run_deploy_actions( 501 cmds_skip, behaviors.dryrun, behaviors.skip_service_status, 502 use_chromite_master=behaviors.update_push_servers) 503 504 if behaviors.report: 505 print('Changes:') 506 print(report_changes(versions_before, versions_after)) 507 508 509if __name__ == '__main__': 510 sys.exit(main(sys.argv[1:])) 511