Home | History | Annotate | Download | only in site_utils
      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 
      8 This script is designed to run on all autotest servers to allow them to
      9 automatically self-update based on the manifests used to create their (existing)
     10 repos.
     11 """
     12 
     13 from __future__ import print_function
     14 
     15 import ConfigParser
     16 import argparse
     17 import os
     18 import re
     19 import subprocess
     20 import sys
     21 import time
     22 
     23 import common
     24 
     25 from autotest_lib.client.common_lib import global_config
     26 
     27 # How long after restarting a service do we watch it to see if it's stable.
     28 SERVICE_STABILITY_TIMER = 120
     29 
     30 
     31 class DirtyTreeException(Exception):
     32     """Raised when the tree has been modified in an unexpected way."""
     33 
     34 
     35 class UnknownCommandException(Exception):
     36     """Raised when we try to run a command name with no associated command."""
     37 
     38 
     39 class UnstableServices(Exception):
     40     """Raised if a service appears unstable after restart."""
     41 
     42 
     43 def strip_terminal_codes(text):
     44     """This function removes all terminal formatting codes from a string.
     45 
     46     @param text: String of text to cleanup.
     47     @returns String with format codes removed.
     48     """
     49     ESC = '\x1b'
     50     return re.sub(ESC+r'\[[^m]*m', '', text)
     51 
     52 
     53 def verify_repo_clean():
     54     """This function verifies that the current repo is valid, and clean.
     55 
     56     @raises DirtyTreeException if the repo is not clean.
     57     @raises subprocess.CalledProcessError on a repo command failure.
     58     """
     59     out = subprocess.check_output(['repo', 'status'], stderr=subprocess.STDOUT)
     60     out = strip_terminal_codes(out).strip()
     61 
     62     CLEAN_STATUS_OUTPUT = 'nothing to commit (working directory clean)'
     63     if out != CLEAN_STATUS_OUTPUT:
     64       raise DirtyTreeException(out)
     65 
     66 
     67 def repo_versions():
     68     """This function collects the versions of all git repos in the general repo.
     69 
     70     @returns A dictionary mapping project names to git hashes for HEAD.
     71     @raises subprocess.CalledProcessError on a repo command failure.
     72     """
     73     cmd = ['repo', 'forall', '-p', '-c', 'pwd && git log -1 --format=%h']
     74     output = strip_terminal_codes(subprocess.check_output(cmd))
     75 
     76     # The expected output format is:
     77 
     78     # project chrome_build/
     79     # /dir/holding/chrome_build
     80     # 73dee9d
     81     #
     82     # project chrome_release/
     83     # /dir/holding/chrome_release
     84     # 9f3a5d8
     85 
     86     lines = output.splitlines()
     87 
     88     PROJECT_PREFIX = 'project '
     89 
     90     project_heads = {}
     91     for n in range(0, len(lines), 4):
     92         project_line = lines[n]
     93         project_dir = lines[n+1]
     94         project_hash = lines[n+2]
     95         # lines[n+3] is a blank line, but doesn't exist for the final block.
     96 
     97         # Convert 'project chrome_build/' -> 'chrome_build'
     98         assert project_line.startswith(PROJECT_PREFIX)
     99         name = project_line[len(PROJECT_PREFIX):].rstrip('/')
    100 
    101         project_heads[name] = (project_dir, project_hash)
    102 
    103     return project_heads
    104 
    105 
    106 def repo_sync():
    107     """Perform a repo sync.
    108 
    109     @raises subprocess.CalledProcessError on a repo command failure.
    110     """
    111     subprocess.check_output(['repo', 'sync'])
    112 
    113 
    114 def discover_update_commands():
    115     """Lookup the commands to run on this server.
    116 
    117     These commonly come from shadow_config.ini, since they vary by server type.
    118 
    119     @returns List of command names in string format.
    120     """
    121     try:
    122         return global_config.global_config.get_config_value(
    123                 'UPDATE', 'commands', type=list)
    124 
    125     except (ConfigParser.NoSectionError, global_config.ConfigError):
    126         return []
    127 
    128 
    129 def discover_restart_services():
    130     """Find the services that need restarting on the current server.
    131 
    132     These commonly come from shadow_config.ini, since they vary by server type.
    133 
    134     @returns List of service names in string format.
    135     """
    136     try:
    137         # From shadow_config.ini, lookup which services to restart.
    138         return global_config.global_config.get_config_value(
    139                 'UPDATE', 'services', type=list)
    140 
    141     except (ConfigParser.NoSectionError, global_config.ConfigError):
    142         return []
    143 
    144 
    145 def update_command(cmd_tag, dryrun=False):
    146     """Restart a command.
    147 
    148     The command name is looked up in global_config.ini to find the full command
    149     to run, then it's executed.
    150 
    151     @param cmd_tag: Which command to restart.
    152     @param dryrun: If true print the command that would have been run.
    153 
    154     @raises UnknownCommandException If cmd_tag can't be looked up.
    155     @raises subprocess.CalledProcessError on a command failure.
    156     """
    157     # Lookup the list of commands to consider. They are intended to be
    158     # in global_config.ini so that they can be shared everywhere.
    159     cmds = dict(global_config.global_config.config.items(
    160         'UPDATE_COMMANDS'))
    161 
    162     if cmd_tag not in cmds:
    163         raise UnknownCommandException(cmd_tag, cmds)
    164 
    165     expanded_command = cmds[cmd_tag].replace('AUTOTEST_REPO',
    166                                               common.autotest_dir)
    167 
    168     print('Running: %s: %s' % (cmd_tag, expanded_command))
    169     if dryrun:
    170         print('Skip: %s' % expanded_command)
    171     else:
    172         try:
    173             subprocess.check_output(expanded_command, shell=True,
    174                                     stderr=subprocess.STDOUT)
    175         except subprocess.CalledProcessError as e:
    176             print('FAILED:')
    177             print(e.output)
    178             raise
    179 
    180 
    181 def restart_service(service_name, dryrun=False):
    182     """Restart a service.
    183 
    184     Restarts the standard service with "service <name> restart".
    185 
    186     @param service_name: The name of the service to restart.
    187     @param dryrun: Don't really run anything, just print out the command.
    188 
    189     @raises subprocess.CalledProcessError on a command failure.
    190     """
    191     cmd = ['sudo', 'service', service_name, 'restart']
    192     print('Restarting: %s' % service_name)
    193     if dryrun:
    194         print('Skip: %s' % ' '.join(cmd))
    195     else:
    196         subprocess.check_call(cmd)
    197 
    198 
    199 def service_status(service_name):
    200     """Return the results "status <name>" for a given service.
    201 
    202     This string is expected to contain the pid, and so to change is the service
    203     is shutdown or restarted for any reason.
    204 
    205     @param service_name: The name of the service to check on.
    206 
    207     @returns The output of the external command.
    208              Ex: autofs start/running, process 1931
    209 
    210     @raises subprocess.CalledProcessError on a command failure.
    211     """
    212     return subprocess.check_output(['sudo', 'status', service_name])
    213 
    214 
    215 def restart_services(service_names, dryrun=False, skip_service_status=False):
    216     """Restart services as needed for the current server type.
    217 
    218     Restart the listed set of services, and watch to see if they are stable for
    219     at least SERVICE_STABILITY_TIMER. It restarts all services quickly,
    220     waits for that delay, then verifies the status of all of them.
    221 
    222     @param service_names: The list of service to restart and monitor.
    223     @param dryrun: Don't really restart the service, just print out the command.
    224     @param skip_service_status: Set to True to skip service status check.
    225                                 Default is False.
    226 
    227     @raises subprocess.CalledProcessError on a command failure.
    228     @raises UnstableServices if any services are unstable after restart.
    229     """
    230     service_statuses = {}
    231 
    232     if dryrun:
    233         for name in service_names:
    234             restart_service(name, dryrun=True)
    235         return
    236 
    237     # Restart each, and record the status (including pid).
    238     for name in service_names:
    239         restart_service(name)
    240         service_statuses[name] = service_status(name)
    241 
    242     # Skip service status check if --skip-service-status is specified. Used for
    243     # servers in backup status.
    244     if skip_service_status:
    245         print('--skip-service-status is specified, skip checking services.')
    246         return
    247 
    248     # Wait for a while to let the services settle.
    249     time.sleep(SERVICE_STABILITY_TIMER)
    250 
    251     # Look for any services that changed status.
    252     unstable_services = [n for n in service_names
    253                          if service_status(n) != service_statuses[n]]
    254 
    255     # Report any services having issues.
    256     if unstable_services:
    257         raise UnstableServices(unstable_services)
    258 
    259 
    260 def run_deploy_actions(dryrun=False, skip_service_status=False):
    261     """Run arbitrary update commands specified in global.ini.
    262 
    263     @param dryrun: Don't really restart the service, just print out the command.
    264     @param skip_service_status: Set to True to skip service status check.
    265                                 Default is False.
    266 
    267     @raises subprocess.CalledProcessError on a command failure.
    268     @raises UnstableServices if any services are unstable after restart.
    269     """
    270     cmds = discover_update_commands()
    271     if cmds:
    272         print('Running update commands:', ', '.join(cmds))
    273         for cmd in cmds:
    274             update_command(cmd, dryrun=dryrun)
    275 
    276     services = discover_restart_services()
    277     if services:
    278         print('Restarting Services:', ', '.join(services))
    279         restart_services(services, dryrun=dryrun,
    280                          skip_service_status=skip_service_status)
    281 
    282 
    283 def report_changes(versions_before, versions_after):
    284     """Produce a report describing what changed in all repos.
    285 
    286     @param versions_before: Results of repo_versions() from before the update.
    287     @param versions_after: Results of repo_versions() from after the update.
    288 
    289     @returns string containing a human friendly changes report.
    290     """
    291     result = []
    292 
    293     if versions_after:
    294         for project in sorted(set(versions_before.keys() + versions_after.keys())):
    295             result.append('%s:' % project)
    296 
    297             _, before_hash = versions_before.get(project, (None, None))
    298             after_dir, after_hash = versions_after.get(project, (None, None))
    299 
    300             if project not in versions_before:
    301                 result.append('Added.')
    302 
    303             elif project not in versions_after:
    304                 result.append('Removed.')
    305 
    306             elif before_hash == after_hash:
    307                 result.append('No Change.')
    308 
    309             else:
    310                 hashes = '%s..%s' % (before_hash, after_hash)
    311                 cmd = ['git', 'log', hashes, '--oneline']
    312                 out = subprocess.check_output(cmd, cwd=after_dir,
    313                                               stderr=subprocess.STDOUT)
    314                 result.append(out.strip())
    315 
    316             result.append('')
    317     else:
    318         for project in sorted(versions_before.keys()):
    319             _, before_hash = versions_before[project]
    320             result.append('%s: %s' % (project, before_hash))
    321         result.append('')
    322 
    323     return '\n'.join(result)
    324 
    325 
    326 def parse_arguments(args):
    327     """Parse command line arguments.
    328 
    329     @param args: The command line arguments to parse. (ususally sys.argsv[1:])
    330 
    331     @returns An argparse.Namespace populated with argument values.
    332     """
    333     parser = argparse.ArgumentParser(
    334             description='Command to update an autotest server.')
    335     parser.add_argument('--skip-verify', action='store_false',
    336                         dest='verify', default=True,
    337                         help='Disable verification of a clean repository.')
    338     parser.add_argument('--skip-update', action='store_false',
    339                         dest='update', default=True,
    340                         help='Skip the repository source code update.')
    341     parser.add_argument('--skip-actions', action='store_false',
    342                         dest='actions', default=True,
    343                         help='Skip the post update actions.')
    344     parser.add_argument('--skip-report', action='store_false',
    345                         dest='report', default=True,
    346                         help='Skip the git version report.')
    347     parser.add_argument('--actions-only', action='store_true',
    348                         help='Run the post update actions (restart services).')
    349     parser.add_argument('--dryrun', action='store_true',
    350                         help='Don\'t actually run any commands, just log.')
    351     parser.add_argument('--skip-service-status', action='store_true',
    352                         help='Skip checking the service status.')
    353 
    354     results = parser.parse_args(args)
    355 
    356     if results.actions_only:
    357         results.verify = False
    358         results.update = False
    359         results.report = False
    360 
    361     # TODO(dgarrett): Make these behaviors support dryrun.
    362     if results.dryrun:
    363         results.verify = False
    364         results.update = False
    365 
    366     return results
    367 
    368 
    369 def main(args):
    370     """Main method."""
    371     os.chdir(common.autotest_dir)
    372     global_config.global_config.parse_config_file()
    373 
    374     behaviors = parse_arguments(args)
    375 
    376     if behaviors.verify:
    377         try:
    378             print('Checking tree status:')
    379             verify_repo_clean()
    380             print('Clean.')
    381         except DirtyTreeException as e:
    382             print('Local tree is dirty, can\'t perform update safely.')
    383             print()
    384             print('repo status:')
    385             print(e.args[0])
    386             return 1
    387 
    388     versions_before = repo_versions()
    389     versions_after = {}
    390 
    391     if behaviors.update:
    392         print('Updating Repo.')
    393         repo_sync()
    394         versions_after = repo_versions()
    395 
    396     if behaviors.actions:
    397         try:
    398             run_deploy_actions(
    399                     dryrun=behaviors.dryrun,
    400                     skip_service_status=behaviors.skip_service_status)
    401         except UnstableServices as e:
    402             print('The following services were not stable after '
    403                   'the update:')
    404             print(e.args[0])
    405             return 1
    406 
    407     if behaviors.report:
    408         print('Changes:')
    409         print(report_changes(versions_before, versions_after))
    410 
    411 
    412 if __name__ == '__main__':
    413     sys.exit(main(sys.argv[1:]))
    414