• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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