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