• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright (c) 2016 The Chromium OS 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"""
6This module includes all moblab-related RPCs. These RPCs can only be run
7on moblab.
8"""
9
10import ConfigParser
11import common
12import logging
13import os
14import re
15import sys
16import shutil
17import socket
18import StringIO
19import subprocess
20import time
21import multiprocessing
22import ctypes
23
24from autotest_lib.client.common_lib import error
25from autotest_lib.client.common_lib import global_config
26from autotest_lib.client.common_lib import utils
27from autotest_lib.frontend.afe import models
28from autotest_lib.frontend.afe import rpc_utils
29from autotest_lib.server import frontend
30from autotest_lib.server.hosts import moblab_host
31from chromite.lib import gs
32
33_CONFIG = global_config.global_config
34MOBLAB_BOTO_LOCATION = '/home/moblab/.boto'
35CROS_CACHEDIR = '/mnt/moblab/cros_cache_apache'
36
37# Google Cloud Storage bucket url regex pattern. The pattern is used to extract
38# the bucket name from the bucket URL. For example, "gs://image_bucket/google"
39# should result in a bucket name "image_bucket".
40GOOGLE_STORAGE_BUCKET_URL_PATTERN = re.compile(
41        r'gs://(?P<bucket>[a-zA-Z][a-zA-Z0-9-_]*)/?.*')
42
43# Contants used in Json RPC field names.
44_IMAGE_STORAGE_SERVER = 'image_storage_server'
45_GS_ACCESS_KEY_ID = 'gs_access_key_id'
46_GS_SECRET_ACCESS_KEY = 'gs_secret_access_key'
47_RESULT_STORAGE_SERVER = 'results_storage_server'
48_USE_EXISTING_BOTO_FILE = 'use_existing_boto_file'
49_CLOUD_NOTIFICATION_ENABLED = 'cloud_notification_enabled'
50_WIFI_AP_NAME = 'wifi_dut_ap_name'
51_WIFI_AP_PASS = 'wifi_dut_ap_pass'
52
53# Location where dhcp leases are stored.
54_DHCPD_LEASES = '/var/lib/dhcp/dhcpd.leases'
55
56# File where information about the current device is stored.
57_ETC_LSB_RELEASE = '/etc/lsb-release'
58
59# ChromeOS update engine client binary location
60_UPDATE_ENGINE_CLIENT = '/usr/bin/update_engine_client'
61
62# Set the suite timeout per suite in minutes
63# default is 24 hours
64_DEFAULT_SUITE_TIMEOUT_MINS = 1440
65_SUITE_TIMEOUT_MAP = {
66    'hardware_storagequal': 40320,
67    'hardware_storagequal_quick': 40320
68}
69
70# Full path to the correct gsutil command to run.
71class GsUtil:
72    """Helper class to find correct gsutil command."""
73    _GSUTIL_CMD = None
74
75    @classmethod
76    def get_gsutil_cmd(cls):
77      if not cls._GSUTIL_CMD:
78         cls._GSUTIL_CMD = gs.GSContext.GetDefaultGSUtilBin(
79           cache_dir=CROS_CACHEDIR)
80
81      return cls._GSUTIL_CMD
82
83
84class BucketPerformanceTestException(Exception):
85  """Exception thrown when the command to test the bucket performance fails."""
86  pass
87
88@rpc_utils.moblab_only
89def get_config_values():
90    """Returns all config values parsed from global and shadow configs.
91
92    Config values are grouped by sections, and each section is composed of
93    a list of name value pairs.
94    """
95    sections =_CONFIG.get_sections()
96    config_values = {}
97    for section in sections:
98        config_values[section] = _CONFIG.config.items(section)
99    return rpc_utils.prepare_for_serialization(config_values)
100
101
102def _write_config_file(config_file, config_values, overwrite=False):
103    """Writes out a configuration file.
104
105    @param config_file: The name of the configuration file.
106    @param config_values: The ConfigParser object.
107    @param ovewrite: Flag on if overwriting is allowed.
108    """
109    if not config_file:
110        raise error.RPCException('Empty config file name.')
111    if not overwrite and os.path.exists(config_file):
112        raise error.RPCException('Config file already exists.')
113
114    if config_values:
115        with open(config_file, 'w') as config_file:
116            config_values.write(config_file)
117
118
119def _read_original_config():
120    """Reads the orginal configuratino without shadow.
121
122    @return: A configuration object, see global_config_class.
123    """
124    original_config = global_config.global_config_class()
125    original_config.set_config_files(shadow_file='')
126    return original_config
127
128
129def _read_raw_config(config_file):
130    """Reads the raw configuration from a configuration file.
131
132    @param: config_file: The path of the configuration file.
133
134    @return: A ConfigParser object.
135    """
136    shadow_config = ConfigParser.RawConfigParser()
137    shadow_config.read(config_file)
138    return shadow_config
139
140
141def _get_shadow_config_from_partial_update(config_values):
142    """Finds out the new shadow configuration based on a partial update.
143
144    Since the input is only a partial config, we should not lose the config
145    data inside the existing shadow config file. We also need to distinguish
146    if the input config info overrides with a new value or reverts back to
147    an original value.
148
149    @param config_values: See get_moblab_settings().
150
151    @return: The new shadow configuration as ConfigParser object.
152    """
153    original_config = _read_original_config()
154    existing_shadow = _read_raw_config(_CONFIG.shadow_file)
155    for section, config_value_list in config_values.iteritems():
156        for key, value in config_value_list:
157            if original_config.get_config_value(section, key,
158                                                default='',
159                                                allow_blank=True) != value:
160                if not existing_shadow.has_section(section):
161                    existing_shadow.add_section(section)
162                existing_shadow.set(section, key, value)
163            elif existing_shadow.has_option(section, key):
164                existing_shadow.remove_option(section, key)
165    return existing_shadow
166
167
168def _update_partial_config(config_values):
169    """Updates the shadow configuration file with a partial config udpate.
170
171    @param config_values: See get_moblab_settings().
172    """
173    existing_config = _get_shadow_config_from_partial_update(config_values)
174    _write_config_file(_CONFIG.shadow_file, existing_config, True)
175
176
177@rpc_utils.moblab_only
178def update_config_handler(config_values):
179    """Update config values and override shadow config.
180
181    @param config_values: See get_moblab_settings().
182    """
183    original_config = _read_original_config()
184    new_shadow = ConfigParser.RawConfigParser()
185    for section, config_value_list in config_values.iteritems():
186        for key, value in config_value_list:
187            if original_config.get_config_value(section, key,
188                                                default='',
189                                                allow_blank=True) != value:
190                if not new_shadow.has_section(section):
191                    new_shadow.add_section(section)
192                new_shadow.set(section, key, value)
193
194    if not _CONFIG.shadow_file or not os.path.exists(_CONFIG.shadow_file):
195        raise error.RPCException('Shadow config file does not exist.')
196    _write_config_file(_CONFIG.shadow_file, new_shadow, True)
197
198    # TODO (sbasi) crbug.com/403916 - Remove the reboot command and
199    # instead restart the services that rely on the config values.
200    os.system('sudo reboot')
201
202
203@rpc_utils.moblab_only
204def reset_config_settings():
205    """Reset moblab shadow config."""
206    with open(_CONFIG.shadow_file, 'w') as config_file:
207        pass
208    os.system('sudo reboot')
209
210
211@rpc_utils.moblab_only
212def reboot_moblab():
213    """Simply reboot the device."""
214    os.system('sudo reboot')
215
216
217@rpc_utils.moblab_only
218def set_boto_key(boto_key):
219    """Update the boto_key file.
220
221    @param boto_key: File name of boto_key uploaded through handle_file_upload.
222    """
223    if not os.path.exists(boto_key):
224        raise error.RPCException('Boto key: %s does not exist!' % boto_key)
225    shutil.copyfile(boto_key, moblab_host.MOBLAB_BOTO_LOCATION)
226
227
228@rpc_utils.moblab_only
229def set_service_account_credential(service_account_filename):
230    """Update the service account credential file.
231
232    @param service_account_filename: Name of uploaded file through
233            handle_file_upload.
234    """
235    if not os.path.exists(service_account_filename):
236        raise error.RPCException(
237                'Service account file: %s does not exist!' %
238                service_account_filename)
239    shutil.copyfile(
240            service_account_filename,
241            moblab_host.MOBLAB_SERVICE_ACCOUNT_LOCATION)
242
243
244@rpc_utils.moblab_only
245def set_launch_control_key(launch_control_key):
246    """Update the launch_control_key file.
247
248    @param launch_control_key: File name of launch_control_key uploaded through
249            handle_file_upload.
250    """
251    if not os.path.exists(launch_control_key):
252        raise error.RPCException('Launch Control key: %s does not exist!' %
253                                 launch_control_key)
254    shutil.copyfile(launch_control_key,
255                    moblab_host.MOBLAB_LAUNCH_CONTROL_KEY_LOCATION)
256    # Restart the devserver service.
257    os.system('sudo restart moblab-devserver-init')
258
259
260###########Moblab Config Wizard RPCs #######################
261def _get_public_ip_address(socket_handle):
262    """Gets the public IP address.
263
264    Connects to Google DNS server using a socket and gets the preferred IP
265    address from the connection.
266
267    @param: socket_handle: a unix socket.
268
269    @return: public ip address as string.
270    """
271    try:
272        socket_handle.settimeout(1)
273        socket_handle.connect(('8.8.8.8', 53))
274        socket_name = socket_handle.getsockname()
275        if socket_name is not None:
276            logging.info('Got socket name from UDP socket.')
277            return socket_name[0]
278        logging.warn('Created UDP socket but with no socket_name.')
279    except socket.error:
280        logging.warn('Could not get socket name from UDP socket.')
281    return None
282
283
284def _get_network_info():
285    """Gets the network information.
286
287    TCP socket is used to test the connectivity. If there is no connectivity,
288    try to get the public IP with UDP socket.
289
290    @return: a tuple as (public_ip_address, connected_to_internet).
291    """
292    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
293    ip = _get_public_ip_address(s)
294    if ip is not None:
295        logging.info('Established TCP connection with well known server.')
296        return (ip, True)
297    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
298    return (_get_public_ip_address(s), False)
299
300
301@rpc_utils.moblab_only
302def get_network_info():
303    """Returns the server ip addresses, and if the server connectivity.
304
305    The server ip addresses as an array of strings, and the connectivity as a
306    flag.
307    """
308    network_info = {}
309    info = _get_network_info()
310    if info[0] is not None:
311        network_info['server_ips'] = [info[0]]
312    network_info['is_connected'] = info[1]
313
314    return rpc_utils.prepare_for_serialization(network_info)
315
316
317# Gets the boto configuration.
318def _get_boto_config():
319    """Reads the boto configuration from the boto file.
320
321    @return: Boto configuration as ConfigParser object.
322    """
323    boto_config = ConfigParser.ConfigParser()
324    boto_config.read(MOBLAB_BOTO_LOCATION)
325    return boto_config
326
327
328@rpc_utils.moblab_only
329def get_cloud_storage_info():
330    """RPC handler to get the cloud storage access information.
331    """
332    cloud_storage_info = {}
333    value =_CONFIG.get_config_value('CROS', _IMAGE_STORAGE_SERVER)
334    if value is not None:
335        cloud_storage_info[_IMAGE_STORAGE_SERVER] = value
336    value = _CONFIG.get_config_value('CROS', _RESULT_STORAGE_SERVER,
337            default=None)
338    if value is not None:
339        cloud_storage_info[_RESULT_STORAGE_SERVER] = value
340
341    boto_config = _get_boto_config()
342    sections = boto_config.sections()
343
344    if sections:
345        cloud_storage_info[_USE_EXISTING_BOTO_FILE] = True
346    else:
347        cloud_storage_info[_USE_EXISTING_BOTO_FILE] = False
348    if 'Credentials' in sections:
349        options = boto_config.options('Credentials')
350        if _GS_ACCESS_KEY_ID in options:
351            value = boto_config.get('Credentials', _GS_ACCESS_KEY_ID)
352            cloud_storage_info[_GS_ACCESS_KEY_ID] = value
353        if _GS_SECRET_ACCESS_KEY in options:
354            value = boto_config.get('Credentials', _GS_SECRET_ACCESS_KEY)
355            cloud_storage_info[_GS_SECRET_ACCESS_KEY] = value
356
357    return rpc_utils.prepare_for_serialization(cloud_storage_info)
358
359
360def _get_bucket_name_from_url(bucket_url):
361    """Gets the bucket name from a bucket url.
362
363    @param: bucket_url: the bucket url string.
364    """
365    if bucket_url:
366        match = GOOGLE_STORAGE_BUCKET_URL_PATTERN.match(bucket_url)
367        if match:
368            return match.group('bucket')
369    return None
370
371
372def _is_valid_boto_key(key_id, key_secret, directory):
373  try:
374      _run_bucket_performance_test(key_id, key_secret, directory)
375  except BucketPerformanceTestException as e:
376       return(False, str(e))
377  return(True, None)
378
379
380def _validate_cloud_storage_info(cloud_storage_info):
381    """Checks if the cloud storage information is valid.
382
383    @param: cloud_storage_info: The JSON RPC object for cloud storage info.
384
385    @return: A tuple as (valid_boolean, details_string).
386    """
387    valid = True
388    details = None
389    if not cloud_storage_info[_USE_EXISTING_BOTO_FILE]:
390        key_id = cloud_storage_info[_GS_ACCESS_KEY_ID]
391        key_secret = cloud_storage_info[_GS_SECRET_ACCESS_KEY]
392        valid, details = _is_valid_boto_key(
393            key_id, key_secret, cloud_storage_info[_IMAGE_STORAGE_SERVER])
394    return (valid, details)
395
396
397def _create_operation_status_response(is_ok, details):
398    """Helper method to create a operation status reponse.
399
400    @param: is_ok: Boolean for if the operation is ok.
401    @param: details: A detailed string.
402
403    @return: A serialized JSON RPC object.
404    """
405    status_response = {'status_ok': is_ok}
406    if details:
407        status_response['status_details'] = details
408    return rpc_utils.prepare_for_serialization(status_response)
409
410
411@rpc_utils.moblab_only
412def validate_cloud_storage_info(cloud_storage_info):
413    """RPC handler to check if the cloud storage info is valid.
414
415    @param cloud_storage_info: The JSON RPC object for cloud storage info.
416    """
417    valid, details = _validate_cloud_storage_info(cloud_storage_info)
418    return _create_operation_status_response(valid, details)
419
420
421@rpc_utils.moblab_only
422def submit_wizard_config_info(cloud_storage_info, wifi_info):
423    """RPC handler to submit the cloud storage info.
424
425    @param cloud_storage_info: The JSON RPC object for cloud storage info.
426    @param wifi_info: The JSON RPC object for DUT wifi info.
427    """
428    config_update = {}
429    config_update['CROS'] = [
430        (_IMAGE_STORAGE_SERVER, cloud_storage_info[_IMAGE_STORAGE_SERVER]),
431        (_RESULT_STORAGE_SERVER, cloud_storage_info[_RESULT_STORAGE_SERVER])
432    ]
433    config_update['MOBLAB'] = [
434        (_WIFI_AP_NAME, wifi_info.get(_WIFI_AP_NAME) or ''),
435        (_WIFI_AP_PASS, wifi_info.get(_WIFI_AP_PASS) or '')
436    ]
437    _update_partial_config(config_update)
438
439    if not cloud_storage_info[_USE_EXISTING_BOTO_FILE]:
440        boto_config = ConfigParser.RawConfigParser()
441        boto_config.add_section('Credentials')
442        boto_config.set('Credentials', _GS_ACCESS_KEY_ID,
443                        cloud_storage_info[_GS_ACCESS_KEY_ID])
444        boto_config.set('Credentials', _GS_SECRET_ACCESS_KEY,
445                        cloud_storage_info[_GS_SECRET_ACCESS_KEY])
446        _write_config_file(MOBLAB_BOTO_LOCATION, boto_config, True)
447
448    _CONFIG.parse_config_file()
449    _enable_notification_using_credentials_in_bucket()
450    services = ['moblab-devserver-init',
451    'moblab-devserver-cleanup-init', 'moblab-gsoffloader_s-init',
452    'moblab-scheduler-init', 'moblab-gsoffloader-init']
453    cmd = 'export ATEST_RESULTS_DIR=/usr/local/autotest/results;'
454    cmd += 'sudo stop ' + ';sudo stop '.join(services)
455    cmd += ';sudo start ' + ';sudo start '.join(services)
456    cmd += ';sudo apache2 -k graceful'
457    logging.info(cmd)
458    try:
459        utils.run(cmd)
460    except error.CmdError as e:
461        logging.error(e)
462        # if all else fails reboot the device.
463        utils.run('sudo reboot')
464
465    return _create_operation_status_response(True, None)
466
467
468@rpc_utils.moblab_only
469def get_version_info():
470    """ RPC handler to get informaiton about the version of the moblab.
471
472    @return: A serialized JSON RPC object.
473    """
474    lines = open(_ETC_LSB_RELEASE).readlines()
475    version_response = {
476        x.split('=')[0]: x.split('=')[1] for x in lines if '=' in x}
477    version_response['MOBLAB_ID'] = utils.get_moblab_id();
478    version_response['MOBLAB_SERIAL_NUMBER'] = (
479        utils.get_moblab_serial_number())
480    _check_for_system_update()
481    update_status = _get_system_update_status()
482    version_response['MOBLAB_UPDATE_VERSION'] = update_status['NEW_VERSION']
483    version_response['MOBLAB_UPDATE_STATUS'] = update_status['CURRENT_OP']
484    version_response['MOBLAB_UPDATE_PROGRESS'] = update_status['PROGRESS']
485    return rpc_utils.prepare_for_serialization(version_response)
486
487
488@rpc_utils.moblab_only
489def update_moblab():
490    """ RPC call to update and reboot moblab """
491    _install_system_update()
492
493
494def _check_for_system_update():
495    """ Run the ChromeOS update client to check update server for an
496    update. If an update exists, the update client begins downloading it
497    in the background
498    """
499    # sudo is required to run the update client
500    subprocess.call(['sudo', _UPDATE_ENGINE_CLIENT, '--check_for_update'])
501    # wait for update engine to finish checking
502    tries = 0
503    while ('CHECKING_FOR_UPDATE' in _get_system_update_status()['CURRENT_OP']
504            and tries < 10):
505        time.sleep(.1)
506        tries = tries + 1
507
508def _get_system_update_status():
509    """ Run the ChromeOS update client to check status on a
510    pending/downloading update
511
512    @return: A dictionary containing {
513        PROGRESS: str containing percent progress of an update download
514        CURRENT_OP: str current status of the update engine,
515            ex UPDATE_STATUS_UPDATED_NEED_REBOOT
516        NEW_SIZE: str size of the update
517        NEW_VERSION: str version number for the update
518        LAST_CHECKED_TIME: str unix time stamp of the last update check
519    }
520    """
521    # sudo is required to run the update client
522    cmd_out = subprocess.check_output(
523        ['sudo' ,_UPDATE_ENGINE_CLIENT, '--status'])
524    split_lines = [x.split('=') for x in cmd_out.strip().split('\n')]
525    status = dict((key, val) for [key, val] in split_lines)
526    return status
527
528
529def _install_system_update():
530    """ Installs a ChromeOS update, will cause the system to reboot
531    """
532    # sudo is required to run the update client
533    # first run a blocking command to check, fetch, prepare an update
534    # then check if a reboot is needed
535    try:
536        subprocess.check_call(['sudo', _UPDATE_ENGINE_CLIENT, '--update'])
537        # --is_reboot_needed returns 0 if a reboot is required
538        subprocess.check_call(
539            ['sudo', _UPDATE_ENGINE_CLIENT, '--is_reboot_needed'])
540        subprocess.call(['sudo', _UPDATE_ENGINE_CLIENT, '--reboot'])
541
542    except subprocess.CalledProcessError as e:
543        update_error = subprocess.check_output(
544            ['sudo', _UPDATE_ENGINE_CLIENT, '--last_attempt_error'])
545        raise error.RPCException(update_error)
546
547
548@rpc_utils.moblab_only
549def get_connected_dut_info():
550    """ RPC handler to get informaiton about the DUTs connected to the moblab.
551
552    @return: A serialized JSON RPC object.
553    """
554    # Make a list of the connected DUT's
555    leases = _get_dhcp_dut_leases()
556
557
558    connected_duts = _test_all_dut_connections(leases)
559
560    # Get a list of the AFE configured DUT's
561    hosts = list(rpc_utils.get_host_query((), False, True, {}))
562    models.Host.objects.populate_relationships(hosts, models.Label,
563                                               'label_list')
564    configured_duts = {}
565    for host in hosts:
566        labels = [label.name for label in host.label_list]
567        labels.sort()
568        for host_attribute in host.hostattribute_set.all():
569              labels.append("ATTR:(%s=%s)" % (host_attribute.attribute,
570                                              host_attribute.value))
571        configured_duts[host.hostname] = ', '.join(labels)
572
573    return rpc_utils.prepare_for_serialization(
574            {'configured_duts': configured_duts,
575             'connected_duts': connected_duts})
576
577
578def _get_dhcp_dut_leases():
579     """ Extract information about connected duts from the dhcp server.
580
581     @return: A dict of ipaddress to mac address for each device connected.
582     """
583     lease_info = open(_DHCPD_LEASES).read()
584
585     leases = {}
586     for lease in lease_info.split('lease'):
587         if lease.find('binding state active;') != -1:
588             ipaddress = lease.split('\n')[0].strip(' {')
589             last_octet = int(ipaddress.split('.')[-1].strip())
590             if last_octet > 150:
591                 continue
592             mac_address_search = re.search('hardware ethernet (.*);', lease)
593             if mac_address_search:
594                 leases[ipaddress] = mac_address_search.group(1)
595     return leases
596
597def _test_all_dut_connections(leases):
598    """ Test ssh connection of all connected DUTs in parallel
599
600    @param leases: dict containing key value pairs of ip and mac address
601
602    @return: dict containing {
603        ip: {mac_address:[string], ssh_connection_ok:[boolean]}
604    }
605    """
606    # target function for parallel process
607    def _test_dut(ip, result):
608        result.value = _test_dut_ssh_connection(ip)
609
610    processes = []
611    for ip in leases:
612        # use a shared variable to get the ssh test result from child process
613        ssh_test_result = multiprocessing.Value(ctypes.c_bool)
614        # create a subprocess to test each DUT
615        process = multiprocessing.Process(
616            target=_test_dut, args=(ip, ssh_test_result))
617        process.start()
618
619        processes.append({
620            'ip': ip,
621            'ssh_test_result': ssh_test_result,
622            'process': process
623        })
624
625    connected_duts = {}
626    for process in processes:
627        process['process'].join()
628        ip = process['ip']
629        connected_duts[ip] = {
630            'mac_address': leases[ip],
631            'ssh_connection_ok': process['ssh_test_result'].value
632        }
633
634    return connected_duts
635
636
637def _test_dut_ssh_connection(ip):
638    """ Test if a connected dut is accessible via ssh.
639    The primary use case is to verify that the dut has a test image.
640
641    @return: True if the ssh connection is good False else
642    """
643    cmd = ('ssh -o ConnectTimeout=2 -o StrictHostKeyChecking=no '
644            "root@%s 'timeout 2 cat /etc/lsb-release'") % ip
645    try:
646        release = subprocess.check_output(cmd, shell=True)
647        return 'CHROMEOS_RELEASE_APPID' in release
648    except:
649        return False
650
651
652@rpc_utils.moblab_only
653def add_moblab_dut(ipaddress):
654    """ RPC handler to add a connected DUT to autotest.
655
656    @param ipaddress: IP address of the DUT.
657
658    @return: A string giving information about the status.
659    """
660    cmd = '/usr/local/autotest/cli/atest host create %s &' % ipaddress
661    subprocess.call(cmd, shell=True)
662    return (True, 'DUT %s added to Autotest' % ipaddress)
663
664
665@rpc_utils.moblab_only
666def remove_moblab_dut(ipaddress):
667    """ RPC handler to remove DUT entry from autotest.
668
669    @param ipaddress: IP address of the DUT.
670
671    @return: True if the command succeeds without an exception
672    """
673    models.Host.smart_get(ipaddress).delete()
674    return (True, 'DUT %s deleted from Autotest' % ipaddress)
675
676
677@rpc_utils.moblab_only
678def add_moblab_label(ipaddress, label_name):
679    """ RPC handler to add a label in autotest to a DUT entry.
680
681    @param ipaddress: IP address of the DUT.
682    @param label_name: The label name.
683
684    @return: A string giving information about the status.
685    """
686    # Try to create the label in case it does not already exist.
687    label = None
688    try:
689        label = models.Label.add_object(name=label_name)
690    except:
691        label = models.Label.smart_get(label_name)
692        if label.is_replaced_by_static():
693            raise error.UnmodifiableLabelException(
694                    'Failed to add label "%s" because it is a static label. '
695                    'Use go/chromeos-skylab-inventory-tools to add this '
696                    'label.' % label.name)
697
698    host_obj = models.Host.smart_get(ipaddress)
699    if label:
700        label.host_set.add(host_obj)
701        return (True, 'Added label %s to DUT %s' % (label_name, ipaddress))
702    return (False,
703            'Failed to add label %s to DUT %s' % (label_name, ipaddress))
704
705
706@rpc_utils.moblab_only
707def remove_moblab_label(ipaddress, label_name):
708    """ RPC handler to remove a label in autotest from a DUT entry.
709
710    @param ipaddress: IP address of the DUT.
711    @param label_name: The label name.
712
713    @return: A string giving information about the status.
714    """
715    host_obj = models.Host.smart_get(ipaddress)
716    label = models.Label.smart_get(label_name)
717    if label.is_replaced_by_static():
718        raise error.UnmodifiableLabelException(
719                    'Failed to remove label "%s" because it is a static label. '
720                    'Use go/chromeos-skylab-inventory-tools to remove this '
721                    'label.' % label.name)
722
723    label.host_set.remove(host_obj)
724    return (True, 'Removed label %s from DUT %s' % (label_name, ipaddress))
725
726
727@rpc_utils.moblab_only
728def set_host_attrib(ipaddress, attribute, value):
729    """ RPC handler to set an attribute of a host.
730
731    @param ipaddress: IP address of the DUT.
732    @param attribute: string name of attribute
733    @param value: string, or None to delete an attribute
734
735    @return: True if the command succeeds without an exception
736    """
737    host_obj = models.Host.smart_get(ipaddress)
738    host_obj.set_or_delete_attribute(attribute, value)
739    return (True, 'Updated attribute %s to %s on DUT %s' % (
740        attribute, value, ipaddress))
741
742
743@rpc_utils.moblab_only
744def delete_host_attrib(ipaddress, attribute):
745    """ RPC handler to delete an attribute of a host.
746
747    @param ipaddress: IP address of the DUT.
748    @param attribute: string name of attribute
749
750    @return: True if the command succeeds without an exception
751    """
752    host_obj = models.Host.smart_get(ipaddress)
753    host_obj.set_or_delete_attribute(attribute, None)
754    return (True, 'Deleted attribute %s from DUT %s' % (
755        attribute, ipaddress))
756
757
758def _get_connected_dut_labels(requested_label, only_first_label=True):
759    """ Query the DUT's attached to the moblab and return a filtered list
760        of labels.
761
762    @param requested_label:  the label name you are requesting.
763    @param only_first_label:  if the device has the same label name multiple
764                              times only return the first label value in the
765                              list.
766
767    @return: A de-duped list of requested dut labels attached to the moblab.
768    """
769    hosts = list(rpc_utils.get_host_query((), False, True, {}))
770    if not hosts:
771        return []
772    models.Host.objects.populate_relationships(hosts, models.Label,
773                                               'label_list')
774    labels = set()
775    for host in hosts:
776        for label in host.label_list:
777            if requested_label in label.name:
778                labels.add(label.name.replace(requested_label, ''))
779                if only_first_label:
780                    break
781    return list(labels)
782
783def _get_connected_dut_board_models():
784    """ Get the boards and their models of attached DUTs
785
786    @return: A de-duped list of dut board/model attached to the moblab
787    format: [
788        {
789            "board": "carl",
790            "model": "bruce"
791        },
792        {
793            "board": "veyron_minnie",
794            "model": "veyron_minnie"
795        }
796    ]
797    """
798    hosts = list(rpc_utils.get_host_query((), False, True, {}))
799    if not hosts:
800        return []
801    models.Host.objects.populate_relationships(hosts, models.Label,
802                                               'label_list')
803    model_board_map = dict()
804    for host in hosts:
805        model = ''
806        board = ''
807        for label in host.label_list:
808            if 'model:' in label.name:
809                model = label.name.replace('model:', '')
810            elif 'board:' in label.name:
811                board = label.name.replace('board:', '')
812        model_board_map[model] = board
813
814    board_models_list = []
815    for model in sorted(model_board_map.keys()):
816        board_models_list.append({
817            'model': model,
818            'board': model_board_map[model]
819        })
820    return board_models_list
821
822
823@rpc_utils.moblab_only
824def get_connected_boards():
825    """ RPC handler to get a list of the boards connected to the moblab.
826
827    @return: A de-duped list of board types attached to the moblab.
828    """
829    return _get_connected_dut_board_models()
830
831
832@rpc_utils.moblab_only
833def get_connected_pools():
834    """ RPC handler to get a list of the pools labels on the DUT's connected.
835
836    @return: A de-duped list of pool labels.
837    """
838    pools = _get_connected_dut_labels("pool:", False)
839    pools.sort()
840    return pools
841
842
843@rpc_utils.moblab_only
844def get_builds_for_board(board_name):
845    """ RPC handler to find the most recent builds for a board.
846
847
848    @param board_name: The name of a connected board.
849    @return: A list of string with the most recent builds for the latest
850             three milestones.
851    """
852    return _get_builds_for_in_directory(board_name + '-release',
853                                        milestone_limit=4)
854
855
856@rpc_utils.moblab_only
857def get_firmware_for_board(board_name):
858    """ RPC handler to find the most recent firmware for a board.
859
860
861    @param board_name: The name of a connected board.
862    @return: A list of strings with the most recent firmware builds for the
863             latest three milestones.
864    """
865    return _get_builds_for_in_directory(board_name + '-firmware')
866
867
868def _get_sortable_build_number(sort_key):
869    """ Converts a build number line cyan-release/R59-9460.27.0 into an integer.
870
871        To be able to sort a list of builds you need to convert the build number
872        into an integer so it can be compared correctly to other build.
873
874        cyan-release/R59-9460.27.0 =>  5909460027000
875
876        If the sort key is not recognised as a build number 1 will be returned.
877
878    @param sort_key: A string that represents a build number like
879                     cyan-release/R59-9460.27.0
880    @return: An integer that represents that build number or 1 if not recognised
881             as a build.
882    """
883    build_number = re.search('.*/R([0-9]*)-([0-9]*)\.([0-9]*)\.([0-9]*)',
884                             sort_key)
885    if not build_number or not len(build_number.groups()) == 4:
886      return 1
887    return int("%d%05d%03d%03d" % (int(build_number.group(1)),
888                                   int(build_number.group(2)),
889                                   int(build_number.group(3)),
890                                   int(build_number.group(4))))
891
892def _get_builds_for_in_directory(directory_name, milestone_limit=3,
893                                 build_limit=20):
894    """ Fetch the most recent builds for the last three milestones from gcs.
895
896
897    @param directory_name: The sub-directory under the configured GCS image
898                           storage bucket to search.
899
900
901    @return: A string list no longer than <milestone_limit> x <build_limit>
902             items, containing the most recent <build_limit> builds from the
903             last milestone_limit milestones.
904    """
905    output = StringIO.StringIO()
906    gs_image_location =_CONFIG.get_config_value('CROS', _IMAGE_STORAGE_SERVER)
907    try:
908        utils.run(GsUtil.get_gsutil_cmd(),
909                  args=('ls', gs_image_location + directory_name),
910                  stdout_tee=output)
911    except error.CmdError as e:
912        error_text = ('Failed to list builds from %s.\n'
913                'Did you configure your boto key? Try running the config '
914                'wizard again.\n\n%s') % ((gs_image_location + directory_name),
915                    e.result_obj.stderr)
916        raise error.RPCException(error_text)
917    lines = output.getvalue().split('\n')
918    output.close()
919    builds = [line.replace(gs_image_location,'').strip('/ ')
920              for line in lines if line != '']
921    build_matcher = re.compile(r'^.*\/R([0-9]*)-.*')
922    build_map = {}
923    for build in builds:
924        match = build_matcher.match(build)
925        if match:
926            milestone = match.group(1)
927            if milestone not in build_map:
928                build_map[milestone] = []
929            build_map[milestone].append(build)
930    milestones = build_map.keys()
931    milestones.sort()
932    milestones.reverse()
933    build_list = []
934    for milestone in milestones[:milestone_limit]:
935         builds = build_map[milestone]
936         builds.sort(key=_get_sortable_build_number)
937         builds.reverse()
938         build_list.extend(builds[:build_limit])
939    return build_list
940
941
942def _run_bucket_performance_test(key_id, key_secret, bucket_name,
943                                 test_size='1M', iterations='1',
944                                 result_file='/tmp/gsutil_perf.json'):
945    """Run a gsutil perfdiag on a supplied bucket and output the results"
946
947       @param key_id: boto key of the bucket to be accessed
948       @param key_secret: boto secret of the bucket to be accessed
949       @param bucket_name: bucket to be tested.
950       @param test_size: size of file to use in test, see gsutil perfdiag help.
951       @param iterations: number of times each test is run.
952       @param result_file: name of file to write results out to.
953
954       @return None
955       @raises BucketPerformanceTestException if the command fails.
956    """
957    try:
958      utils.run(GsUtil.get_gsutil_cmd(), args=(
959          '-o', 'Credentials:gs_access_key_id=%s' % key_id,
960          '-o', 'Credentials:gs_secret_access_key=%s' % key_secret,
961          'perfdiag', '-s', test_size, '-o', result_file,
962          '-n', iterations,
963          bucket_name))
964    except error.CmdError as e:
965       logging.error(e)
966       # Extract useful error from the stacktrace
967       errormsg = str(e)
968       start_error_pos = errormsg.find("<Error>")
969       end_error_pos = errormsg.find("</Error>", start_error_pos)
970       extracted_error_msg = errormsg[start_error_pos:end_error_pos]
971       raise BucketPerformanceTestException(
972           extracted_error_msg if extracted_error_msg else errormsg)
973    # TODO(haddowk) send the results to the cloud console when that feature is
974    # enabled.
975
976
977# TODO(haddowk) Change suite_args name to "test_filter_list" or similar. May
978# also need to make changes at MoblabRpcHelper.java
979@rpc_utils.moblab_only
980def run_suite(board, build, suite, model=None, ro_firmware=None,
981              rw_firmware=None, pool=None, suite_args=None, test_args=None,
982              bug_id=None, part_id=None):
983    """ RPC handler to run a test suite.
984
985    @param board: a board name connected to the moblab.
986    @param build: a build name of a build in the GCS.
987    @param suite: the name of a suite to run
988    @param model: a board model name connected to the moblab.
989    @param ro_firmware: Optional ro firmware build number to use.
990    @param rw_firmware: Optional rw firmware build number to use.
991    @param pool: Optional pool name to run the suite in.
992    @param suite_args: Arguments to be used in the suite control file.
993    @param test_args: '\n' delimited key=val pairs passed to test control file.
994    @param bug_id: Optional bug ID used for AVL qualification process.
995    @param part_id: Optional part ID used for AVL qualification
996    process.
997
998    @return: None
999    """
1000    builds = {'cros-version': build}
1001    # TODO(mattmallett b/92031054) Standardize bug id, part id passing for memory/storage qual
1002    processed_suite_args = dict()
1003    processed_test_args = dict()
1004    if rw_firmware:
1005        builds['fwrw-version'] = rw_firmware
1006    if ro_firmware:
1007        builds['fwro-version'] = ro_firmware
1008    if suite_args:
1009        processed_suite_args['tests'] = \
1010            [s.strip() for s in suite_args.split(',')]
1011    if bug_id:
1012        processed_suite_args['bug_id'] = bug_id
1013    if part_id:
1014        processed_suite_args['part_id'] = part_id
1015    processed_test_args['bug_id'] = bug_id or ''
1016    processed_test_args['part_id'] = part_id or ''
1017
1018
1019    # set processed_suite_args to None instead of empty dict when there is no
1020    # argument in processed_suite_args
1021    if len(processed_suite_args) == 0:
1022        processed_suite_args = None
1023
1024    if test_args:
1025        try:
1026          processed_test_args['args'] = [test_args]
1027          for line in test_args.split('\n'):
1028              key, value = line.strip().split('=')
1029              processed_test_args[key] = value
1030        except:
1031            raise error.RPCException('Could not parse test args.')
1032
1033
1034    ap_name =_CONFIG.get_config_value('MOBLAB', _WIFI_AP_NAME, default=None)
1035    processed_test_args['ssid'] = ap_name
1036    ap_pass =_CONFIG.get_config_value('MOBLAB', _WIFI_AP_PASS, default='')
1037    processed_test_args['wifipass'] = ap_pass
1038
1039    suite_timeout_mins = _SUITE_TIMEOUT_MAP.get(
1040            suite, _DEFAULT_SUITE_TIMEOUT_MINS)
1041
1042    afe = frontend.AFE(user='moblab')
1043    afe.run('create_suite_job', board=board, builds=builds, name=suite,
1044            pool=pool, run_prod_code=False, test_source_build=build,
1045            wait_for_results=True, suite_args=processed_suite_args,
1046            test_args=processed_test_args, job_retry=True,
1047            max_retries=sys.maxint, model=model,
1048            timeout_mins=suite_timeout_mins,
1049            max_runtime_mins=suite_timeout_mins)
1050
1051
1052def _enable_notification_using_credentials_in_bucket():
1053    """ Check and enable cloud notification if a credentials file exits.
1054    @return: None
1055    """
1056    gs_image_location =_CONFIG.get_config_value('CROS', _IMAGE_STORAGE_SERVER)
1057    try:
1058        utils.run(GsUtil.get_gsutil_cmd(), args=(
1059            'cp', gs_image_location + 'pubsub-key-do-not-delete.json', '/tmp'))
1060        # This runs the copy as moblab user
1061        shutil.copyfile('/tmp/pubsub-key-do-not-delete.json',
1062                        moblab_host.MOBLAB_SERVICE_ACCOUNT_LOCATION)
1063
1064    except error.CmdError as e:
1065        logging.error(e)
1066    else:
1067        logging.info('Enabling cloud notifications')
1068        config_update = {}
1069        config_update['CROS'] = [(_CLOUD_NOTIFICATION_ENABLED, True)]
1070        _update_partial_config(config_update)
1071
1072
1073@rpc_utils.moblab_only
1074def get_dut_wifi_info():
1075    """RPC handler to get the dut wifi AP information.
1076    """
1077    dut_wifi_info = {}
1078    value =_CONFIG.get_config_value('MOBLAB', _WIFI_AP_NAME,
1079        default=None)
1080    if value is not None:
1081        dut_wifi_info[_WIFI_AP_NAME] = value
1082    value = _CONFIG.get_config_value('MOBLAB', _WIFI_AP_PASS,
1083        default=None)
1084    if value is not None:
1085        dut_wifi_info[_WIFI_AP_PASS] = value
1086    return rpc_utils.prepare_for_serialization(dut_wifi_info)
1087