1# Copyright 2008 Google Inc. All Rights Reserved.
2
3"""
4The host module contains the objects and method used to
5manage a host in Autotest.
6
7The valid actions are:
8create:  adds host(s)
9delete:  deletes host(s)
10list:    lists host(s)
11stat:    displays host(s) information
12mod:     modifies host(s)
13jobs:    lists all jobs that ran on host(s)
14
15The common options are:
16-M|--mlist:   file containing a list of machines
17
18
19See topic_common.py for a High Level Design and Algorithm.
20
21"""
22import common
23import re
24import socket
25
26from autotest_lib.cli import action_common, rpc, topic_common
27from autotest_lib.client.bin import utils as bin_utils
28from autotest_lib.client.common_lib import error, host_protections
29from autotest_lib.server import frontend, hosts
30from autotest_lib.server.hosts import host_info
31
32
33class host(topic_common.atest):
34    """Host class
35    atest host [create|delete|list|stat|mod|jobs] <options>"""
36    usage_action = '[create|delete|list|stat|mod|jobs]'
37    topic = msg_topic = 'host'
38    msg_items = '<hosts>'
39
40    protections = host_protections.Protection.names
41
42
43    def __init__(self):
44        """Add to the parser the options common to all the
45        host actions"""
46        super(host, self).__init__()
47
48        self.parser.add_option('-M', '--mlist',
49                               help='File listing the machines',
50                               type='string',
51                               default=None,
52                               metavar='MACHINE_FLIST')
53
54        self.topic_parse_info = topic_common.item_parse_info(
55            attribute_name='hosts',
56            filename_option='mlist',
57            use_leftover=True)
58
59
60    def _parse_lock_options(self, options):
61        if options.lock and options.unlock:
62            self.invalid_syntax('Only specify one of '
63                                '--lock and --unlock.')
64
65        if options.lock:
66            self.data['locked'] = True
67            self.messages.append('Locked host')
68        elif options.unlock:
69            self.data['locked'] = False
70            self.data['lock_reason'] = ''
71            self.messages.append('Unlocked host')
72
73        if options.lock and options.lock_reason:
74            self.data['lock_reason'] = options.lock_reason
75
76
77    def _cleanup_labels(self, labels, platform=None):
78        """Removes the platform label from the overall labels"""
79        if platform:
80            return [label for label in labels
81                    if label != platform]
82        else:
83            try:
84                return [label for label in labels
85                        if not label['platform']]
86            except TypeError:
87                # This is a hack - the server will soon
88                # do this, so all this code should be removed.
89                return labels
90
91
92    def get_items(self):
93        return self.hosts
94
95
96class host_help(host):
97    """Just here to get the atest logic working.
98    Usage is set by its parent"""
99    pass
100
101
102class host_list(action_common.atest_list, host):
103    """atest host list [--mlist <file>|<hosts>] [--label <label>]
104       [--status <status1,status2>] [--acl <ACL>] [--user <user>]"""
105
106    def __init__(self):
107        super(host_list, self).__init__()
108
109        self.parser.add_option('-b', '--label',
110                               default='',
111                               help='Only list hosts with all these labels '
112                               '(comma separated)')
113        self.parser.add_option('-s', '--status',
114                               default='',
115                               help='Only list hosts with any of these '
116                               'statuses (comma separated)')
117        self.parser.add_option('-a', '--acl',
118                               default='',
119                               help='Only list hosts within this ACL')
120        self.parser.add_option('-u', '--user',
121                               default='',
122                               help='Only list hosts available to this user')
123        self.parser.add_option('-N', '--hostnames-only', help='Only return '
124                               'hostnames for the machines queried.',
125                               action='store_true')
126        self.parser.add_option('--locked',
127                               default=False,
128                               help='Only list locked hosts',
129                               action='store_true')
130        self.parser.add_option('--unlocked',
131                               default=False,
132                               help='Only list unlocked hosts',
133                               action='store_true')
134
135
136
137    def parse(self):
138        """Consume the specific options"""
139        label_info = topic_common.item_parse_info(attribute_name='labels',
140                                                  inline_option='label')
141
142        (options, leftover) = super(host_list, self).parse([label_info])
143
144        self.status = options.status
145        self.acl = options.acl
146        self.user = options.user
147        self.hostnames_only = options.hostnames_only
148
149        if options.locked and options.unlocked:
150            self.invalid_syntax('--locked and --unlocked are '
151                                'mutually exclusive')
152        self.locked = options.locked
153        self.unlocked = options.unlocked
154        return (options, leftover)
155
156
157    def execute(self):
158        """Execute 'atest host list'."""
159        filters = {}
160        check_results = {}
161        if self.hosts:
162            filters['hostname__in'] = self.hosts
163            check_results['hostname__in'] = 'hostname'
164
165        if self.labels:
166            if len(self.labels) == 1:
167                # This is needed for labels with wildcards (x86*)
168                filters['labels__name__in'] = self.labels
169                check_results['labels__name__in'] = None
170            else:
171                filters['multiple_labels'] = self.labels
172                check_results['multiple_labels'] = None
173
174        if self.status:
175            statuses = self.status.split(',')
176            statuses = [status.strip() for status in statuses
177                        if status.strip()]
178
179            filters['status__in'] = statuses
180            check_results['status__in'] = None
181
182        if self.acl:
183            filters['aclgroup__name'] = self.acl
184            check_results['aclgroup__name'] = None
185        if self.user:
186            filters['aclgroup__users__login'] = self.user
187            check_results['aclgroup__users__login'] = None
188
189        if self.locked or self.unlocked:
190            filters['locked'] = self.locked
191            check_results['locked'] = None
192
193        return super(host_list, self).execute(op='get_hosts',
194                                              filters=filters,
195                                              check_results=check_results)
196
197
198    def output(self, results):
199        """Print output of 'atest host list'.
200
201        @param results: the results to be printed.
202        """
203        if results:
204            # Remove the platform from the labels.
205            for result in results:
206                result['labels'] = self._cleanup_labels(result['labels'],
207                                                        result['platform'])
208        if self.hostnames_only:
209            self.print_list(results, key='hostname')
210        else:
211            keys = ['hostname', 'status',
212                    'shard', 'locked', 'lock_reason', 'locked_by', 'platform',
213                    'labels']
214            super(host_list, self).output(results, keys=keys)
215
216
217class host_stat(host):
218    """atest host stat --mlist <file>|<hosts>"""
219    usage_action = 'stat'
220
221    def execute(self):
222        """Execute 'atest host stat'."""
223        results = []
224        # Convert wildcards into real host stats.
225        existing_hosts = []
226        for host in self.hosts:
227            if host.endswith('*'):
228                stats = self.execute_rpc('get_hosts',
229                                         hostname__startswith=host.rstrip('*'))
230                if len(stats) == 0:
231                    self.failure('No hosts matching %s' % host, item=host,
232                                 what_failed='Failed to stat')
233                    continue
234            else:
235                stats = self.execute_rpc('get_hosts', hostname=host)
236                if len(stats) == 0:
237                    self.failure('Unknown host %s' % host, item=host,
238                                 what_failed='Failed to stat')
239                    continue
240            existing_hosts.extend(stats)
241
242        for stat in existing_hosts:
243            host = stat['hostname']
244            # The host exists, these should succeed
245            acls = self.execute_rpc('get_acl_groups', hosts__hostname=host)
246
247            labels = self.execute_rpc('get_labels', host__hostname=host)
248            results.append([[stat], acls, labels, stat['attributes']])
249        return results
250
251
252    def output(self, results):
253        """Print output of 'atest host stat'.
254
255        @param results: the results to be printed.
256        """
257        for stats, acls, labels, attributes in results:
258            print '-'*5
259            self.print_fields(stats,
260                              keys=['hostname', 'platform',
261                                    'status', 'locked', 'locked_by',
262                                    'lock_time', 'lock_reason', 'protection',])
263            self.print_by_ids(acls, 'ACLs', line_before=True)
264            labels = self._cleanup_labels(labels)
265            self.print_by_ids(labels, 'Labels', line_before=True)
266            self.print_dict(attributes, 'Host Attributes', line_before=True)
267
268
269class host_jobs(host):
270    """atest host jobs [--max-query] --mlist <file>|<hosts>"""
271    usage_action = 'jobs'
272
273    def __init__(self):
274        super(host_jobs, self).__init__()
275        self.parser.add_option('-q', '--max-query',
276                               help='Limits the number of results '
277                               '(20 by default)',
278                               type='int', default=20)
279
280
281    def parse(self):
282        """Consume the specific options"""
283        (options, leftover) = super(host_jobs, self).parse()
284        self.max_queries = options.max_query
285        return (options, leftover)
286
287
288    def execute(self):
289        """Execute 'atest host jobs'."""
290        results = []
291        real_hosts = []
292        for host in self.hosts:
293            if host.endswith('*'):
294                stats = self.execute_rpc('get_hosts',
295                                         hostname__startswith=host.rstrip('*'))
296                if len(stats) == 0:
297                    self.failure('No host matching %s' % host, item=host,
298                                 what_failed='Failed to stat')
299                [real_hosts.append(stat['hostname']) for stat in stats]
300            else:
301                real_hosts.append(host)
302
303        for host in real_hosts:
304            queue_entries = self.execute_rpc('get_host_queue_entries',
305                                             host__hostname=host,
306                                             query_limit=self.max_queries,
307                                             sort_by=['-job__id'])
308            jobs = []
309            for entry in queue_entries:
310                job = {'job_id': entry['job']['id'],
311                       'job_owner': entry['job']['owner'],
312                       'job_name': entry['job']['name'],
313                       'status': entry['status']}
314                jobs.append(job)
315            results.append((host, jobs))
316        return results
317
318
319    def output(self, results):
320        """Print output of 'atest host jobs'.
321
322        @param results: the results to be printed.
323        """
324        for host, jobs in results:
325            print '-'*5
326            print 'Hostname: %s' % host
327            self.print_table(jobs, keys_header=['job_id',
328                                                'job_owner',
329                                                'job_name',
330                                                'status'])
331
332class BaseHostModCreate(host):
333    """The base class for host_mod and host_create"""
334    # Matches one attribute=value pair
335    attribute_regex = r'(?P<attribute>\w+)=(?P<value>.+)?'
336
337    def __init__(self):
338        """Add the options shared between host mod and host create actions."""
339        self.messages = []
340        self.host_ids = {}
341        super(BaseHostModCreate, self).__init__()
342        self.parser.add_option('-l', '--lock',
343                               help='Lock hosts',
344                               action='store_true')
345        self.parser.add_option('-u', '--unlock',
346                               help='Unlock hosts',
347                               action='store_true')
348        self.parser.add_option('-r', '--lock_reason',
349                               help='Reason for locking hosts',
350                               default='')
351        self.parser.add_option('-p', '--protection', type='choice',
352                               help=('Set the protection level on a host.  '
353                                     'Must be one of: %s' %
354                                     ', '.join('"%s"' % p
355                                               for p in self.protections)),
356                               choices=self.protections)
357        self._attributes = []
358        self.parser.add_option('--attribute', '-i',
359                               help=('Host attribute to add or change. Format '
360                                     'is <attribute>=<value>. Multiple '
361                                     'attributes can be set by passing the '
362                                     'argument multiple times. Attributes can '
363                                     'be unset by providing an empty value.'),
364                               action='append')
365        self.parser.add_option('-b', '--labels',
366                               help='Comma separated list of labels')
367        self.parser.add_option('-B', '--blist',
368                               help='File listing the labels',
369                               type='string',
370                               metavar='LABEL_FLIST')
371        self.parser.add_option('-a', '--acls',
372                               help='Comma separated list of ACLs')
373        self.parser.add_option('-A', '--alist',
374                               help='File listing the acls',
375                               type='string',
376                               metavar='ACL_FLIST')
377        self.parser.add_option('-t', '--platform',
378                               help='Sets the platform label')
379
380
381    def parse(self):
382        """Consume the options common to host create and host mod.
383        """
384        label_info = topic_common.item_parse_info(attribute_name='labels',
385                                                 inline_option='labels',
386                                                 filename_option='blist')
387        acl_info = topic_common.item_parse_info(attribute_name='acls',
388                                                inline_option='acls',
389                                                filename_option='alist')
390
391        (options, leftover) = super(BaseHostModCreate, self).parse([label_info,
392                                                              acl_info],
393                                                             req_items='hosts')
394
395        self._parse_lock_options(options)
396
397        if options.protection:
398            self.data['protection'] = options.protection
399            self.messages.append('Protection set to "%s"' % options.protection)
400
401        self.attributes = {}
402        if options.attribute:
403            for pair in options.attribute:
404                m = re.match(self.attribute_regex, pair)
405                if not m:
406                    raise topic_common.CliError('Attribute must be in key=value '
407                                                'syntax.')
408                elif m.group('attribute') in self.attributes:
409                    raise topic_common.CliError(
410                            'Multiple values provided for attribute '
411                            '%s.' % m.group('attribute'))
412                self.attributes[m.group('attribute')] = m.group('value')
413
414        self.platform = options.platform
415        return (options, leftover)
416
417
418    def _set_acls(self, hosts, acls):
419        """Add hosts to acls (and remove from all other acls).
420
421        @param hosts: list of hostnames
422        @param acls: list of acl names
423        """
424        # Remove from all ACLs except 'Everyone' and ACLs in list
425        # Skip hosts that don't exist
426        for host in hosts:
427            if host not in self.host_ids:
428                continue
429            host_id = self.host_ids[host]
430            for a in self.execute_rpc('get_acl_groups', hosts=host_id):
431                if a['name'] not in self.acls and a['id'] != 1:
432                    self.execute_rpc('acl_group_remove_hosts', id=a['id'],
433                                     hosts=self.hosts)
434
435        # Add hosts to the ACLs
436        self.check_and_create_items('get_acl_groups', 'add_acl_group',
437                                    self.acls)
438        for a in acls:
439            self.execute_rpc('acl_group_add_hosts', id=a, hosts=hosts)
440
441
442    def _remove_labels(self, host, condition):
443        """Remove all labels from host that meet condition(label).
444
445        @param host: hostname
446        @param condition: callable that returns bool when given a label
447        """
448        if host in self.host_ids:
449            host_id = self.host_ids[host]
450            labels_to_remove = []
451            for l in self.execute_rpc('get_labels', host=host_id):
452                if condition(l):
453                    labels_to_remove.append(l['id'])
454            if labels_to_remove:
455                self.execute_rpc('host_remove_labels', id=host_id,
456                                 labels=labels_to_remove)
457
458
459    def _set_labels(self, host, labels):
460        """Apply labels to host (and remove all other labels).
461
462        @param host: hostname
463        @param labels: list of label names
464        """
465        condition = lambda l: l['name'] not in labels and not l['platform']
466        self._remove_labels(host, condition)
467        self.check_and_create_items('get_labels', 'add_label', labels)
468        self.execute_rpc('host_add_labels', id=host, labels=labels)
469
470
471    def _set_platform_label(self, host, platform_label):
472        """Apply the platform label to host (and remove existing).
473
474        @param host: hostname
475        @param platform_label: platform label's name
476        """
477        self._remove_labels(host, lambda l: l['platform'])
478        self.check_and_create_items('get_labels', 'add_label', [platform_label],
479                                    platform=True)
480        self.execute_rpc('host_add_labels', id=host, labels=[platform_label])
481
482
483    def _set_attributes(self, host, attributes):
484        """Set attributes on host.
485
486        @param host: hostname
487        @param attributes: attribute dictionary
488        """
489        for attr, value in self.attributes.iteritems():
490            self.execute_rpc('set_host_attribute', attribute=attr,
491                             value=value, hostname=host)
492
493
494class host_mod(BaseHostModCreate):
495    """atest host mod [--lock|--unlock --force_modify_locking
496    --platform <arch>
497    --labels <labels>|--blist <label_file>
498    --acls <acls>|--alist <acl_file>
499    --protection <protection_type>
500    --attributes <attr>=<value>;<attr>=<value>
501    --mlist <mach_file>] <hosts>"""
502    usage_action = 'mod'
503
504    def __init__(self):
505        """Add the options specific to the mod action"""
506        super(host_mod, self).__init__()
507        self.parser.add_option('-f', '--force_modify_locking',
508                               help='Forcefully lock\unlock a host',
509                               action='store_true')
510        self.parser.add_option('--remove_acls',
511                               help='Remove all active acls.',
512                               action='store_true')
513        self.parser.add_option('--remove_labels',
514                               help='Remove all labels.',
515                               action='store_true')
516
517
518    def parse(self):
519        """Consume the specific options"""
520        (options, leftover) = super(host_mod, self).parse()
521
522        if options.force_modify_locking:
523             self.data['force_modify_locking'] = True
524
525        self.remove_acls = options.remove_acls
526        self.remove_labels = options.remove_labels
527
528        return (options, leftover)
529
530
531    def execute(self):
532        """Execute 'atest host mod'."""
533        successes = []
534        for host in self.execute_rpc('get_hosts', hostname__in=self.hosts):
535            self.host_ids[host['hostname']] = host['id']
536        for host in self.hosts:
537            if host not in self.host_ids:
538                self.failure('Cannot modify non-existant host %s.' % host)
539                continue
540            host_id = self.host_ids[host]
541
542            try:
543                if self.data:
544                    self.execute_rpc('modify_host', item=host,
545                                     id=host, **self.data)
546
547                if self.attributes:
548                    self._set_attributes(host, self.attributes)
549
550                if self.labels or self.remove_labels:
551                    self._set_labels(host, self.labels)
552
553                if self.platform:
554                    self._set_platform_label(host, self.platform)
555
556                # TODO: Make the AFE return True or False,
557                # especially for lock
558                successes.append(host)
559            except topic_common.CliError, full_error:
560                # Already logged by execute_rpc()
561                pass
562
563        if self.acls or self.remove_acls:
564            self._set_acls(self.hosts, self.acls)
565
566        return successes
567
568
569    def output(self, hosts):
570        """Print output of 'atest host mod'.
571
572        @param hosts: the host list to be printed.
573        """
574        for msg in self.messages:
575            self.print_wrapped(msg, hosts)
576
577
578class HostInfo(object):
579    """Store host information so we don't have to keep looking it up."""
580    def __init__(self, hostname, platform, labels):
581        self.hostname = hostname
582        self.platform = platform
583        self.labels = labels
584
585
586class host_create(BaseHostModCreate):
587    """atest host create [--lock|--unlock --platform <arch>
588    --labels <labels>|--blist <label_file>
589    --acls <acls>|--alist <acl_file>
590    --protection <protection_type>
591    --attributes <attr>=<value>;<attr>=<value>
592    --mlist <mach_file>] <hosts>"""
593    usage_action = 'create'
594
595    def parse(self):
596        """Option logic specific to create action.
597        """
598        (options, leftovers) = super(host_create, self).parse()
599        self.locked = options.lock
600        if 'serials' in self.attributes:
601            if len(self.hosts) > 1:
602                raise topic_common.CliError('Can not specify serials with '
603                                            'multiple hosts.')
604
605
606    @classmethod
607    def construct_without_parse(
608            cls, web_server, hosts, platform=None,
609            locked=False, lock_reason='', labels=[], acls=[],
610            protection=host_protections.Protection.NO_PROTECTION):
611        """Construct a host_create object and fill in data from args.
612
613        Do not need to call parse after the construction.
614
615        Return an object of site_host_create ready to execute.
616
617        @param web_server: A string specifies the autotest webserver url.
618            It is needed to setup comm to make rpc.
619        @param hosts: A list of hostnames as strings.
620        @param platform: A string or None.
621        @param locked: A boolean.
622        @param lock_reason: A string.
623        @param labels: A list of labels as strings.
624        @param acls: A list of acls as strings.
625        @param protection: An enum defined in host_protections.
626        """
627        obj = cls()
628        obj.web_server = web_server
629        try:
630            # Setup stuff needed for afe comm.
631            obj.afe = rpc.afe_comm(web_server)
632        except rpc.AuthError, s:
633            obj.failure(str(s), fatal=True)
634        obj.hosts = hosts
635        obj.platform = platform
636        obj.locked = locked
637        if locked and lock_reason.strip():
638            obj.data['lock_reason'] = lock_reason.strip()
639        obj.labels = labels
640        obj.acls = acls
641        if protection:
642            obj.data['protection'] = protection
643        obj.attributes = {}
644        return obj
645
646
647    def _detect_host_info(self, host):
648        """Detect platform and labels from the host.
649
650        @param host: hostname
651
652        @return: HostInfo object
653        """
654        # Mock an afe_host object so that the host is constructed as if the
655        # data was already in afe
656        data = {'attributes': self.attributes, 'labels': self.labels}
657        afe_host = frontend.Host(None, data)
658        store = host_info.InMemoryHostInfoStore(
659                host_info.HostInfo(labels=self.labels,
660                                   attributes=self.attributes))
661        machine = {
662                'hostname': host,
663                'afe_host': afe_host,
664                'host_info_store': store
665        }
666        try:
667            if bin_utils.ping(host, tries=1, deadline=1) == 0:
668                serials = self.attributes.get('serials', '').split(',')
669                if serials and len(serials) > 1:
670                    host_dut = hosts.create_testbed(machine,
671                                                    adb_serials=serials)
672                else:
673                    adb_serial = self.attributes.get('serials')
674                    host_dut = hosts.create_host(machine,
675                                                 adb_serial=adb_serial)
676
677                info = HostInfo(host, host_dut.get_platform(),
678                                host_dut.get_labels())
679                # Clean host to make sure nothing left after calling it,
680                # e.g. tunnels.
681                if hasattr(host_dut, 'close'):
682                    host_dut.close()
683            else:
684                # Can't ping the host, use default information.
685                info = HostInfo(host, None, [])
686        except (socket.gaierror, error.AutoservRunError,
687                error.AutoservSSHTimeout):
688            # We may be adding a host that does not exist yet or we can't
689            # reach due to hostname/address issues or if the host is down.
690            info = HostInfo(host, None, [])
691        return info
692
693
694    def _execute_add_one_host(self, host):
695        # Always add the hosts as locked to avoid the host
696        # being picked up by the scheduler before it's ACL'ed.
697        self.data['locked'] = True
698        if not self.locked:
699            self.data['lock_reason'] = 'Forced lock on device creation'
700        self.execute_rpc('add_host', hostname=host, status="Ready", **self.data)
701
702        # If there are labels avaliable for host, use them.
703        info = self._detect_host_info(host)
704        labels = set(self.labels)
705        if info.labels:
706            labels.update(info.labels)
707
708        if labels:
709            self._set_labels(host, list(labels))
710
711        # Now add the platform label.
712        # If a platform was not provided and we were able to retrieve it
713        # from the host, use the retrieved platform.
714        platform = self.platform if self.platform else info.platform
715        if platform:
716            self._set_platform_label(host, platform)
717
718        if self.attributes:
719            self._set_attributes(host, self.attributes)
720
721
722    def execute(self):
723        """Execute 'atest host create'."""
724        successful_hosts = []
725        for host in self.hosts:
726            try:
727                self._execute_add_one_host(host)
728                successful_hosts.append(host)
729            except topic_common.CliError:
730                pass
731
732        if successful_hosts:
733            self._set_acls(successful_hosts, self.acls)
734
735            if not self.locked:
736                for host in successful_hosts:
737                    self.execute_rpc('modify_host', id=host, locked=False,
738                                     lock_reason='')
739        return successful_hosts
740
741
742    def output(self, hosts):
743        """Print output of 'atest host create'.
744
745        @param hosts: the added host list to be printed.
746        """
747        self.print_wrapped('Added host', hosts)
748
749
750class host_delete(action_common.atest_delete, host):
751    """atest host delete [--mlist <mach_file>] <hosts>"""
752    pass
753