• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright (c) 2013 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5
6import grp
7import httplib
8import json
9import logging
10import os
11import random
12import re
13import time
14import urllib2
15
16import common
17from autotest_lib.client.common_lib import base_utils
18from autotest_lib.client.common_lib import error
19from autotest_lib.client.common_lib import global_config
20from autotest_lib.client.common_lib import host_queue_entry_states
21from autotest_lib.server.cros.dynamic_suite import constants
22from autotest_lib.server.cros.dynamic_suite import job_status
23
24try:
25    from chromite.lib import cros_build_lib
26except ImportError:
27    logging.warn('Unable to import chromite.')
28    # Init the module variable to None. Access to this module can check if it
29    # is not None before making calls.
30    cros_build_lib = None
31
32
33_SHERIFF_JS = global_config.global_config.get_config_value(
34    'NOTIFICATIONS', 'sheriffs', default='')
35_LAB_SHERIFF_JS = global_config.global_config.get_config_value(
36    'NOTIFICATIONS', 'lab_sheriffs', default='')
37_CHROMIUM_BUILD_URL = global_config.global_config.get_config_value(
38    'NOTIFICATIONS', 'chromium_build_url', default='')
39
40LAB_GOOD_STATES = ('open', 'throttled')
41
42
43class TestLabException(Exception):
44    """Exception raised when the Test Lab blocks a test or suite."""
45    pass
46
47
48class ParseBuildNameException(Exception):
49    """Raised when ParseBuildName() cannot parse a build name."""
50    pass
51
52
53class Singleton(type):
54    """Enforce that only one client class is instantiated per process."""
55    _instances = {}
56
57    def __call__(cls, *args, **kwargs):
58        """Fetch the instance of a class to use for subsequent calls."""
59        if cls not in cls._instances:
60            cls._instances[cls] = super(Singleton, cls).__call__(
61                    *args, **kwargs)
62        return cls._instances[cls]
63
64
65def ParseBuildName(name):
66    """Format a build name, given board, type, milestone, and manifest num.
67
68    @param name: a build name, e.g. 'x86-alex-release/R20-2015.0.0' or a
69                 relative build name, e.g. 'x86-alex-release/LATEST'
70
71    @return board: board the manifest is for, e.g. x86-alex.
72    @return type: one of 'release', 'factory', or 'firmware'
73    @return milestone: (numeric) milestone the manifest was associated with.
74                        Will be None for relative build names.
75    @return manifest: manifest number, e.g. '2015.0.0'.
76                      Will be None for relative build names.
77
78    """
79    match = re.match(r'(trybot-)?(?P<board>[\w-]+)-(?P<type>\w+)/'
80                     r'(R(?P<milestone>\d+)-(?P<manifest>[\d.ab-]+)|LATEST)',
81                     name)
82    if match and len(match.groups()) >= 5:
83        return (match.group('board'), match.group('type'),
84                match.group('milestone'), match.group('manifest'))
85    raise ParseBuildNameException('%s is a malformed build name.' % name)
86
87
88def get_labels_from_afe(hostname, label_prefix, afe):
89    """Retrieve a host's specific labels from the AFE.
90
91    Looks for the host labels that have the form <label_prefix>:<value>
92    and returns the "<value>" part of the label. None is returned
93    if there is not a label matching the pattern
94
95    @param hostname: hostname of given DUT.
96    @param label_prefix: prefix of label to be matched, e.g., |board:|
97    @param afe: afe instance.
98
99    @returns A list of labels that match the prefix or 'None'
100
101    """
102    labels = afe.get_labels(name__startswith=label_prefix,
103                            host__hostname__in=[hostname])
104    if labels:
105        return [l.name.split(label_prefix, 1)[1] for l in labels]
106
107
108def get_label_from_afe(hostname, label_prefix, afe):
109    """Retrieve a host's specific label from the AFE.
110
111    Looks for a host label that has the form <label_prefix>:<value>
112    and returns the "<value>" part of the label. None is returned
113    if there is not a label matching the pattern
114
115    @param hostname: hostname of given DUT.
116    @param label_prefix: prefix of label to be matched, e.g., |board:|
117    @param afe: afe instance.
118    @returns the label that matches the prefix or 'None'
119
120    """
121    labels = get_labels_from_afe(hostname, label_prefix, afe)
122    if labels and len(labels) == 1:
123        return labels[0]
124
125
126def get_board_from_afe(hostname, afe):
127    """Retrieve given host's board from its labels in the AFE.
128
129    Looks for a host label of the form "board:<board>", and
130    returns the "<board>" part of the label.  `None` is returned
131    if there is not a single, unique label matching the pattern.
132
133    @param hostname: hostname of given DUT.
134    @param afe: afe instance.
135    @returns board from label, or `None`.
136
137    """
138    return get_label_from_afe(hostname, constants.BOARD_PREFIX, afe)
139
140
141def get_build_from_afe(hostname, afe):
142    """Retrieve the current build for given host from the AFE.
143
144    Looks through the host's labels in the AFE to determine its build.
145
146    @param hostname: hostname of given DUT.
147    @param afe: afe instance.
148    @returns The current build or None if it could not find it or if there
149             were multiple build labels assigned to this host.
150
151    """
152    return get_label_from_afe(hostname, constants.VERSION_PREFIX, afe)
153
154
155def get_sheriffs(lab_only=False):
156    """
157    Polls the javascript file that holds the identity of the sheriff and
158    parses it's output to return a list of chromium sheriff email addresses.
159    The javascript file can contain the ldap of more than one sheriff, eg:
160    document.write('sheriff_one, sheriff_two').
161
162    @param lab_only: if True, only pulls lab sheriff.
163    @return: A list of chroium.org sheriff email addresses to cc on the bug.
164             An empty list if failed to parse the javascript.
165    """
166    sheriff_ids = []
167    sheriff_js_list = _LAB_SHERIFF_JS.split(',')
168    if not lab_only:
169        sheriff_js_list.extend(_SHERIFF_JS.split(','))
170
171    for sheriff_js in sheriff_js_list:
172        try:
173            url_content = base_utils.urlopen('%s%s'% (
174                _CHROMIUM_BUILD_URL, sheriff_js)).read()
175        except (ValueError, IOError) as e:
176            logging.warning('could not parse sheriff from url %s%s: %s',
177                             _CHROMIUM_BUILD_URL, sheriff_js, str(e))
178        except (urllib2.URLError, httplib.HTTPException) as e:
179            logging.warning('unexpected error reading from url "%s%s": %s',
180                             _CHROMIUM_BUILD_URL, sheriff_js, str(e))
181        else:
182            ldaps = re.search(r"document.write\('(.*)'\)", url_content)
183            if not ldaps:
184                logging.warning('Could not retrieve sheriff ldaps for: %s',
185                                 url_content)
186                continue
187            sheriff_ids += ['%s@chromium.org' % alias.replace(' ', '')
188                            for alias in ldaps.group(1).split(',')]
189    return sheriff_ids
190
191
192def remote_wget(source_url, dest_path, ssh_cmd):
193    """wget source_url from localhost to dest_path on remote host using ssh.
194
195    @param source_url: The complete url of the source of the package to send.
196    @param dest_path: The path on the remote host's file system where we would
197        like to store the package.
198    @param ssh_cmd: The ssh command to use in performing the remote wget.
199    """
200    wget_cmd = ("wget -O - %s | %s 'cat >%s'" %
201                (source_url, ssh_cmd, dest_path))
202    base_utils.run(wget_cmd)
203
204
205_MAX_LAB_STATUS_ATTEMPTS = 5
206def _get_lab_status(status_url):
207    """Grabs the current lab status and message.
208
209    @returns The JSON object obtained from the given URL.
210
211    """
212    retry_waittime = 1
213    for _ in range(_MAX_LAB_STATUS_ATTEMPTS):
214        try:
215            response = urllib2.urlopen(status_url)
216        except IOError as e:
217            logging.debug('Error occurred when grabbing the lab status: %s.',
218                          e)
219            time.sleep(retry_waittime)
220            continue
221        # Check for successful response code.
222        if response.getcode() == 200:
223            return json.load(response)
224        time.sleep(retry_waittime)
225    return None
226
227
228def _decode_lab_status(lab_status, build):
229    """Decode lab status, and report exceptions as needed.
230
231    Take a deserialized JSON object from the lab status page, and
232    interpret it to determine the actual lab status.  Raise
233    exceptions as required to report when the lab is down.
234
235    @param build: build name that we want to check the status of.
236
237    @raises TestLabException Raised if a request to test for the given
238                             status and build should be blocked.
239    """
240    # First check if the lab is up.
241    if not lab_status['general_state'] in LAB_GOOD_STATES:
242        raise TestLabException('Chromium OS Test Lab is closed: '
243                               '%s.' % lab_status['message'])
244
245    # Check if the build we wish to use is disabled.
246    # Lab messages should be in the format of:
247    #    Lab is 'status' [regex ...] (comment)
248    # If the build name matches any regex, it will be blocked.
249    build_exceptions = re.search('\[(.*)\]', lab_status['message'])
250    if not build_exceptions or not build:
251        return
252    for build_pattern in build_exceptions.group(1).split():
253        if re.match(build_pattern, build):
254            raise TestLabException('Chromium OS Test Lab is closed: '
255                                   '%s matches %s.' % (
256                                           build, build_pattern))
257    return
258
259
260def is_in_lab():
261    """Check if current Autotest instance is in lab
262
263    @return: True if the Autotest instance is in lab.
264    """
265    test_server_name = global_config.global_config.get_config_value(
266              'SERVER', 'hostname')
267    return test_server_name.startswith('cautotest')
268
269
270def check_lab_status(build):
271    """Check if the lab status allows us to schedule for a build.
272
273    Checks if the lab is down, or if testing for the requested build
274    should be blocked.
275
276    @param build: Name of the build to be scheduled for testing.
277
278    @raises TestLabException Raised if a request to test for the given
279                             status and build should be blocked.
280
281    """
282    # Ensure we are trying to schedule on the actual lab.
283    if not is_in_lab():
284        return
285
286    # Download the lab status from its home on the web.
287    status_url = global_config.global_config.get_config_value(
288            'CROS', 'lab_status_url')
289    json_status = _get_lab_status(status_url)
290    if json_status is None:
291        # We go ahead and say the lab is open if we can't get the status.
292        logging.warning('Could not get a status from %s', status_url)
293        return
294    _decode_lab_status(json_status, build)
295
296
297def lock_host_with_labels(afe, lock_manager, labels):
298    """Lookup and lock one host that matches the list of input labels.
299
300    @param afe: An instance of the afe class, as defined in server.frontend.
301    @param lock_manager: A lock manager capable of locking hosts, eg the
302        one defined in server.cros.host_lock_manager.
303    @param labels: A list of labels to look for on hosts.
304
305    @return: The hostname of a host matching all labels, and locked through the
306        lock_manager. The hostname will be as specified in the database the afe
307        object is associated with, i.e if it exists in afe_hosts with a .cros
308        suffix, the hostname returned will contain a .cros suffix.
309
310    @raises: error.NoEligibleHostException: If no hosts matching the list of
311        input labels are available.
312    @raises: error.TestError: If unable to lock a host matching the labels.
313    """
314    potential_hosts = afe.get_hosts(multiple_labels=labels)
315    if not potential_hosts:
316        raise error.NoEligibleHostException(
317                'No devices found with labels %s.' % labels)
318
319    # This prevents errors where a fault might seem repeatable
320    # because we lock, say, the same packet capturer for each test run.
321    random.shuffle(potential_hosts)
322    for host in potential_hosts:
323        if lock_manager.lock([host.hostname]):
324            logging.info('Locked device %s with labels %s.',
325                         host.hostname, labels)
326            return host.hostname
327        else:
328            logging.info('Unable to lock device %s with labels %s.',
329                         host.hostname, labels)
330
331    raise error.TestError('Could not lock a device with labels %s' % labels)
332
333
334def get_test_views_from_tko(suite_job_id, tko):
335    """Get test name and result for given suite job ID.
336
337    @param suite_job_id: ID of suite job.
338    @param tko: an instance of TKO as defined in server/frontend.py.
339    @return: A dictionary of test status keyed by test name, e.g.,
340             {'dummy_Fail.Error': 'ERROR', 'dummy_Fail.NAError': 'TEST_NA'}
341    @raise: Exception when there is no test view found.
342
343    """
344    views = tko.run('get_detailed_test_views', afe_job_id=suite_job_id)
345    relevant_views = filter(job_status.view_is_relevant, views)
346    if not relevant_views:
347        raise Exception('Failed to retrieve job results.')
348
349    test_views = {}
350    for view in relevant_views:
351        test_views[view['test_name']] = view['status']
352
353    return test_views
354
355
356def parse_simple_config(config_file):
357    """Get paths by parsing a simple config file.
358
359    Each line of the config file is a path for a file or directory.
360    Ignore an empty line and a line starting with a hash character ('#').
361    One example of this kind of simple config file is
362    client/common_lib/logs_to_collect.
363
364    @param config_file: Config file path
365    @return: A list of directory strings
366    """
367    dirs = []
368    for l in open(config_file):
369        l = l.strip()
370        if l and not l.startswith('#'):
371            dirs.append(l)
372    return dirs
373
374
375def concat_path_except_last(base, sub):
376    """Concatenate two paths but exclude last entry.
377
378    Take two paths as parameters and return a path string in which
379    the second path becomes under the first path.
380    In addition, remove the last path entry from the concatenated path.
381    This works even when two paths are absolute paths.
382
383    e.g., /usr/local/autotest/results/ + /var/log/ =
384    /usr/local/autotest/results/var
385
386    e.g., /usr/local/autotest/results/ + /var/log/syslog =
387    /usr/local/autotest/results/var/log
388
389    @param base: Beginning path
390    @param sub: The path that is concatenated to base
391    @return: Concatenated path string
392    """
393    dirname = os.path.dirname(sub.rstrip('/'))
394    return os.path.join(base, dirname.strip('/'))
395
396
397def get_data_key(prefix, suite, build, board):
398    """
399    Constructs a key string from parameters.
400
401    @param prefix: Prefix for the generating key.
402    @param suite: a suite name. e.g., bvt-cq, bvt-inline, dummy
403    @param build: The build string. This string should have a consistent
404        format eg: x86-mario-release/R26-3570.0.0. If the format of this
405        string changes such that we can't determine build_type or branch
406        we give up and use the parametes we're sure of instead (suite,
407        board). eg:
408            1. build = x86-alex-pgo-release/R26-3570.0.0
409               branch = 26
410               build_type = pgo-release
411            2. build = lumpy-paladin/R28-3993.0.0-rc5
412               branch = 28
413               build_type = paladin
414    @param board: The board that this suite ran on.
415    @return: The key string used for a dictionary.
416    """
417    try:
418        _board, build_type, branch = ParseBuildName(build)[:3]
419    except ParseBuildNameException as e:
420        logging.error(str(e))
421        branch = 'Unknown'
422        build_type = 'Unknown'
423    else:
424        embedded_str = re.search(r'x86-\w+-(.*)', _board)
425        if embedded_str:
426            build_type = embedded_str.group(1) + '-' + build_type
427
428    data_key_dict = {
429        'prefix': prefix,
430        'board': board,
431        'branch': branch,
432        'build_type': build_type,
433        'suite': suite,
434    }
435    return ('%(prefix)s.%(board)s.%(build_type)s.%(branch)s.%(suite)s'
436            % data_key_dict)
437
438
439def setup_logging(logfile=None, prefix=False):
440    """Setup basic logging with all logging info stripped.
441
442    Calls to logging will only show the message. No severity is logged.
443
444    @param logfile: If specified dump output to a file as well.
445    @param prefix: Flag for log prefix. Set to True to add prefix to log
446        entries to include timestamp and log level. Default is False.
447    """
448    # Remove all existing handlers. client/common_lib/logging_config adds
449    # a StreamHandler to logger when modules are imported, e.g.,
450    # autotest_lib.client.bin.utils. A new StreamHandler will be added here to
451    # log only messages, not severity.
452    logging.getLogger().handlers = []
453
454    if prefix:
455        log_format = '%(asctime)s %(levelname)-5s| %(message)s'
456    else:
457        log_format = '%(message)s'
458
459    screen_handler = logging.StreamHandler()
460    screen_handler.setFormatter(logging.Formatter(log_format))
461    logging.getLogger().addHandler(screen_handler)
462    logging.getLogger().setLevel(logging.INFO)
463    if logfile:
464        file_handler = logging.FileHandler(logfile)
465        file_handler.setFormatter(logging.Formatter(log_format))
466        file_handler.setLevel(logging.DEBUG)
467        logging.getLogger().addHandler(file_handler)
468
469
470def is_shard():
471    """Determines if this instance is running as a shard.
472
473    Reads the global_config value shard_hostname in the section SHARD.
474
475    @return True, if shard_hostname is set, False otherwise.
476    """
477    hostname = global_config.global_config.get_config_value(
478            'SHARD', 'shard_hostname', default=None)
479    return bool(hostname)
480
481
482def get_global_afe_hostname():
483    """Read the hostname of the global AFE from the global configuration."""
484    return global_config.global_config.get_config_value(
485            'SERVER', 'global_afe_hostname')
486
487
488def is_restricted_user(username):
489    """Determines if a user is in a restricted group.
490
491    User in restricted group only have access to master.
492
493    @param username: A string, representing a username.
494
495    @returns: True if the user is in a restricted group.
496    """
497    if not username:
498        return False
499
500    restricted_groups = global_config.global_config.get_config_value(
501            'AUTOTEST_WEB', 'restricted_groups', default='').split(',')
502    for group in restricted_groups:
503        if group and username in grp.getgrnam(group).gr_mem:
504            return True
505    return False
506
507
508def get_special_task_status(is_complete, success, is_active):
509    """Get the status of a special task.
510
511    Emulate a host queue entry status for a special task
512    Although SpecialTasks are not HostQueueEntries, it is helpful to
513    the user to present similar statuses.
514
515    @param is_complete    Boolean if the task is completed.
516    @param success        Boolean if the task succeeded.
517    @param is_active      Boolean if the task is active.
518
519    @return The status of a special task.
520    """
521    if is_complete:
522        if success:
523            return host_queue_entry_states.Status.COMPLETED
524        return host_queue_entry_states.Status.FAILED
525    if is_active:
526        return host_queue_entry_states.Status.RUNNING
527    return host_queue_entry_states.Status.QUEUED
528
529
530def get_special_task_exec_path(hostname, task_id, task_name, time_requested):
531    """Get the execution path of the SpecialTask.
532
533    This method returns different paths depending on where a
534    the task ran:
535        * Master: hosts/hostname/task_id-task_type
536        * Shard: Master_path/time_created
537    This is to work around the fact that a shard can fail independent
538    of the master, and be replaced by another shard that has the same
539    hosts. Without the time_created stamp the logs of the tasks running
540    on the second shard will clobber the logs from the first in google
541    storage, because task ids are not globally unique.
542
543    @param hostname        Hostname
544    @param task_id         Special task id
545    @param task_name       Special task name (e.g., Verify, Repair, etc)
546    @param time_requested  Special task requested time.
547
548    @return An execution path for the task.
549    """
550    results_path = 'hosts/%s/%s-%s' % (hostname, task_id, task_name.lower())
551
552    # If we do this on the master it will break backward compatibility,
553    # as there are tasks that currently don't have timestamps. If a host
554    # or job has been sent to a shard, the rpc for that host/job will
555    # be redirected to the shard, so this global_config check will happen
556    # on the shard the logs are on.
557    if not is_shard():
558        return results_path
559
560    # Generate a uid to disambiguate special task result directories
561    # in case this shard fails. The simplest uid is the job_id, however
562    # in rare cases tasks do not have jobs associated with them (eg:
563    # frontend verify), so just use the creation timestamp. The clocks
564    # between a shard and master should always be in sync. Any discrepancies
565    # will be brought to our attention in the form of job timeouts.
566    uid = time_requested.strftime('%Y%d%m%H%M%S')
567
568    # TODO: This is a hack, however it is the easiest way to achieve
569    # correctness. There is currently some debate over the future of
570    # tasks in our infrastructure and refactoring everything right
571    # now isn't worth the time.
572    return '%s/%s' % (results_path, uid)
573
574
575def get_job_tag(id, owner):
576    """Returns a string tag for a job.
577
578    @param id    Job id
579    @param owner Job owner
580
581    """
582    return '%s-%s' % (id, owner)
583
584
585def get_hqe_exec_path(tag, execution_subdir):
586    """Returns a execution path to a HQE's results.
587
588    @param tag               Tag string for a job associated with a HQE.
589    @param execution_subdir  Execution sub-directory string of a HQE.
590
591    """
592    return os.path.join(tag, execution_subdir)
593
594
595def is_inside_chroot():
596    """Check if the process is running inside chroot.
597
598    This is a wrapper around chromite.lib.cros_build_lib.IsInsideChroot(). The
599    method checks if cros_build_lib can be imported first.
600
601    @return: True if the process is running inside chroot or cros_build_lib
602             cannot be imported.
603
604    """
605    return not cros_build_lib or cros_build_lib.IsInsideChroot()
606
607
608def parse_job_name(name):
609    """Parse job name to get information including build, board and suite etc.
610
611    Suite job created by run_suite follows the naming convention of:
612    [build]-test_suites/control.[suite]
613    For example: lumpy-release/R46-7272.0.0-test_suites/control.bvt
614    The naming convention is defined in site_rpc_interface.create_suite_job.
615
616    Test job created by suite job follows the naming convention of:
617    [build]/[suite]/[test name]
618    For example: lumpy-release/R46-7272.0.0/bvt/login_LoginSuccess
619    The naming convention is defined in
620    server/cros/dynamic_suite/tools.create_job_name
621
622    Note that pgo and chrome-perf builds will fail the method. Since lab does
623    not run test for these builds, they can be ignored.
624
625    @param name: Name of the job.
626
627    @return: A dictionary containing the test information. The keyvals include:
628             build: Name of the build, e.g., lumpy-release/R46-7272.0.0
629             build_version: The version of the build, e.g., R46-7272.0.0
630             board: Name of the board, e.g., lumpy
631             suite: Name of the test suite, e.g., bvt
632
633    """
634    info = {}
635    suite_job_regex = '([^/]*/[^/]*)-test_suites/control\.(.*)'
636    test_job_regex = '([^/]*/[^/]*)/([^/]+)/.*'
637    match = re.match(suite_job_regex, name)
638    if not match:
639        match = re.match(test_job_regex, name)
640    if match:
641        info['build'] = match.groups()[0]
642        info['suite'] = match.groups()[1]
643        info['build_version'] = info['build'].split('/')[1]
644        try:
645            info['board'], _, _, _ = ParseBuildName(info['build'])
646        except ParseBuildNameException:
647            pass
648    return info
649
650
651def add_label_detector(label_function_list, label_list=None, label=None):
652    """Decorator used to group functions together into the provided list.
653
654    This is a helper function to automatically add label functions that have
655    the label decorator.  This is to help populate the class list of label
656    functions to be retrieved by the get_labels class method.
657
658    @param label_function_list: List of label detecting functions to add
659                                decorated function to.
660    @param label_list: List of detectable labels to add detectable labels to.
661                       (Default: None)
662    @param label: Label string that is detectable by this detection function
663                  (Default: None)
664    """
665    def add_func(func):
666        """
667        @param func: The function to be added as a detector.
668        """
669        label_function_list.append(func)
670        if label and label_list is not None:
671            label_list.append(label)
672        return func
673    return add_func
674
675
676def verify_not_root_user():
677    """Simple function to error out if running with uid == 0"""
678    if os.getuid() == 0:
679        raise error.IllegalUser('This script can not be ran as root.')
680
681
682def get_hostname_from_machine(machine):
683    """Lookup hostname from a machine string or dict.
684
685    @returns: Machine hostname in string format.
686    """
687    hostname, _ = get_host_info_from_machine(machine)
688    return hostname
689
690
691def get_host_info_from_machine(machine):
692    """Lookup host information from a machine string or dict.
693
694    @returns: Tuple of (hostname, host_attributes)
695    """
696    if isinstance(machine, dict):
697        return (machine['hostname'], machine['host_attributes'])
698    else:
699        return (machine, {})
700
701
702def get_creds_abspath(creds_file):
703    """Returns the abspath of the credentials file.
704
705    If creds_file is already an absolute path, just return it.
706    Otherwise, assume it is located in the creds directory
707    specified in global_config and return the absolute path.
708
709    @param: creds_path, a path to the credentials.
710    @return: An absolute path to the credentials file.
711    """
712    if not creds_file:
713        return None
714    if os.path.isabs(creds_file):
715        return creds_file
716    creds_dir = global_config.global_config.get_config_value(
717            'SERVER', 'creds_dir', default='')
718    if not creds_dir or not os.path.exists(creds_dir):
719        creds_dir = common.autotest_dir
720    return os.path.join(creds_dir, creds_file)
721
722
723def machine_is_testbed(machine):
724    """Checks if the machine is a testbed.
725
726    The signal we use to determine if the machine is a testbed
727    is if the host attributes contain more than 1 serial.
728
729    @param machine: is a list of dicts
730
731    @return: True if the machine is a testbed, False otherwise.
732    """
733    _, attributes = get_host_info_from_machine(machine)
734    if len(attributes.get('serials', '').split(',')) > 1:
735        return True
736    return False
737