| # |
| # Copyright 2008 Google Inc. All Rights Reserved. |
| # |
| """ |
| This module contains the generic CLI object |
| |
| High Level Design: |
| |
| The atest class contains attributes & method generic to all the CLI |
| operations. |
| |
| The class inheritance is shown here using the command |
| 'atest host create ...' as an example: |
| |
| atest <-- host <-- host_create <-- site_host_create |
| |
| Note: The site_<topic>.py and its classes are only needed if you need |
| to override the common <topic>.py methods with your site specific ones. |
| |
| |
| High Level Algorithm: |
| |
| 1. atest figures out the topic and action from the 2 first arguments |
| on the command line and imports the <topic> (or site_<topic>) |
| module. |
| |
| 1. Init |
| The main atest module creates a <topic>_<action> object. The |
| __init__() function is used to setup the parser options, if this |
| <action> has some specific options to add to its <topic>. |
| |
| If it exists, the child __init__() method must call its parent |
| class __init__() before adding its own parser arguments. |
| |
| 2. Parsing |
| If the child wants to validate the parsing (e.g. make sure that |
| there are hosts in the arguments), or if it wants to check the |
| options it added in its __init__(), it should implement a parse() |
| method. |
| |
| The child parser must call its parent parser and gets back the |
| options dictionary and the rest of the command line arguments |
| (leftover). Each level gets to see all the options, but the |
| leftovers can be deleted as they can be consumed by only one |
| object. |
| |
| 3. Execution |
| This execute() method is specific to the child and should use the |
| self.execute_rpc() to send commands to the Autotest Front-End. It |
| should return results. |
| |
| 4. Output |
| The child output() method is called with the execute() resutls as a |
| parameter. This is child-specific, but should leverage the |
| atest.print_*() methods. |
| """ |
| |
| import optparse |
| import os |
| import re |
| import sys |
| import textwrap |
| import traceback |
| import urllib2 |
| |
| from autotest_lib.cli import rpc |
| from autotest_lib.client.common_lib.test_utils import mock |
| |
| |
| # Maps the AFE keys to printable names. |
| KEYS_TO_NAMES_EN = {'hostname': 'Host', |
| 'platform': 'Platform', |
| 'status': 'Status', |
| 'locked': 'Locked', |
| 'locked_by': 'Locked by', |
| 'lock_time': 'Locked time', |
| 'labels': 'Labels', |
| 'description': 'Description', |
| 'hosts': 'Hosts', |
| 'users': 'Users', |
| 'id': 'Id', |
| 'name': 'Name', |
| 'invalid': 'Valid', |
| 'login': 'Login', |
| 'access_level': 'Access Level', |
| 'job_id': 'Job Id', |
| 'job_owner': 'Job Owner', |
| 'job_name': 'Job Name', |
| 'test_type': 'Test Type', |
| 'test_class': 'Test Class', |
| 'path': 'Path', |
| 'owner': 'Owner', |
| 'status_counts': 'Status Counts', |
| 'hosts_status': 'Host Status', |
| 'hosts_selected_status': 'Hosts filtered by Status', |
| 'priority': 'Priority', |
| 'control_type': 'Control Type', |
| 'created_on': 'Created On', |
| 'synch_type': 'Synch Type', |
| 'control_file': 'Control File', |
| 'only_if_needed': 'Use only if needed', |
| 'protection': 'Protection', |
| 'run_verify': 'Run verify', |
| 'reboot_before': 'Pre-job reboot', |
| 'reboot_after': 'Post-job reboot', |
| 'experimental': 'Experimental', |
| 'synch_count': 'Sync Count', |
| 'max_number_of_machines': 'Max. hosts to use', |
| 'parse_failed_repair': 'Include failed repair results', |
| 'atomic_group.name': 'Atomic Group Name', |
| } |
| |
| # In the failure, tag that will replace the item. |
| FAIL_TAG = '<XYZ>' |
| |
| # Global socket timeout: uploading kernels can take much, |
| # much longer than the default |
| UPLOAD_SOCKET_TIMEOUT = 60*30 |
| |
| |
| # Convertion functions to be called for printing, |
| # e.g. to print True/False for booleans. |
| def __convert_platform(field): |
| if field is None: |
| return "" |
| elif isinstance(field, int): |
| # Can be 0/1 for False/True |
| return str(bool(field)) |
| else: |
| # Can be a platform name |
| return field |
| |
| |
| def _int_2_bool_string(value): |
| return str(bool(value)) |
| |
| KEYS_CONVERT = {'locked': _int_2_bool_string, |
| 'invalid': lambda flag: str(bool(not flag)), |
| 'only_if_needed': _int_2_bool_string, |
| 'platform': __convert_platform, |
| 'labels': lambda labels: ', '.join(labels)} |
| |
| |
| def _get_item_key(item, key): |
| """Allow for lookups in nested dictionaries using '.'s within a key.""" |
| if key in item: |
| return item[key] |
| nested_item = item |
| for subkey in key.split('.'): |
| if not subkey: |
| raise ValueError('empty subkey in %r' % key) |
| try: |
| nested_item = nested_item[subkey] |
| except KeyError, e: |
| raise KeyError('%r - looking up key %r in %r' % |
| (e, key, nested_item)) |
| else: |
| return nested_item |
| |
| |
| class CliError(Exception): |
| """Error raised by cli calls. |
| """ |
| pass |
| |
| |
| class item_parse_info(object): |
| """Object keeping track of the parsing options. |
| """ |
| |
| def __init__(self, attribute_name, inline_option='', |
| filename_option='', use_leftover=False): |
| """Object keeping track of the parsing options that will |
| make up the content of the atest attribute: |
| attribute_name: the atest attribute name to populate (label) |
| inline_option: the option containing the items (--label) |
| filename_option: the option containing the filename (--blist) |
| use_leftover: whether to add the leftover arguments or not.""" |
| self.attribute_name = attribute_name |
| self.filename_option = filename_option |
| self.inline_option = inline_option |
| self.use_leftover = use_leftover |
| |
| |
| def get_values(self, options, leftover=[]): |
| """Returns the value for that attribute by accumualting all |
| the values found through the inline option, the parsing of the |
| file and the leftover""" |
| |
| def __get_items(input, split_spaces=True): |
| """Splits a string of comma separated items. Escaped commas will not |
| be split. I.e. Splitting 'a, b\,c, d' will yield ['a', 'b,c', 'd']. |
| If split_spaces is set to False spaces will not be split. I.e. |
| Splitting 'a b, c\,d, e' will yield ['a b', 'c,d', 'e']""" |
| |
| # Replace escaped slashes with null characters so we don't misparse |
| # proceeding commas. |
| input = input.replace(r'\\', '\0') |
| |
| # Split on commas which are not preceded by a slash. |
| if not split_spaces: |
| split = re.split(r'(?<!\\),', input) |
| else: |
| split = re.split(r'(?<!\\),|\s', input) |
| |
| # Convert null characters to single slashes and escaped commas to |
| # just plain commas. |
| return (item.strip().replace('\0', '\\').replace(r'\,', ',') for |
| item in split if item.strip()) |
| |
| if self.use_leftover: |
| add_on = leftover |
| leftover = [] |
| else: |
| add_on = [] |
| |
| # Start with the add_on |
| result = set() |
| for items in add_on: |
| # Don't split on space here because the add-on |
| # may have some spaces (like the job name) |
| result.update(__get_items(items, split_spaces=False)) |
| |
| # Process the inline_option, if any |
| try: |
| items = getattr(options, self.inline_option) |
| result.update(__get_items(items)) |
| except (AttributeError, TypeError): |
| pass |
| |
| # Process the file list, if any and not empty |
| # The file can contain space and/or comma separated items |
| try: |
| flist = getattr(options, self.filename_option) |
| file_content = [] |
| for line in open(flist).readlines(): |
| file_content += __get_items(line) |
| if len(file_content) == 0: |
| raise CliError("Empty file %s" % flist) |
| result.update(file_content) |
| except (AttributeError, TypeError): |
| pass |
| except IOError: |
| raise CliError("Could not open file %s" % flist) |
| |
| return list(result), leftover |
| |
| |
| class atest(object): |
| """Common class for generic processing |
| Should only be instantiated by itself for usage |
| references, otherwise, the <topic> objects should |
| be used.""" |
| msg_topic = "[acl|host|job|label|shard|atomicgroup|test|user|server]" |
| usage_action = "[action]" |
| msg_items = '' |
| |
| def invalid_arg(self, header, follow_up=''): |
| """Fail the command with error that command line has invalid argument. |
| |
| @param header: Header of the error message. |
| @param follow_up: Extra error message, default to empty string. |
| """ |
| twrap = textwrap.TextWrapper(initial_indent=' ', |
| subsequent_indent=' ') |
| rest = twrap.fill(follow_up) |
| |
| if self.kill_on_failure: |
| self.invalid_syntax(header + rest) |
| else: |
| print >> sys.stderr, header + rest |
| |
| |
| def invalid_syntax(self, msg): |
| """Fail the command with error that the command line syntax is wrong. |
| |
| @param msg: Error message. |
| """ |
| print |
| print >> sys.stderr, msg |
| print |
| print "usage:", |
| print self._get_usage() |
| print |
| sys.exit(1) |
| |
| |
| def generic_error(self, msg): |
| """Fail the command with a generic error. |
| |
| @param msg: Error message. |
| """ |
| if self.debug: |
| traceback.print_exc() |
| print >> sys.stderr, msg |
| sys.exit(1) |
| |
| |
| def parse_json_exception(self, full_error): |
| """Parses the JSON exception to extract the bad |
| items and returns them |
| This is very kludgy for the moment, but we would need |
| to refactor the exceptions sent from the front end |
| to make this better. |
| |
| @param full_error: The complete error message. |
| """ |
| errmsg = str(full_error).split('Traceback')[0].rstrip('\n') |
| parts = errmsg.split(':') |
| # Kludge: If there are 2 colons the last parts contains |
| # the items that failed. |
| if len(parts) != 3: |
| return [] |
| return [item.strip() for item in parts[2].split(',') if item.strip()] |
| |
| |
| def failure(self, full_error, item=None, what_failed='', fatal=False): |
| """If kill_on_failure, print this error and die, |
| otherwise, queue the error and accumulate all the items |
| that triggered the same error. |
| |
| @param full_error: The complete error message. |
| @param item: Name of the actionable item, e.g., hostname. |
| @param what_failed: Name of the failed item. |
| @param fatal: True to exit the program with failure. |
| """ |
| |
| if self.debug: |
| errmsg = str(full_error) |
| else: |
| errmsg = str(full_error).split('Traceback')[0].rstrip('\n') |
| |
| if self.kill_on_failure or fatal: |
| print >> sys.stderr, "%s\n %s" % (what_failed, errmsg) |
| sys.exit(1) |
| |
| # Build a dictionary with the 'what_failed' as keys. The |
| # values are dictionaries with the errmsg as keys and a set |
| # of items as values. |
| # self.failed = |
| # {'Operation delete_host_failed': {'AclAccessViolation: |
| # set('host0', 'host1')}} |
| # Try to gather all the same error messages together, |
| # even if they contain the 'item' |
| if item and item in errmsg: |
| errmsg = errmsg.replace(item, FAIL_TAG) |
| if self.failed.has_key(what_failed): |
| self.failed[what_failed].setdefault(errmsg, set()).add(item) |
| else: |
| self.failed[what_failed] = {errmsg: set([item])} |
| |
| |
| def show_all_failures(self): |
| """Print all failure information. |
| """ |
| if not self.failed: |
| return 0 |
| for what_failed in self.failed.keys(): |
| print >> sys.stderr, what_failed + ':' |
| for (errmsg, items) in self.failed[what_failed].iteritems(): |
| if len(items) == 0: |
| print >> sys.stderr, errmsg |
| elif items == set(['']): |
| print >> sys.stderr, ' ' + errmsg |
| elif len(items) == 1: |
| # Restore the only item |
| if FAIL_TAG in errmsg: |
| errmsg = errmsg.replace(FAIL_TAG, items.pop()) |
| else: |
| errmsg = '%s (%s)' % (errmsg, items.pop()) |
| print >> sys.stderr, ' ' + errmsg |
| else: |
| print >> sys.stderr, ' ' + errmsg + ' with <XYZ> in:' |
| twrap = textwrap.TextWrapper(initial_indent=' ', |
| subsequent_indent=' ') |
| items = list(items) |
| items.sort() |
| print >> sys.stderr, twrap.fill(', '.join(items)) |
| return 1 |
| |
| |
| def __init__(self): |
| """Setup the parser common options""" |
| # Initialized for unit tests. |
| self.afe = None |
| self.failed = {} |
| self.data = {} |
| self.debug = False |
| self.parse_delim = '|' |
| self.kill_on_failure = False |
| self.web_server = '' |
| self.verbose = False |
| self.topic_parse_info = item_parse_info(attribute_name='not_used') |
| |
| self.parser = optparse.OptionParser(self._get_usage()) |
| self.parser.add_option('-g', '--debug', |
| help='Print debugging information', |
| action='store_true', default=False) |
| self.parser.add_option('--kill-on-failure', |
| help='Stop at the first failure', |
| action='store_true', default=False) |
| self.parser.add_option('--parse', |
| help='Print the output using | ' |
| 'separated key=value fields', |
| action='store_true', default=False) |
| self.parser.add_option('--parse-delim', |
| help='Delimiter to use to separate the ' |
| 'key=value fields', default='|') |
| self.parser.add_option('-v', '--verbose', |
| action='store_true', default=False) |
| self.parser.add_option('-w', '--web', |
| help='Specify the autotest server ' |
| 'to talk to', |
| action='store', type='string', |
| dest='web_server', default=None) |
| |
| |
| def _get_usage(self): |
| return "atest %s %s [options] %s" % (self.msg_topic.lower(), |
| self.usage_action, |
| self.msg_items) |
| |
| |
| def backward_compatibility(self, action, argv): |
| """To be overidden by subclass if their syntax changed. |
| |
| @param action: Name of the action. |
| @param argv: A list of arguments. |
| """ |
| return action |
| |
| |
| def parse(self, parse_info=[], req_items=None): |
| """Parse command arguments. |
| |
| parse_info is a list of item_parse_info objects. |
| There should only be one use_leftover set to True in the list. |
| |
| Also check that the req_items is not empty after parsing. |
| |
| @param parse_info: A list of item_parse_info objects. |
| @param req_items: A list of required items. |
| """ |
| (options, leftover) = self.parse_global() |
| |
| all_parse_info = parse_info[:] |
| all_parse_info.append(self.topic_parse_info) |
| |
| try: |
| for item_parse_info in all_parse_info: |
| values, leftover = item_parse_info.get_values(options, |
| leftover) |
| setattr(self, item_parse_info.attribute_name, values) |
| except CliError, s: |
| self.invalid_syntax(s) |
| |
| if (req_items and not getattr(self, req_items, None)): |
| self.invalid_syntax('%s %s requires at least one %s' % |
| (self.msg_topic, |
| self.usage_action, |
| self.msg_topic)) |
| |
| return (options, leftover) |
| |
| |
| def parse_global(self): |
| """Parse the global arguments. |
| |
| It consumes what the common object needs to know, and |
| let the children look at all the options. We could |
| remove the options that we have used, but there is no |
| harm in leaving them, and the children may need them |
| in the future. |
| |
| Must be called from its children parse()""" |
| (options, leftover) = self.parser.parse_args() |
| # Handle our own options setup in __init__() |
| self.debug = options.debug |
| self.kill_on_failure = options.kill_on_failure |
| |
| if options.parse: |
| suffix = '_parse' |
| else: |
| suffix = '_std' |
| for func in ['print_fields', 'print_table', |
| 'print_by_ids', 'print_list']: |
| setattr(self, func, getattr(self, func + suffix)) |
| |
| self.parse_delim = options.parse_delim |
| |
| self.verbose = options.verbose |
| self.web_server = options.web_server |
| try: |
| self.afe = rpc.afe_comm(self.web_server) |
| except rpc.AuthError, s: |
| self.failure(str(s), fatal=True) |
| |
| return (options, leftover) |
| |
| |
| def check_and_create_items(self, op_get, op_create, |
| items, **data_create): |
| """Create the items if they don't exist already. |
| |
| @param op_get: Name of `get` RPC. |
| @param op_create: Name of `create` RPC. |
| @param items: Actionable items specified in CLI command, e.g., hostname, |
| to be passed to each RPC. |
| @param data_create: Data to be passed to `create` RPC. |
| """ |
| for item in items: |
| ret = self.execute_rpc(op_get, name=item) |
| |
| if len(ret) == 0: |
| try: |
| data_create['name'] = item |
| self.execute_rpc(op_create, **data_create) |
| except CliError: |
| continue |
| |
| |
| def execute_rpc(self, op, item='', **data): |
| """Execute RPC. |
| |
| @param op: Name of the RPC. |
| @param item: Actionable item specified in CLI command. |
| @param data: Data to be passed to RPC. |
| """ |
| retry = 2 |
| while retry: |
| try: |
| return self.afe.run(op, **data) |
| except urllib2.URLError, err: |
| if hasattr(err, 'reason'): |
| if 'timed out' not in err.reason: |
| self.invalid_syntax('Invalid server name %s: %s' % |
| (self.afe.web_server, err)) |
| if hasattr(err, 'code'): |
| error_parts = [str(err)] |
| if self.debug: |
| error_parts.append(err.read()) # read the response body |
| self.failure('\n\n'.join(error_parts), item=item, |
| what_failed=("Error received from web server")) |
| raise CliError("Error from web server") |
| if self.debug: |
| print 'retrying: %r %d' % (data, retry) |
| retry -= 1 |
| if retry == 0: |
| if item: |
| myerr = '%s timed out for %s' % (op, item) |
| else: |
| myerr = '%s timed out' % op |
| self.failure(myerr, item=item, |
| what_failed=("Timed-out contacting " |
| "the Autotest server")) |
| raise CliError("Timed-out contacting the Autotest server") |
| except mock.CheckPlaybackError: |
| raise |
| except Exception, full_error: |
| # There are various exceptions throwns by JSON, |
| # urllib & httplib, so catch them all. |
| self.failure(full_error, item=item, |
| what_failed='Operation %s failed' % op) |
| raise CliError(str(full_error)) |
| |
| |
| # There is no output() method in the atest object (yet?) |
| # but here are some helper functions to be used by its |
| # children |
| def print_wrapped(self, msg, values): |
| """Print given message and values in wrapped lines unless |
| AUTOTEST_CLI_NO_WRAP is specified in environment variables. |
| |
| @param msg: Message to print. |
| @param values: A list of values to print. |
| """ |
| if len(values) == 0: |
| return |
| elif len(values) == 1: |
| print msg + ': ' |
| elif len(values) > 1: |
| if msg.endswith('s'): |
| print msg + ': ' |
| else: |
| print msg + 's: ' |
| |
| values.sort() |
| |
| if 'AUTOTEST_CLI_NO_WRAP' in os.environ: |
| print '\n'.join(values) |
| return |
| |
| twrap = textwrap.TextWrapper(initial_indent='\t', |
| subsequent_indent='\t') |
| print twrap.fill(', '.join(values)) |
| |
| |
| def __conv_value(self, type, value): |
| return KEYS_CONVERT.get(type, str)(value) |
| |
| |
| def print_fields_std(self, items, keys, title=None): |
| """Print the keys in each item, one on each line. |
| |
| @param items: Items to print. |
| @param keys: Name of the keys to look up each item in items. |
| @param title: Title of the output, default to None. |
| """ |
| if not items: |
| return |
| if title: |
| print title |
| for item in items: |
| for key in keys: |
| print '%s: %s' % (KEYS_TO_NAMES_EN[key], |
| self.__conv_value(key, |
| _get_item_key(item, key))) |
| |
| |
| def print_fields_parse(self, items, keys, title=None): |
| """Print the keys in each item as comma separated name=value |
| |
| @param items: Items to print. |
| @param keys: Name of the keys to look up each item in items. |
| @param title: Title of the output, default to None. |
| """ |
| for item in items: |
| values = ['%s=%s' % (KEYS_TO_NAMES_EN[key], |
| self.__conv_value(key, |
| _get_item_key(item, key))) |
| for key in keys |
| if self.__conv_value(key, |
| _get_item_key(item, key)) != ''] |
| print self.parse_delim.join(values) |
| |
| |
| def __find_justified_fmt(self, items, keys): |
| """Find the max length for each field. |
| |
| @param items: Items to lookup for. |
| @param keys: Name of the keys to look up each item in items. |
| """ |
| lens = {} |
| # Don't justify the last field, otherwise we have blank |
| # lines when the max is overlaps but the current values |
| # are smaller |
| if not items: |
| print "No results" |
| return |
| for key in keys[:-1]: |
| lens[key] = max(len(self.__conv_value(key, |
| _get_item_key(item, key))) |
| for item in items) |
| lens[key] = max(lens[key], len(KEYS_TO_NAMES_EN[key])) |
| lens[keys[-1]] = 0 |
| |
| return ' '.join(["%%-%ds" % lens[key] for key in keys]) |
| |
| |
| def print_table_std(self, items, keys_header, sublist_keys=()): |
| """Print a mix of header and lists in a user readable format. |
| |
| The headers are justified, the sublist_keys are wrapped. |
| |
| @param items: Items to print. |
| @param keys_header: Header of the keys, use to look up in items. |
| @param sublist_keys: Keys for sublist in each item. |
| """ |
| if not items: |
| return |
| fmt = self.__find_justified_fmt(items, keys_header) |
| header = tuple(KEYS_TO_NAMES_EN[key] for key in keys_header) |
| print fmt % header |
| for item in items: |
| values = tuple(self.__conv_value(key, |
| _get_item_key(item, key)) |
| for key in keys_header) |
| print fmt % values |
| if sublist_keys: |
| for key in sublist_keys: |
| self.print_wrapped(KEYS_TO_NAMES_EN[key], |
| _get_item_key(item, key)) |
| print '\n' |
| |
| |
| def print_table_parse(self, items, keys_header, sublist_keys=()): |
| """Print a mix of header and lists in a user readable format. |
| |
| @param items: Items to print. |
| @param keys_header: Header of the keys, use to look up in items. |
| @param sublist_keys: Keys for sublist in each item. |
| """ |
| for item in items: |
| values = ['%s=%s' % (KEYS_TO_NAMES_EN[key], |
| self.__conv_value(key, _get_item_key(item, key))) |
| for key in keys_header |
| if self.__conv_value(key, |
| _get_item_key(item, key)) != ''] |
| |
| if sublist_keys: |
| [values.append('%s=%s'% (KEYS_TO_NAMES_EN[key], |
| ','.join(_get_item_key(item, key)))) |
| for key in sublist_keys |
| if len(_get_item_key(item, key))] |
| |
| print self.parse_delim.join(values) |
| |
| |
| def print_by_ids_std(self, items, title=None, line_before=False): |
| """Prints ID & names of items in a user readable form. |
| |
| @param items: Items to print. |
| @param title: Title of the output, default to None. |
| @param line_before: True to print an empty line before the output, |
| default to False. |
| """ |
| if not items: |
| return |
| if line_before: |
| print |
| if title: |
| print title + ':' |
| self.print_table_std(items, keys_header=['id', 'name']) |
| |
| |
| def print_by_ids_parse(self, items, title=None, line_before=False): |
| """Prints ID & names of items in a parseable format. |
| |
| @param items: Items to print. |
| @param title: Title of the output, default to None. |
| @param line_before: True to print an empty line before the output, |
| default to False. |
| """ |
| if not items: |
| return |
| if line_before: |
| print |
| if title: |
| print title + '=', |
| values = [] |
| for item in items: |
| values += ['%s=%s' % (KEYS_TO_NAMES_EN[key], |
| self.__conv_value(key, |
| _get_item_key(item, key))) |
| for key in ['id', 'name'] |
| if self.__conv_value(key, |
| _get_item_key(item, key)) != ''] |
| print self.parse_delim.join(values) |
| |
| |
| def print_list_std(self, items, key): |
| """Print a wrapped list of results |
| |
| @param items: Items to to lookup for given key, could be a nested |
| dictionary. |
| @param key: Name of the key to look up for value. |
| """ |
| if not items: |
| return |
| print ' '.join(_get_item_key(item, key) for item in items) |
| |
| |
| def print_list_parse(self, items, key): |
| """Print a wrapped list of results. |
| |
| @param items: Items to to lookup for given key, could be a nested |
| dictionary. |
| @param key: Name of the key to look up for value. |
| """ |
| if not items: |
| return |
| print '%s=%s' % (KEYS_TO_NAMES_EN[key], |
| ','.join(_get_item_key(item, key) for item in items)) |