• 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
10# The boto module is only available/used in Moblab for validation of cloud
11# storage access. The module is not available in the test lab environment,
12# and the import error is handled.
13try:
14    import boto
15except ImportError:
16    boto = None
17
18import ConfigParser
19import common
20import logging
21import os
22import re
23import shutil
24import socket
25import StringIO
26import subprocess
27
28from autotest_lib.client.common_lib import error
29from autotest_lib.client.common_lib import global_config, utils, site_utils
30from autotest_lib.frontend.afe import models
31from autotest_lib.frontend.afe import rpc_utils
32from autotest_lib.server import frontend
33from autotest_lib.server.hosts import moblab_host
34
35
36_CONFIG = global_config.global_config
37MOBLAB_BOTO_LOCATION = '/home/moblab/.boto'
38
39# Google Cloud Storage bucket url regex pattern. The pattern is used to extract
40# the bucket name from the bucket URL. For example, "gs://image_bucket/google"
41# should result in a bucket name "image_bucket".
42GOOGLE_STORAGE_BUCKET_URL_PATTERN = re.compile(
43        r'gs://(?P<bucket>[a-zA-Z][a-zA-Z0-9-_]*)/?.*')
44
45# Contants used in Json RPC field names.
46_IMAGE_STORAGE_SERVER = 'image_storage_server'
47_GS_ACCESS_KEY_ID = 'gs_access_key_id'
48_GS_SECRETE_ACCESS_KEY = 'gs_secret_access_key'
49_RESULT_STORAGE_SERVER = 'results_storage_server'
50_USE_EXISTING_BOTO_FILE = 'use_existing_boto_file'
51
52# Location where dhcp leases are stored.
53_DHCPD_LEASES = '/var/lib/dhcp/dhcpd.leases'
54
55# File where information about the current device is stored.
56_ETC_LSB_RELEASE = '/etc/lsb-release'
57
58@rpc_utils.moblab_only
59def get_config_values():
60    """Returns all config values parsed from global and shadow configs.
61
62    Config values are grouped by sections, and each section is composed of
63    a list of name value pairs.
64    """
65    sections =_CONFIG.get_sections()
66    config_values = {}
67    for section in sections:
68        config_values[section] = _CONFIG.config.items(section)
69    return rpc_utils.prepare_for_serialization(config_values)
70
71
72def _write_config_file(config_file, config_values, overwrite=False):
73    """Writes out a configuration file.
74
75    @param config_file: The name of the configuration file.
76    @param config_values: The ConfigParser object.
77    @param ovewrite: Flag on if overwriting is allowed.
78    """
79    if not config_file:
80        raise error.RPCException('Empty config file name.')
81    if not overwrite and os.path.exists(config_file):
82        raise error.RPCException('Config file already exists.')
83
84    if config_values:
85        with open(config_file, 'w') as config_file:
86            config_values.write(config_file)
87
88
89def _read_original_config():
90    """Reads the orginal configuratino without shadow.
91
92    @return: A configuration object, see global_config_class.
93    """
94    original_config = global_config.global_config_class()
95    original_config.set_config_files(shadow_file='')
96    return original_config
97
98
99def _read_raw_config(config_file):
100    """Reads the raw configuration from a configuration file.
101
102    @param: config_file: The path of the configuration file.
103
104    @return: A ConfigParser object.
105    """
106    shadow_config = ConfigParser.RawConfigParser()
107    shadow_config.read(config_file)
108    return shadow_config
109
110
111def _get_shadow_config_from_partial_update(config_values):
112    """Finds out the new shadow configuration based on a partial update.
113
114    Since the input is only a partial config, we should not lose the config
115    data inside the existing shadow config file. We also need to distinguish
116    if the input config info overrides with a new value or reverts back to
117    an original value.
118
119    @param config_values: See get_moblab_settings().
120
121    @return: The new shadow configuration as ConfigParser object.
122    """
123    original_config = _read_original_config()
124    existing_shadow = _read_raw_config(_CONFIG.shadow_file)
125    for section, config_value_list in config_values.iteritems():
126        for key, value in config_value_list:
127            if original_config.get_config_value(section, key,
128                                                default='',
129                                                allow_blank=True) != value:
130                if not existing_shadow.has_section(section):
131                    existing_shadow.add_section(section)
132                existing_shadow.set(section, key, value)
133            elif existing_shadow.has_option(section, key):
134                existing_shadow.remove_option(section, key)
135    return existing_shadow
136
137
138def _update_partial_config(config_values):
139    """Updates the shadow configuration file with a partial config udpate.
140
141    @param config_values: See get_moblab_settings().
142    """
143    existing_config = _get_shadow_config_from_partial_update(config_values)
144    _write_config_file(_CONFIG.shadow_file, existing_config, True)
145
146
147@rpc_utils.moblab_only
148def update_config_handler(config_values):
149    """Update config values and override shadow config.
150
151    @param config_values: See get_moblab_settings().
152    """
153    original_config = _read_original_config()
154    new_shadow = ConfigParser.RawConfigParser()
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 new_shadow.has_section(section):
161                    new_shadow.add_section(section)
162                new_shadow.set(section, key, value)
163
164    if not _CONFIG.shadow_file or not os.path.exists(_CONFIG.shadow_file):
165        raise error.RPCException('Shadow config file does not exist.')
166    _write_config_file(_CONFIG.shadow_file, new_shadow, True)
167
168    # TODO (sbasi) crbug.com/403916 - Remove the reboot command and
169    # instead restart the services that rely on the config values.
170    os.system('sudo reboot')
171
172
173@rpc_utils.moblab_only
174def reset_config_settings():
175    """Reset moblab shadow config."""
176    with open(_CONFIG.shadow_file, 'w') as config_file:
177        pass
178    os.system('sudo reboot')
179
180
181@rpc_utils.moblab_only
182def reboot_moblab():
183    """Simply reboot the device."""
184    os.system('sudo reboot')
185
186
187@rpc_utils.moblab_only
188def set_boto_key(boto_key):
189    """Update the boto_key file.
190
191    @param boto_key: File name of boto_key uploaded through handle_file_upload.
192    """
193    if not os.path.exists(boto_key):
194        raise error.RPCException('Boto key: %s does not exist!' % boto_key)
195    shutil.copyfile(boto_key, moblab_host.MOBLAB_BOTO_LOCATION)
196
197
198@rpc_utils.moblab_only
199def set_service_account_credential(service_account_filename):
200    """Update the service account credential file.
201
202    @param service_account_filename: Name of uploaded file through
203            handle_file_upload.
204    """
205    if not os.path.exists(service_account_filename):
206        raise error.RPCException(
207                'Service account file: %s does not exist!' %
208                service_account_filename)
209    shutil.copyfile(
210            service_account_filename,
211            moblab_host.MOBLAB_SERVICE_ACCOUNT_LOCATION)
212
213
214@rpc_utils.moblab_only
215def set_launch_control_key(launch_control_key):
216    """Update the launch_control_key file.
217
218    @param launch_control_key: File name of launch_control_key uploaded through
219            handle_file_upload.
220    """
221    if not os.path.exists(launch_control_key):
222        raise error.RPCException('Launch Control key: %s does not exist!' %
223                                 launch_control_key)
224    shutil.copyfile(launch_control_key,
225                    moblab_host.MOBLAB_LAUNCH_CONTROL_KEY_LOCATION)
226    # Restart the devserver service.
227    os.system('sudo restart moblab-devserver-init')
228
229
230###########Moblab Config Wizard RPCs #######################
231def _get_public_ip_address(socket_handle):
232    """Gets the public IP address.
233
234    Connects to Google DNS server using a socket and gets the preferred IP
235    address from the connection.
236
237    @param: socket_handle: a unix socket.
238
239    @return: public ip address as string.
240    """
241    try:
242        socket_handle.settimeout(1)
243        socket_handle.connect(('8.8.8.8', 53))
244        socket_name = socket_handle.getsockname()
245        if socket_name is not None:
246            logging.info('Got socket name from UDP socket.')
247            return socket_name[0]
248        logging.warn('Created UDP socket but with no socket_name.')
249    except socket.error:
250        logging.warn('Could not get socket name from UDP socket.')
251    return None
252
253
254def _get_network_info():
255    """Gets the network information.
256
257    TCP socket is used to test the connectivity. If there is no connectivity, try to
258    get the public IP with UDP socket.
259
260    @return: a tuple as (public_ip_address, connected_to_internet).
261    """
262    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
263    ip = _get_public_ip_address(s)
264    if ip is not None:
265        logging.info('Established TCP connection with well known server.')
266        return (ip, True)
267    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
268    return (_get_public_ip_address(s), False)
269
270
271@rpc_utils.moblab_only
272def get_network_info():
273    """Returns the server ip addresses, and if the server connectivity.
274
275    The server ip addresses as an array of strings, and the connectivity as a
276    flag.
277    """
278    network_info = {}
279    info = _get_network_info()
280    if info[0] is not None:
281        network_info['server_ips'] = [info[0]]
282    network_info['is_connected'] = info[1]
283
284    return rpc_utils.prepare_for_serialization(network_info)
285
286
287# Gets the boto configuration.
288def _get_boto_config():
289    """Reads the boto configuration from the boto file.
290
291    @return: Boto configuration as ConfigParser object.
292    """
293    boto_config = ConfigParser.ConfigParser()
294    boto_config.read(MOBLAB_BOTO_LOCATION)
295    return boto_config
296
297
298@rpc_utils.moblab_only
299def get_cloud_storage_info():
300    """RPC handler to get the cloud storage access information.
301    """
302    cloud_storage_info = {}
303    value =_CONFIG.get_config_value('CROS', _IMAGE_STORAGE_SERVER)
304    if value is not None:
305        cloud_storage_info[_IMAGE_STORAGE_SERVER] = value
306    value = _CONFIG.get_config_value('CROS', _RESULT_STORAGE_SERVER,
307            default=None)
308    if value is not None:
309        cloud_storage_info[_RESULT_STORAGE_SERVER] = value
310
311    boto_config = _get_boto_config()
312    sections = boto_config.sections()
313
314    if sections:
315        cloud_storage_info[_USE_EXISTING_BOTO_FILE] = True
316    else:
317        cloud_storage_info[_USE_EXISTING_BOTO_FILE] = False
318    if 'Credentials' in sections:
319        options = boto_config.options('Credentials')
320        if _GS_ACCESS_KEY_ID in options:
321            value = boto_config.get('Credentials', _GS_ACCESS_KEY_ID)
322            cloud_storage_info[_GS_ACCESS_KEY_ID] = value
323        if _GS_SECRETE_ACCESS_KEY in options:
324            value = boto_config.get('Credentials', _GS_SECRETE_ACCESS_KEY)
325            cloud_storage_info[_GS_SECRETE_ACCESS_KEY] = value
326
327    return rpc_utils.prepare_for_serialization(cloud_storage_info)
328
329
330def _get_bucket_name_from_url(bucket_url):
331    """Gets the bucket name from a bucket url.
332
333    @param: bucket_url: the bucket url string.
334    """
335    if bucket_url:
336        match = GOOGLE_STORAGE_BUCKET_URL_PATTERN.match(bucket_url)
337        if match:
338            return match.group('bucket')
339    return None
340
341
342def _is_valid_boto_key(key_id, key_secret):
343    """Checks if the boto key is valid.
344
345    @param: key_id: The boto key id string.
346    @param: key_secret: The boto key string.
347
348    @return: A tuple as (valid_boolean, details_string).
349    """
350    if not key_id or not key_secret:
351        return (False, "Empty key id or secret.")
352    conn = boto.connect_gs(key_id, key_secret)
353    try:
354        buckets = conn.get_all_buckets()
355        return (True, None)
356    except boto.exception.GSResponseError:
357        details = "The boto access key is not valid"
358        return (False, details)
359    finally:
360        conn.close()
361
362
363def _is_valid_bucket(key_id, key_secret, bucket_name):
364    """Checks if a bucket is valid and accessible.
365
366    @param: key_id: The boto key id string.
367    @param: key_secret: The boto key string.
368    @param: bucket name string.
369
370    @return: A tuple as (valid_boolean, details_string).
371    """
372    if not key_id or not key_secret or not bucket_name:
373        return (False, "Server error: invalid argument")
374    conn = boto.connect_gs(key_id, key_secret)
375    bucket = conn.lookup(bucket_name)
376    conn.close()
377    if bucket:
378        return (True, None)
379    return (False, "Bucket %s does not exist." % bucket_name)
380
381
382def _is_valid_bucket_url(key_id, key_secret, bucket_url):
383    """Validates the bucket url is accessible.
384
385    @param: key_id: The boto key id string.
386    @param: key_secret: The boto key string.
387    @param: bucket url string.
388
389    @return: A tuple as (valid_boolean, details_string).
390    """
391    bucket_name = _get_bucket_name_from_url(bucket_url)
392    if bucket_name:
393        return _is_valid_bucket(key_id, key_secret, bucket_name)
394    return (False, "Bucket url %s is not valid" % bucket_url)
395
396
397def _validate_cloud_storage_info(cloud_storage_info):
398    """Checks if the cloud storage information is valid.
399
400    @param: cloud_storage_info: The JSON RPC object for cloud storage info.
401
402    @return: A tuple as (valid_boolean, details_string).
403    """
404    valid = True
405    details = None
406    if not cloud_storage_info[_USE_EXISTING_BOTO_FILE]:
407        key_id = cloud_storage_info[_GS_ACCESS_KEY_ID]
408        key_secret = cloud_storage_info[_GS_SECRETE_ACCESS_KEY]
409        valid, details = _is_valid_boto_key(key_id, key_secret)
410
411        if valid:
412            valid, details = _is_valid_bucket_url(
413                key_id, key_secret, cloud_storage_info[_IMAGE_STORAGE_SERVER])
414
415        # allows result bucket to be empty.
416        if valid and cloud_storage_info[_RESULT_STORAGE_SERVER]:
417            valid, details = _is_valid_bucket_url(
418                key_id, key_secret, cloud_storage_info[_RESULT_STORAGE_SERVER])
419    return (valid, details)
420
421
422def _create_operation_status_response(is_ok, details):
423    """Helper method to create a operation status reponse.
424
425    @param: is_ok: Boolean for if the operation is ok.
426    @param: details: A detailed string.
427
428    @return: A serialized JSON RPC object.
429    """
430    status_response = {'status_ok': is_ok}
431    if details:
432        status_response['status_details'] = details
433    return rpc_utils.prepare_for_serialization(status_response)
434
435
436@rpc_utils.moblab_only
437def validate_cloud_storage_info(cloud_storage_info):
438    """RPC handler to check if the cloud storage info is valid.
439
440    @param cloud_storage_info: The JSON RPC object for cloud storage info.
441    """
442    valid, details = _validate_cloud_storage_info(cloud_storage_info)
443    return _create_operation_status_response(valid, details)
444
445
446@rpc_utils.moblab_only
447def submit_wizard_config_info(cloud_storage_info):
448    """RPC handler to submit the cloud storage info.
449
450    @param cloud_storage_info: The JSON RPC object for cloud storage info.
451    """
452    valid, details = _validate_cloud_storage_info(cloud_storage_info)
453    if not valid:
454        return _create_operation_status_response(valid, details)
455    config_update = {}
456    config_update['CROS'] = [
457        (_IMAGE_STORAGE_SERVER, cloud_storage_info[_IMAGE_STORAGE_SERVER]),
458        (_RESULT_STORAGE_SERVER, cloud_storage_info[_RESULT_STORAGE_SERVER])
459    ]
460    _update_partial_config(config_update)
461
462    if not cloud_storage_info[_USE_EXISTING_BOTO_FILE]:
463        boto_config = ConfigParser.RawConfigParser()
464        boto_config.add_section('Credentials')
465        boto_config.set('Credentials', _GS_ACCESS_KEY_ID,
466                        cloud_storage_info[_GS_ACCESS_KEY_ID])
467        boto_config.set('Credentials', _GS_SECRETE_ACCESS_KEY,
468                        cloud_storage_info[_GS_SECRETE_ACCESS_KEY])
469        _write_config_file(MOBLAB_BOTO_LOCATION, boto_config, True)
470
471    _CONFIG.parse_config_file()
472    services = ['moblab-devserver-init', 'moblab-apache-init',
473    'moblab-devserver-cleanup-init', ' moblab-gsoffloader_s-init',
474    'moblab-base-container-init', 'moblab-scheduler-init', 'moblab-gsoffloader-init']
475    cmd = ';/sbin/restart '.join(services)
476    os.system(cmd)
477
478    return _create_operation_status_response(True, None)
479
480
481@rpc_utils.moblab_only
482def get_version_info():
483    """ RPC handler to get informaiton about the version of the moblab.
484
485    @return: A serialized JSON RPC object.
486    """
487    lines = open(_ETC_LSB_RELEASE).readlines()
488    version_response = {
489        x.split('=')[0]: x.split('=')[1] for x in lines if '=' in x}
490    version_response['MOBLAB_ID'] = site_utils.get_moblab_id();
491    version_response['MOBLAB_MAC_ADDRESS'] = (
492        site_utils.get_default_interface_mac_address())
493    return rpc_utils.prepare_for_serialization(version_response)
494
495
496@rpc_utils.moblab_only
497def get_connected_dut_info():
498    """ RPC handler to get informaiton about the DUTs connected to the moblab.
499
500    @return: A serialized JSON RPC object.
501    """
502    # Make a list of the connected DUT's
503    leases = _get_dhcp_dut_leases()
504
505    # Get a list of the AFE configured DUT's
506    hosts = list(rpc_utils.get_host_query((), False, True, {}))
507    models.Host.objects.populate_relationships(hosts, models.Label,
508                                               'label_list')
509    configured_duts = {}
510    for host in hosts:
511        labels = [label.name for label in host.label_list]
512        labels.sort()
513        configured_duts[host.hostname] = ', '.join(labels)
514
515    return rpc_utils.prepare_for_serialization(
516            {'configured_duts': configured_duts,
517             'connected_duts': leases})
518
519
520def _get_dhcp_dut_leases():
521     """ Extract information about connected duts from the dhcp server.
522
523     @return: A dict of ipaddress to mac address for each device connected.
524     """
525     lease_info = open(_DHCPD_LEASES).read()
526
527     leases = {}
528     for lease in lease_info.split('lease'):
529         if lease.find('binding state active;') != -1:
530             ipaddress = lease.split('\n')[0].strip(' {')
531             last_octet = int(ipaddress.split('.')[-1].strip())
532             if last_octet > 150:
533                 continue
534             mac_address_search = re.search('hardware ethernet (.*);', lease)
535             if mac_address_search:
536                 leases[ipaddress] = mac_address_search.group(1)
537     return leases
538
539
540@rpc_utils.moblab_only
541def add_moblab_dut(ipaddress):
542    """ RPC handler to add a connected DUT to autotest.
543
544    @param ipaddress: IP address of the DUT.
545
546    @return: A string giving information about the status.
547    """
548    cmd = '/usr/local/autotest/cli/atest host create %s &' % ipaddress
549    subprocess.call(cmd, shell=True)
550    return (True, 'DUT %s added to Autotest' % ipaddress)
551
552
553@rpc_utils.moblab_only
554def remove_moblab_dut(ipaddress):
555    """ RPC handler to remove DUT entry from autotest.
556
557    @param ipaddress: IP address of the DUT.
558
559    @return: True if the command succeeds without an exception
560    """
561    models.Host.smart_get(ipaddress).delete()
562    return (True, 'DUT %s deleted from Autotest' % ipaddress)
563
564
565@rpc_utils.moblab_only
566def add_moblab_label(ipaddress, label_name):
567    """ RPC handler to add a label in autotest to a DUT entry.
568
569    @param ipaddress: IP address of the DUT.
570    @param label_name: The label name.
571
572    @return: A string giving information about the status.
573    """
574    # Try to create the label in case it does not already exist.
575    label = None
576    try:
577        label = models.Label.add_object(name=label_name)
578    except:
579        label = models.Label.smart_get(label_name)
580    host_obj = models.Host.smart_get(ipaddress)
581    if label:
582        label.host_set.add(host_obj)
583        return (True, 'Added label %s to DUT %s' % (label_name, ipaddress))
584    return (False, 'Failed to add label %s to DUT %s' % (label_name, ipaddress))
585
586
587@rpc_utils.moblab_only
588def remove_moblab_label(ipaddress, label_name):
589    """ RPC handler to remove a label in autotest from a DUT entry.
590
591    @param ipaddress: IP address of the DUT.
592    @param label_name: The label name.
593
594    @return: A string giving information about the status.
595    """
596    host_obj = models.Host.smart_get(ipaddress)
597    models.Label.smart_get(label_name).host_set.remove(host_obj)
598    return (True, 'Removed label %s from DUT %s' % (label_name, ipaddress))
599
600
601def _get_connected_dut_labels(requested_label, only_first_label=True):
602    """ Query the DUT's attached to the moblab and return a filtered list of labels.
603
604    @param requested_label:  the label name you are requesting.
605    @param only_first_label:  if the device has the same label name multiple times only
606                              return the first label value in the list.
607
608    @return: A de-duped list of requested dut labels attached to the moblab.
609    """
610    hosts = list(rpc_utils.get_host_query((), False, True, {}))
611    if not hosts:
612        return []
613    models.Host.objects.populate_relationships(hosts, models.Label,
614                                               'label_list')
615    labels = set()
616    for host in hosts:
617        for label in host.label_list:
618            if requested_label in label.name:
619                labels.add(label.name.replace(requested_label, ''))
620                if only_first_label:
621                    break
622    return list(labels)
623
624
625@rpc_utils.moblab_only
626def get_connected_boards():
627    """ RPC handler to get a list of the boards connected to the moblab.
628
629    @return: A de-duped list of board types attached to the moblab.
630    """
631    boards = _get_connected_dut_labels("board:")
632    boards.sort()
633    return boards
634
635
636@rpc_utils.moblab_only
637def get_connected_pools():
638    """ RPC handler to get a list of the pools labels on the DUT's connected.
639
640    @return: A de-duped list of pool labels.
641    """
642    pools = _get_connected_dut_labels("pool:", False)
643    pools.sort()
644    return pools
645
646
647@rpc_utils.moblab_only
648def get_builds_for_board(board_name):
649    """ RPC handler to find the most recent builds for a board.
650
651
652    @param board_name: The name of a connected board.
653    @return: A list of string with the most recent builds for the latest
654             three milestones.
655    """
656    return _get_builds_for_in_directory(board_name + '-release')
657
658
659@rpc_utils.moblab_only
660def get_firmware_for_board(board_name):
661    """ RPC handler to find the most recent firmware for a board.
662
663
664    @param board_name: The name of a connected board.
665    @return: A list of strings with the most recent firmware builds for the
666             latest three milestones.
667    """
668    return _get_builds_for_in_directory(board_name + '-firmware')
669
670
671def _get_builds_for_in_directory(directory_name, milestone_limit=3, build_limit=20):
672    """ Fetch the most recent builds for the last three milestones from gcs.
673
674
675    @param directory_name: The sub-directory under the configured GCS image
676                           storage bucket to search.
677
678
679    @return: A string list no longer than <milestone_limit> x <build_limit> items,
680             containing the most recent <build_limit> builds from the last
681             milestone_limit milestones.
682    """
683    output = StringIO.StringIO()
684    gs_image_location =_CONFIG.get_config_value('CROS', _IMAGE_STORAGE_SERVER)
685    utils.run('gsutil', args=('ls', gs_image_location + directory_name), stdout_tee=output)
686    lines = output.getvalue().split('\n')
687    output.close()
688    builds = [line.replace(gs_image_location,'').strip('/ ') for line in lines if line != '']
689    build_matcher = re.compile(r'^.*\/R([0-9]*)-.*')
690    build_map = {}
691    for build in builds:
692        match = build_matcher.match(build)
693        if match:
694            milestone = match.group(1)
695            if milestone not in build_map:
696                build_map[milestone] = []
697            build_map[milestone].append(build)
698    milestones = build_map.keys()
699    milestones.sort()
700    milestones.reverse()
701    build_list = []
702    for milestone in milestones[:milestone_limit]:
703         builds = build_map[milestone]
704         builds.reverse()
705         build_list.extend(builds[:build_limit])
706    return build_list
707
708
709@rpc_utils.moblab_only
710def run_suite(board, build, suite, ro_firmware=None, rw_firmware=None, pool=None):
711    """ RPC handler to run a test suite.
712
713    @param board: a board name connected to the moblab.
714    @param build: a build name of a build in the GCS.
715    @param suite: the name of a suite to run
716    @param ro_firmware: Optional ro firmware build number to use.
717    @param rw_firmware: Optional rw firmware build number to use.
718    @param pool: Optional pool name to run the suite in.
719
720    @return: None
721    """
722    builds = {'cros-version': build}
723    if rw_firmware:
724        builds['fwrw-version'] = rw_firmware
725    if ro_firmware:
726        builds['fwro-version'] = ro_firmware
727    afe = frontend.AFE(user='moblab')
728    afe.run('create_suite_job', board=board, builds=builds, name=suite,
729    pool=pool, run_prod_code=False, test_source_build=build,
730    wait_for_results=False)
731