Home | History | Annotate | Download | only in cli
      1 #
      2 # Copyright 2008 Google Inc. All Rights Reserved.
      3 
      4 """
      5 The job module contains the objects and methods used to
      6 manage jobs in Autotest.
      7 
      8 The valid actions are:
      9 list:    lists job(s)
     10 create:  create a job
     11 abort:   abort job(s)
     12 stat:    detailed listing of job(s)
     13 
     14 The common options are:
     15 
     16 See topic_common.py for a High Level Design and Algorithm.
     17 """
     18 
     19 # pylint: disable=missing-docstring
     20 
     21 import getpass, re
     22 from autotest_lib.cli import topic_common, action_common
     23 from autotest_lib.client.common_lib import control_data
     24 from autotest_lib.client.common_lib import priorities
     25 
     26 
     27 class job(topic_common.atest):
     28     """Job class
     29     atest job [create|clone|list|stat|abort] <options>"""
     30     usage_action = '[create|clone|list|stat|abort]'
     31     topic = msg_topic = 'job'
     32     msg_items = '<job_ids>'
     33 
     34 
     35     def _convert_status(self, results):
     36         for result in results:
     37             total = sum(result['status_counts'].values())
     38             status = ['%s=%s(%.1f%%)' % (key, val, 100.0*float(val)/total)
     39                       for key, val in result['status_counts'].iteritems()]
     40             status.sort()
     41             result['status_counts'] = ', '.join(status)
     42 
     43 
     44     def backward_compatibility(self, action, argv):
     45         """ 'job create --clone' became 'job clone --id' """
     46         if action == 'create':
     47             for option in ['-l', '--clone']:
     48                 if option in argv:
     49                     argv[argv.index(option)] = '--id'
     50                     action = 'clone'
     51         return action
     52 
     53 
     54 class job_help(job):
     55     """Just here to get the atest logic working.
     56     Usage is set by its parent"""
     57     pass
     58 
     59 
     60 class job_list_stat(action_common.atest_list, job):
     61     def __init__(self):
     62         super(job_list_stat, self).__init__()
     63 
     64         self.topic_parse_info = topic_common.item_parse_info(
     65             attribute_name='jobs',
     66             use_leftover=True)
     67 
     68 
     69     def __split_jobs_between_ids_names(self):
     70         job_ids = []
     71         job_names = []
     72 
     73         # Sort between job IDs and names
     74         for job_id in self.jobs:
     75             if job_id.isdigit():
     76                 job_ids.append(job_id)
     77             else:
     78                 job_names.append(job_id)
     79         return (job_ids, job_names)
     80 
     81 
     82     def execute_on_ids_and_names(self, op, filters={},
     83                                  check_results={'id__in': 'id',
     84                                                 'name__in': 'id'},
     85                                  tag_id='id__in', tag_name='name__in'):
     86         if not self.jobs:
     87             # Want everything
     88             return super(job_list_stat, self).execute(op=op, filters=filters)
     89 
     90         all_jobs = []
     91         (job_ids, job_names) = self.__split_jobs_between_ids_names()
     92 
     93         for items, tag in [(job_ids, tag_id),
     94                           (job_names, tag_name)]:
     95             if items:
     96                 new_filters = filters.copy()
     97                 new_filters[tag] = items
     98                 jobs = super(job_list_stat,
     99                              self).execute(op=op,
    100                                            filters=new_filters,
    101                                            check_results=check_results)
    102                 all_jobs.extend(jobs)
    103 
    104         return all_jobs
    105 
    106 
    107 class job_list(job_list_stat):
    108     """atest job list [<jobs>] [--all] [--running] [--user <username>]"""
    109     def __init__(self):
    110         super(job_list, self).__init__()
    111         self.parser.add_option('-a', '--all', help='List jobs for all '
    112                                'users.', action='store_true', default=False)
    113         self.parser.add_option('-r', '--running', help='List only running '
    114                                'jobs', action='store_true')
    115         self.parser.add_option('-u', '--user', help='List jobs for given '
    116                                'user', type='string')
    117 
    118 
    119     def parse(self):
    120         options, leftover = super(job_list, self).parse()
    121         self.all = options.all
    122         self.data['running'] = options.running
    123         if options.user:
    124             if options.all:
    125                 self.invalid_syntax('Only specify --all or --user, not both.')
    126             else:
    127                 self.data['owner'] = options.user
    128         elif not options.all and not self.jobs:
    129             self.data['owner'] = getpass.getuser()
    130 
    131         return options, leftover
    132 
    133 
    134     def execute(self):
    135         return self.execute_on_ids_and_names(op='get_jobs_summary',
    136                                              filters=self.data)
    137 
    138 
    139     def output(self, results):
    140         keys = ['id', 'owner', 'name', 'status_counts']
    141         if self.verbose:
    142             keys.extend(['priority', 'control_type', 'created_on'])
    143         self._convert_status(results)
    144         super(job_list, self).output(results, keys)
    145 
    146 
    147 
    148 class job_stat(job_list_stat):
    149     """atest job stat <job>"""
    150     usage_action = 'stat'
    151 
    152     def __init__(self):
    153         super(job_stat, self).__init__()
    154         self.parser.add_option('-f', '--control-file',
    155                                help='Display the control file',
    156                                action='store_true', default=False)
    157         self.parser.add_option('-N', '--list-hosts',
    158                                help='Display only a list of hosts',
    159                                action='store_true')
    160         self.parser.add_option('-s', '--list-hosts-status',
    161                                help='Display only the hosts in these statuses '
    162                                'for a job.', action='store')
    163 
    164 
    165     def parse(self):
    166         status_list = topic_common.item_parse_info(
    167                 attribute_name='status_list',
    168                 inline_option='list_hosts_status')
    169         options, leftover = super(job_stat, self).parse([status_list],
    170                                                         req_items='jobs')
    171 
    172         if not self.jobs:
    173             self.invalid_syntax('Must specify at least one job.')
    174 
    175         self.show_control_file = options.control_file
    176         self.list_hosts = options.list_hosts
    177 
    178         if self.list_hosts and self.status_list:
    179             self.invalid_syntax('--list-hosts is implicit when using '
    180                                 '--list-hosts-status.')
    181         if len(self.jobs) > 1 and (self.list_hosts or self.status_list):
    182             self.invalid_syntax('--list-hosts and --list-hosts-status should '
    183                                 'only be used on a single job.')
    184 
    185         return options, leftover
    186 
    187 
    188     def _merge_results(self, summary, qes):
    189         hosts_status = {}
    190         for qe in qes:
    191             if qe['host']:
    192                 job_id = qe['job']['id']
    193                 hostname = qe['host']['hostname']
    194                 hosts_status.setdefault(job_id,
    195                                         {}).setdefault(qe['status'],
    196                                                        []).append(hostname)
    197 
    198         for job in summary:
    199             job_id = job['id']
    200             if hosts_status.has_key(job_id):
    201                 this_job = hosts_status[job_id]
    202                 job['hosts'] = ' '.join(' '.join(host) for host in
    203                                         this_job.itervalues())
    204                 host_per_status = ['%s="%s"' %(status, ' '.join(host))
    205                                    for status, host in this_job.iteritems()]
    206                 job['hosts_status'] = ', '.join(host_per_status)
    207                 if self.status_list:
    208                     statuses = set(s.lower() for s in self.status_list)
    209                     all_hosts = [s for s in host_per_status if s.split('=',
    210                                  1)[0].lower() in statuses]
    211                     job['hosts_selected_status'] = '\n'.join(all_hosts)
    212             else:
    213                 job['hosts_status'] = ''
    214 
    215             if not job.get('hosts'):
    216                 self.generic_error('Job has unassigned meta-hosts, '
    217                                    'try again shortly.')
    218 
    219         return summary
    220 
    221 
    222     def execute(self):
    223         summary = self.execute_on_ids_and_names(op='get_jobs_summary')
    224 
    225         # Get the real hostnames
    226         qes = self.execute_on_ids_and_names(op='get_host_queue_entries',
    227                                             check_results={},
    228                                             tag_id='job__in',
    229                                             tag_name='job__name__in')
    230 
    231         self._convert_status(summary)
    232 
    233         return self._merge_results(summary, qes)
    234 
    235 
    236     def output(self, results):
    237         if self.list_hosts:
    238             keys = ['hosts']
    239         elif self.status_list:
    240             keys = ['hosts_selected_status']
    241         elif not self.verbose:
    242             keys = ['id', 'name', 'priority', 'status_counts', 'hosts_status']
    243         else:
    244             keys = ['id', 'name', 'priority', 'status_counts', 'hosts_status',
    245                     'owner', 'control_type',  'synch_count', 'created_on',
    246                     'run_verify', 'reboot_before', 'reboot_after',
    247                     'parse_failed_repair']
    248 
    249         if self.show_control_file:
    250             keys.append('control_file')
    251 
    252         super(job_stat, self).output(results, keys)
    253 
    254 
    255 class job_create_or_clone(action_common.atest_create, job):
    256     """Class containing the code common to the job create and clone actions"""
    257     msg_items = 'job_name'
    258 
    259     def __init__(self):
    260         super(job_create_or_clone, self).__init__()
    261         self.hosts = []
    262         self.data_item_key = 'name'
    263         self.parser.add_option('-p', '--priority',
    264                                help='Job priority (int)', type='int',
    265                                default=priorities.Priority.DEFAULT)
    266         self.parser.add_option('-b', '--labels',
    267                                help='Comma separated list of labels '
    268                                'to get machine list from.', default='')
    269         self.parser.add_option('-m', '--machine', help='List of machines to '
    270                                'run on')
    271         self.parser.add_option('-M', '--mlist',
    272                                help='File listing machines to use',
    273                                type='string', metavar='MACHINE_FLIST')
    274         self.parser.add_option('--one-time-hosts',
    275                                help='List of one time hosts')
    276         self.parser.add_option('-e', '--email',
    277                                help='A comma seperated list of '
    278                                'email addresses to notify of job completion',
    279                                default='')
    280 
    281 
    282     def _parse_hosts(self, args):
    283         """ Parses the arguments to generate a list of hosts and meta_hosts
    284         A host is a regular name, a meta_host is n*label or *label.
    285         These can be mixed on the CLI, and separated by either commas or
    286         spaces, e.g.: 5*Machine_Label host0 5*Machine_Label2,host2 """
    287 
    288         hosts = []
    289         meta_hosts = []
    290 
    291         for arg in args:
    292             for host in arg.split(','):
    293                 if re.match('^[0-9]+[*]', host):
    294                     num, host = host.split('*', 1)
    295                     meta_hosts += int(num) * [host]
    296                 elif re.match('^[*](\w*)', host):
    297                     meta_hosts += [re.match('^[*](\w*)', host).group(1)]
    298                 elif host != '' and host not in hosts:
    299                     # Real hostname and not a duplicate
    300                     hosts.append(host)
    301 
    302         return (hosts, meta_hosts)
    303 
    304 
    305     def parse(self, parse_info=[]):
    306         host_info = topic_common.item_parse_info(attribute_name='hosts',
    307                                                  inline_option='machine',
    308                                                  filename_option='mlist')
    309         job_info = topic_common.item_parse_info(attribute_name='jobname',
    310                                                 use_leftover=True)
    311         oth_info = topic_common.item_parse_info(attribute_name='one_time_hosts',
    312                                                 inline_option='one_time_hosts')
    313         label_info = topic_common.item_parse_info(attribute_name='labels',
    314                                                   inline_option='labels')
    315 
    316         options, leftover = super(job_create_or_clone, self).parse(
    317                 [host_info, job_info, oth_info, label_info] + parse_info,
    318                 req_items='jobname')
    319         self.data = {
    320             'priority': options.priority,
    321         }
    322         jobname = getattr(self, 'jobname')
    323         if len(jobname) > 1:
    324             self.invalid_syntax('Too many arguments specified, only expected '
    325                                 'to receive job name: %s' % jobname)
    326         self.jobname = jobname[0]
    327 
    328         if self.one_time_hosts:
    329             self.data['one_time_hosts'] = self.one_time_hosts
    330 
    331         if self.labels:
    332             label_hosts = self.execute_rpc(op='get_hosts',
    333                                            multiple_labels=self.labels)
    334             for host in label_hosts:
    335                 self.hosts.append(host['hostname'])
    336 
    337         self.data['name'] = self.jobname
    338 
    339         (self.data['hosts'],
    340          self.data['meta_hosts']) = self._parse_hosts(self.hosts)
    341 
    342         self.data['email_list'] = options.email
    343 
    344         return options, leftover
    345 
    346 
    347     def create_job(self):
    348         job_id = self.execute_rpc(op='create_job', **self.data)
    349         return ['%s (id %s)' % (self.jobname, job_id)]
    350 
    351 
    352     def get_items(self):
    353         return [self.jobname]
    354 
    355 
    356 
    357 class job_create(job_create_or_clone):
    358     """atest job create [--priority <int>]
    359     [--synch_count] [--control-file </path/to/cfile>]
    360     [--on-server] [--test <test1,test2>]
    361     [--mlist </path/to/machinelist>] [--machine <host1 host2 host3>]
    362     [--labels <list of labels of machines to run on>]
    363     [--reboot_before <option>] [--reboot_after <option>]
    364     [--noverify] [--timeout <timeout>] [--max_runtime <max runtime>]
    365     [--one-time-hosts <hosts>] [--email <email>]
    366     [--dependencies <labels this job is dependent on>]
    367     [--parse-failed-repair <option>]
    368     [--image <http://path/to/image>] [--require-ssp]
    369     job_name
    370 
    371     Creating a job is rather different from the other create operations,
    372     so it only uses the __init__() and output() from its superclass.
    373     """
    374     op_action = 'create'
    375 
    376     def __init__(self):
    377         super(job_create, self).__init__()
    378         self.ctrl_file_data = {}
    379         self.parser.add_option('-y', '--synch_count', type=int,
    380                                help='Number of machines to use per autoserv '
    381                                     'execution')
    382         self.parser.add_option('-f', '--control-file',
    383                                help='use this control file', metavar='FILE')
    384         self.parser.add_option('-s', '--server',
    385                                help='This is server-side job',
    386                                action='store_true', default=False)
    387         self.parser.add_option('-t', '--test',
    388                                help='List of tests to run')
    389 
    390         self.parser.add_option('-d', '--dependencies', help='Comma separated '
    391                                'list of labels this job is dependent on.',
    392                                default='')
    393 
    394         self.parser.add_option('-B', '--reboot_before',
    395                                help='Whether or not to reboot the machine '
    396                                     'before the job (never/if dirty/always)',
    397                                type='choice',
    398                                choices=('never', 'if dirty', 'always'))
    399         self.parser.add_option('-a', '--reboot_after',
    400                                help='Whether or not to reboot the machine '
    401                                     'after the job (never/if all tests passed/'
    402                                     'always)',
    403                                type='choice',
    404                                choices=('never', 'if all tests passed',
    405                                         'always'))
    406 
    407         self.parser.add_option('--parse-failed-repair',
    408                                help='Whether or not to parse failed repair '
    409                                     'results as part of the job',
    410                                type='choice',
    411                                choices=('true', 'false'))
    412         self.parser.add_option('-n', '--noverify',
    413                                help='Do not run verify for job',
    414                                default=False, action='store_true')
    415         self.parser.add_option('-o', '--timeout_mins',
    416                                help='Job timeout in minutes.',
    417                                metavar='TIMEOUT')
    418         self.parser.add_option('--max_runtime',
    419                                help='Job maximum runtime in minutes')
    420 
    421         self.parser.add_option('-i', '--image',
    422                                help='OS image to install before running the '
    423                                     'test.')
    424         self.parser.add_option('--require-ssp',
    425                                help='Require server-side packaging',
    426                                default=False, action='store_true')
    427 
    428 
    429     def parse(self):
    430         deps_info = topic_common.item_parse_info(attribute_name='dependencies',
    431                                                  inline_option='dependencies')
    432         options, leftover = super(job_create, self).parse(
    433                 parse_info=[deps_info])
    434 
    435         if (len(self.hosts) == 0 and not self.one_time_hosts
    436             and not options.labels):
    437             self.invalid_syntax('Must specify at least one machine.'
    438                                 '(-m, -M, -b or --one-time-hosts).')
    439         if not options.control_file and not options.test:
    440             self.invalid_syntax('Must specify either --test or --control-file'
    441                                 ' to create a job.')
    442         if options.control_file and options.test:
    443             self.invalid_syntax('Can only specify one of --control-file or '
    444                                 '--test, not both.')
    445         if options.control_file:
    446             try:
    447                 control_file_f = open(options.control_file)
    448                 try:
    449                     control_file_data = control_file_f.read()
    450                 finally:
    451                     control_file_f.close()
    452             except IOError:
    453                 self.generic_error('Unable to read from specified '
    454                                    'control-file: %s' % options.control_file)
    455             self.data['control_file'] = control_file_data
    456         if options.test:
    457             if options.server:
    458                 self.invalid_syntax('If you specify tests, then the '
    459                                     'client/server setting is implicit and '
    460                                     'cannot be overriden.')
    461             tests = [t.strip() for t in options.test.split(',') if t.strip()]
    462             self.ctrl_file_data['tests'] = tests
    463 
    464         if options.image:
    465             self.data['image'] = options.image
    466 
    467         if options.reboot_before:
    468             self.data['reboot_before'] = options.reboot_before.capitalize()
    469         if options.reboot_after:
    470             self.data['reboot_after'] = options.reboot_after.capitalize()
    471         if options.parse_failed_repair:
    472             self.data['parse_failed_repair'] = (
    473                 options.parse_failed_repair == 'true')
    474         if options.noverify:
    475             self.data['run_verify'] = False
    476         if options.timeout_mins:
    477             self.data['timeout_mins'] = options.timeout_mins
    478         if options.max_runtime:
    479             self.data['max_runtime_mins'] = options.max_runtime
    480 
    481         self.data['dependencies'] = self.dependencies
    482 
    483         if options.synch_count:
    484             self.data['synch_count'] = options.synch_count
    485         if options.server:
    486             self.data['control_type'] = control_data.CONTROL_TYPE_NAMES.SERVER
    487         else:
    488             self.data['control_type'] = control_data.CONTROL_TYPE_NAMES.CLIENT
    489 
    490         self.data['require_ssp'] = options.require_ssp
    491 
    492         return options, leftover
    493 
    494 
    495     def execute(self):
    496         if self.ctrl_file_data:
    497             cf_info = self.execute_rpc(op='generate_control_file',
    498                                        item=self.jobname,
    499                                        **self.ctrl_file_data)
    500 
    501             self.data['control_file'] = cf_info['control_file']
    502             if 'synch_count' not in self.data:
    503                 self.data['synch_count'] = cf_info['synch_count']
    504             if cf_info['is_server']:
    505                 self.data['control_type'] = control_data.CONTROL_TYPE_NAMES.SERVER
    506             else:
    507                 self.data['control_type'] = control_data.CONTROL_TYPE_NAMES.CLIENT
    508 
    509             # Get the union of the 2 sets of dependencies
    510             deps = set(self.data['dependencies'])
    511             deps = sorted(deps.union(cf_info['dependencies']))
    512             self.data['dependencies'] = list(deps)
    513 
    514         if 'synch_count' not in self.data:
    515             self.data['synch_count'] = 1
    516 
    517         return self.create_job()
    518 
    519 
    520 class job_clone(job_create_or_clone):
    521     """atest job clone [--priority <int>]
    522     [--mlist </path/to/machinelist>] [--machine <host1 host2 host3>]
    523     [--labels <list of labels of machines to run on>]
    524     [--one-time-hosts <hosts>] [--email <email>]
    525     job_name
    526 
    527     Cloning a job is rather different from the other create operations,
    528     so it only uses the __init__() and output() from its superclass.
    529     """
    530     op_action = 'clone'
    531     usage_action = 'clone'
    532 
    533     def __init__(self):
    534         super(job_clone, self).__init__()
    535         self.parser.add_option('-i', '--id', help='Job id to clone',
    536                                default=False,
    537                                metavar='JOB_ID')
    538         self.parser.add_option('-r', '--reuse-hosts',
    539                                help='Use the exact same hosts as the '
    540                                'cloned job.',
    541                                action='store_true', default=False)
    542 
    543 
    544     def parse(self):
    545         options, leftover = super(job_clone, self).parse()
    546 
    547         self.clone_id = options.id
    548         self.reuse_hosts = options.reuse_hosts
    549 
    550         host_specified = self.hosts or self.one_time_hosts or options.labels
    551         if self.reuse_hosts and host_specified:
    552             self.invalid_syntax('Cannot specify hosts and reuse the same '
    553                                 'ones as the cloned job.')
    554 
    555         if not (self.reuse_hosts or host_specified):
    556             self.invalid_syntax('Must reuse or specify at least one '
    557                                 'machine (-r, -m, -M, -b or '
    558                                 '--one-time-hosts).')
    559 
    560         return options, leftover
    561 
    562 
    563     def execute(self):
    564         clone_info = self.execute_rpc(op='get_info_for_clone',
    565                                       id=self.clone_id,
    566                                       preserve_metahosts=self.reuse_hosts)
    567 
    568         # Remove fields from clone data that cannot be reused
    569         for field in ('name', 'created_on', 'id', 'owner'):
    570             del clone_info['job'][field]
    571 
    572         # Also remove parameterized_job field, as the feature still is
    573         # incomplete, this tool does not attempt to support it for now,
    574         # it uses a different API function and it breaks create_job()
    575         if clone_info['job'].has_key('parameterized_job'):
    576             del clone_info['job']['parameterized_job']
    577 
    578         # Keyword args cannot be unicode strings
    579         self.data.update((str(key), val)
    580                          for key, val in clone_info['job'].iteritems())
    581 
    582         if self.reuse_hosts:
    583             # Convert host list from clone info that can be used for job_create
    584             for label, qty in clone_info['meta_host_counts'].iteritems():
    585                 self.data['meta_hosts'].extend([label]*qty)
    586 
    587             self.data['hosts'].extend(host['hostname']
    588                                       for host in clone_info['hosts'])
    589 
    590         return self.create_job()
    591 
    592 
    593 class job_abort(job, action_common.atest_delete):
    594     """atest job abort <job(s)>"""
    595     usage_action = op_action = 'abort'
    596     msg_done = 'Aborted'
    597 
    598     def parse(self):
    599         job_info = topic_common.item_parse_info(attribute_name='jobids',
    600                                                 use_leftover=True)
    601         options, leftover = super(job_abort, self).parse([job_info],
    602                                                          req_items='jobids')
    603 
    604 
    605     def execute(self):
    606         data = {'job__id__in': self.jobids}
    607         self.execute_rpc(op='abort_host_queue_entries', **data)
    608         print 'Aborting jobs: %s' % ', '.join(self.jobids)
    609 
    610 
    611     def get_items(self):
    612         return self.jobids
    613