xref: /aosp_15_r20/external/autotest/server/cros/bluetooth/bluetooth_peer_update.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
1# Lint as: python2, python3
2# Copyright 2020 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6"""
7This file provides functions to implement bluetooth_PeerUpdate test
8which downloads chameleond bundle from google cloud storage and updates
9peer device associated with a DUT
10"""
11
12from __future__ import absolute_import
13
14import logging
15import os
16import sys
17import tempfile
18import time
19import yaml
20
21from datetime import datetime
22
23import common
24from autotest_lib.client.bin import utils
25from autotest_lib.client.common_lib import error
26
27
28# The location of the package in the cloud
29GS_PUBLIC = 'gs://chromeos-localmirror/distfiles/bluetooth_peer_bundle/'
30
31# NAME of the file that stores python2 commits info in the cloud
32PYTHON2_COMMITS_FILENAME = 'bluetooth_python2_commits'
33
34# NAME of the file that stores commits info in the Google cloud storage.
35COMMITS_FILENAME = 'bluetooth_commits.yaml'
36
37
38# The following needs to be kept in sync with values chameleond code
39BUNDLE_TEMPLATE='chameleond-0.0.2-{}.tar.gz' # Name of the chamleond package
40BUNDLE_DIR = 'chameleond-0.0.2'
41BUNDLE_VERSION = '9999'
42CHAMELEON_BOARD = 'fpga_tio'
43
44
45def run_cmd(peer, cmd):
46    """A wrapper around host.run()."""
47    try:
48        logging.info('executing command %s on peer',cmd)
49        result = peer.host.run(cmd)
50        logging.info('exit_status is %s', result.exit_status)
51        logging.info('stdout is %s stderr is %s', result.stdout, result.stderr)
52        output = result.stderr if result.stderr else result.stdout
53        if result.exit_status == 0:
54            return True, output
55        else:
56            return False, output
57    except error.AutoservRunError as e:
58        logging.error('Error while running cmd %s %s', cmd, e)
59        return False, None
60
61
62def read_google_cloud_file(filename):
63    """ Check if update is required
64
65    Read the contents of the Googlle cloud file.
66
67    @param filename: the filename of the Google cloud file
68
69    @returns: the contexts of the file if successful; None otherwise.
70    """
71    try:
72        with tempfile.NamedTemporaryFile() as tmp_file:
73            tmp_filename = tmp_file.name
74            cmd = 'gsutil cp {} {}'.format(filename, tmp_filename)
75            result = utils.run(cmd)
76            if result.exit_status != 0:
77                logging.error('Downloading file %s failed with %s',
78                              filename, result.exit_status)
79                return None
80            with open(tmp_filename) as f:
81                content = f.read()
82                logging.debug('content of the file %s: %s', filename, content)
83                return content
84    except Exception as e:
85        logging.error('Error in reading %s', filename)
86        return None
87
88
89def is_update_needed(peer, target_commit):
90    """ Check if update is required
91
92    Update if the commit hash doesn't match
93
94    @returns: True/False
95    """
96    return not is_commit_hash_equal(peer, target_commit)
97
98
99def is_commit_hash_equal(peer, target_commit):
100    """ Check if chameleond commit hash is the expected one"""
101    try:
102        commit = peer.get_bt_commit_hash()
103    except:
104        logging.error('Getting the commit hash failed. Updating the peer %s',
105                      sys.exc_info())
106        return True
107
108    logging.debug('commit %s found on peer %s', commit, peer.host)
109    return commit == target_commit
110
111
112def is_chromeos_build_greater_or_equal(build1, build2):
113    """ Check if build1 is greater or equal to the build2"""
114    build1 = [int(key1) for key1 in build1.split('.')]
115    build2 = [int(key2) for key2 in build2.split('.')]
116    for key1, key2 in zip(build1, build2):
117        if key1 > key2:
118            return True
119        elif key1 == key2:
120            continue
121        else:
122            return False
123    return True
124
125
126def perform_update(force_system_packages_update, peer, target_commit,
127                   latest_commit):
128    """ Update the chameleond on the peer
129
130    @param force_system_packages_update: True to update system packages of the
131                                          peer.
132    @param peer: btpeer to be updated
133    @param target_commit: target git commit
134    @param latest_commit: the latest git commit in the lab_commit_map, which
135                           is defined in the bluetooth_commits.yaml
136
137    @returns: True if the update process is success, False otherwise
138    """
139
140    # Only update the system when the target commit is the latest.
141    # Since system packages are backward compatible so it's safe to keep
142    # it the latest.
143    needs_system_update = 'true'
144    if force_system_packages_update:
145        logging.info("Forced system packages update on the peer.")
146    elif target_commit == latest_commit:
147        logging.info(
148                "Perform system packages update as the peer's "
149                "target_commit is the latest one %s", target_commit)
150    else:
151        logging.info("Skip updating system packages on the peer.")
152        needs_system_update = 'false'
153
154    logging.info('copy the file over to the peer')
155    try:
156        cur_dir = '/tmp/'
157        bundle = BUNDLE_TEMPLATE.format(target_commit)
158        bundle_path = os.path.join(cur_dir, bundle)
159        logging.debug('package location is %s', bundle_path)
160
161        peer.host.send_file(bundle_path, '/tmp/')
162    except:
163        logging.error('copying the file failed %s ', sys.exc_info())
164        logging.error(str(os.listdir(cur_dir)))
165        return False
166
167    # Backward compatibility for deploying the chamleeon bundle:
168    # use 'PY_VERSION=python3' only when the target_commit is not in
169    # the specified python2 commits. When py_version_option is empty,
170    # python2 will be used in the deployment.
171    python2_commits_filename = GS_PUBLIC + PYTHON2_COMMITS_FILENAME
172    python2_commits = read_google_cloud_file(python2_commits_filename)
173    logging.info('target_commit %s python2_commits %s ',
174                 target_commit, python2_commits)
175    if bool(python2_commits) and target_commit in python2_commits:
176        py_version_option = ''
177    else:
178        py_version_option = 'PY_VERSION=python3'
179
180    HOST_NOW = datetime.strftime(datetime.now(), '%Y-%m-%d %H:%M:%S')
181    logging.info('running make on peer')
182    cmd = ('cd %s && rm -rf %s && tar zxf %s &&'
183           'cd %s && find -exec touch -c {} \; &&'
184           'make install REMOTE_INSTALL=TRUE '
185           'HOST_NOW="%s" BUNDLE_VERSION=%s '
186           'CHAMELEON_BOARD=%s NEEDS_SYSTEM_UPDATE=%s '
187           '%s && rm %s%s' %
188           (cur_dir, BUNDLE_DIR, bundle, BUNDLE_DIR, HOST_NOW, BUNDLE_VERSION,
189            CHAMELEON_BOARD, needs_system_update, py_version_option, cur_dir,
190            bundle))
191    logging.info(cmd)
192    status, _ = run_cmd(peer, cmd)
193    if not status:
194        logging.info('make failed')
195        return False
196
197    logging.info('chameleond installed on peer')
198    return True
199
200
201def restart_check_chameleond(peer):
202    """restart chameleond and make sure it is running."""
203
204    restart_cmd = 'sudo /etc/init.d/chameleond restart'
205    start_cmd = 'sudo /etc/init.d/chameleond start'
206    status_cmd = 'sudo /etc/init.d/chameleond status'
207
208    status, _ = run_cmd(peer, restart_cmd)
209    if not status:
210        status, _ = run_cmd(peer, start_cmd)
211        if not status:
212            logging.error('restarting/starting chamleond failed')
213    #
214    #TODO: Refactor so that we wait for all peer devices all together.
215    #
216    # Wait till chameleond initialization is complete
217    time.sleep(5)
218
219    status, output = run_cmd(peer, status_cmd)
220    expected_output = 'chameleond is running'
221    return status and expected_output in output
222
223
224def update_peer(force_system_packages_update, peer, target_commit,
225                latest_commit):
226    """Update the chameleond on peer devices if required
227
228    @param force_system_packages_update: True to update system packages of the
229                                          peer
230    @param peer: btpeer to be updated
231    @param target_commit: target git commit
232    @param latest_commit: the latest git commit in the lab_commit_map, which
233                           is defined in the bluetooth_commits.yaml
234
235    @returns: (True, None) if update succeeded
236              (False, reason) if update failed
237    """
238
239    if peer.get_platform() != 'RASPI':
240        logging.error('Unsupported peer %s',str(peer.host))
241        return False, 'Unsupported peer'
242
243    if not perform_update(force_system_packages_update, peer, target_commit,
244                          latest_commit):
245        return False, 'Update failed'
246
247    if not restart_check_chameleond(peer):
248        return False, 'Unable to start chameleond'
249
250    if is_update_needed(peer, target_commit):
251        return False, 'Commit not updated after upgrade'
252
253    logging.info('updating chameleond succeded')
254    return True, ''
255
256
257def update_all_peers(host, raise_error=False):
258    """Update the chameleond on all peer devices of the given host
259
260    @param host: the DUT, usually a Chromebook
261    @param raise_error: set this to True to raise an error if any
262
263    @returns: True if _update_all_peers success
264              False if raise_error=False and _update_all_peers failed
265
266    @raises: error.TestFail if raise_error=True and _update_all_peers failed
267    """
268    fail_reason = _update_all_peers(host)
269
270    if fail_reason:
271        if raise_error:
272            raise error.TestFail(fail_reason)
273        logging.error(fail_reason)
274        return False
275    else:
276        return True
277
278
279def _update_all_peers(host):
280    """Update the chameleond on all peer devices of an host"""
281    try:
282        target_commit = get_target_commit(host)
283        latest_commit = get_latest_commit(host)
284
285        if target_commit is None:
286            return 'Unable to get current commit'
287
288        if latest_commit is None:
289            return 'Unable to get latest commit'
290
291        if host.btpeer_list == []:
292            return 'Bluetooth Peer not present'
293
294        peers_to_update = [
295                p for p in host.btpeer_list
296                if is_update_needed(p, target_commit)
297        ]
298
299        if not peers_to_update:
300            logging.info('No peer needed update')
301            return
302        logging.debug('At least one peer needs update')
303
304        if not download_installation_files(host, target_commit):
305            return 'Unable to download installation files'
306
307        # TODO(b:160782273) Make this parallel
308        failed_peers = []
309        host_is_in_lab_next_hosts = is_in_lab_next_hosts(host)
310        for peer in peers_to_update:
311            updated, reason = update_peer(host_is_in_lab_next_hosts, peer,
312                                          target_commit, latest_commit)
313            if updated:
314                logging.info('peer %s updated successfully', str(peer.host))
315            else:
316                failed_peers.append((str(peer.host), reason))
317
318        if failed_peers:
319            return 'peer update failed (host, reason): %s' % failed_peers
320
321    except Exception as e:
322        return 'Exception raised in _update_all_peers: %s' % e
323    finally:
324        if not cleanup(host, target_commit):
325            return 'Update peer cleanup failed'
326
327
328def get_bluetooth_commits_yaml(host, method='from_cloud'):
329    """Get the bluetooth_commit.yaml file
330
331    This function has the side effect that it will set the attribute,
332    host.bluetooth_commits_yaml for caching.
333
334    @param host: the DUT, usually a Chromebook
335    @param method: from_cloud: download the YAML file from the Google Cloud
336                                Storage
337                    from_local: download the YAML file from local, this option
338                                is convienent for testing
339    @returns: bluetooth_commits.yaml file if exists
340
341    @raises: error.TestFail if failed to get the yaml file
342    """
343    try:
344        if not hasattr(host, 'bluetooth_commits_yaml'):
345            if method == 'from_cloud':
346                src = GS_PUBLIC + COMMITS_FILENAME
347                host.bluetooth_commits_yaml = yaml.safe_load(
348                        read_google_cloud_file(src))
349            elif method == 'from_local':
350                yaml_file_path = os.path.dirname(os.path.realpath(__file__))
351                yaml_file_path = os.path.join(yaml_file_path,
352                                              'bluetooth_commits.yaml')
353                with open(yaml_file_path) as f:
354                    yaml_file = f.read()
355                    host.bluetooth_commits_yaml = yaml.safe_load(yaml_file)
356            else:
357                raise error.TestError('invalid YAML download method: %s',
358                                      method)
359            logging.info('content of yaml file: %s',
360                         host.bluetooth_commits_yaml)
361    except Exception as e:
362        logging.error('Error getting bluetooth_commits.yaml: %s', e)
363
364    return host.bluetooth_commits_yaml
365
366
367def is_in_lab_next_hosts(host):
368    """Check if the host is in the lab_next_hosts
369
370    This function has the side effect that it will set the attribute,
371    host.is_in_lab_next_hosts for caching.
372
373    @param host: the DUT, usually a Chromebook
374
375    @returns: True if the host is in the lab_next_hosts, False otherwise.
376    """
377    if not hasattr(host, 'is_in_lab_next_hosts'):
378        host_build = host.get_release_version()
379        content = get_bluetooth_commits_yaml(host)
380
381        if (host_name(host) in content.get('lab_next_hosts')
382                    and host_build == content.get('lab_next_build')):
383            host.is_in_lab_next_hosts = True
384        else:
385            host.is_in_lab_next_hosts = False
386    return host.is_in_lab_next_hosts
387
388
389def get_latest_commit(host):
390    """ Get the latest_commmit in the bluetooth_commits.yaml
391
392    @param host: the DUT, usually a Chromebook
393
394    @returns: the latest commit hash if exists
395    """
396    try:
397        content = get_bluetooth_commits_yaml(host)
398        latest_commit = content.get('lab_commit_map')[0]['chameleon_commit']
399        logging.info('The latest commit is: %s', latest_commit)
400    except Exception as e:
401        logging.error('Exception in get_latest_commit(): ', str(e))
402    return latest_commit
403
404
405def host_name(host):
406    """ Get the name of a host
407
408    @param host: the DUT, usually a Chromebook
409
410    @returns: the hostname if exists, None otherwise
411    """
412    if hasattr(host, 'hostname'):
413        return host.hostname.rstrip('.cros')
414    else:
415        return None
416
417
418def get_target_commit(host):
419    """ Get the target commit per the DUT
420
421    Download the yaml file containing the commits, parse its contents,
422    and cleanup.
423
424    The yaml file looks like
425    ------------------------
426    lab_curr_commit: d732343cf
427    lab_next_build: 13721.0.0
428    lab_next_commit: 71be114
429    lab_next_hosts:
430      - chromeos15-row8-rack5-host1
431      - chromeos15-row5-rack7-host7
432      - chromeos15-row5-rack1-host4
433    lab_commit_map:
434      - build_version: 14461.0.0
435        chameleon_commit: 87bed79
436      - build_version: 00000.0.0
437        chameleon_commit: 881f0e0
438
439    The lab_next_commit will be used only when 3 conditions are satisfied
440    - the lab_next_commit is non-empty
441    - the hostname of the DUT can be found in lab_next_hosts
442    - the host_build of the DUT is the same as lab_next_build
443
444    Tests of next build will go back to the commits in the lab_commit_map
445    automatically. The purpose is that in case lab_next_commit is not stable,
446    the DUTs will go back to use the supposed stable commit according to the
447    lab_commit_map. Test server will choose the biggest build_version in the
448    lab_commit_map which is smaller than the host_build.
449
450    On the other hand, if lab_next_commit is stable by juding from the lab
451    dashboard, someone can then copy lab_next_build to lab_commit_map manually.
452
453    @param host: the DUT, usually a Chromebook
454
455    @returns commit in case of success; None in case of failure
456    """
457    hostname = host_name(host)
458
459    try:
460        content = get_bluetooth_commits_yaml(host)
461
462        lab_next_commit = content.get('lab_next_commit')
463        if (is_in_lab_next_hosts(host) and bool(lab_next_commit)):
464            commit = lab_next_commit
465            logging.info(
466                    'target commit of the host %s is: %s from the '
467                    'lab_next_commit', hostname, commit)
468        else:
469            host_build = host.get_release_version()
470            lab_commit_map = content.get('lab_commit_map')
471            for item in lab_commit_map:
472                build = item['build_version']
473                if is_chromeos_build_greater_or_equal(host_build, build):
474                    commit = item['chameleon_commit']
475                    break
476            else:
477                logging.error('lab_commit_map is corrupted')
478                commit = None
479            logging.info(
480                    'target commit of the host %s is: %s from the '
481                    'lab_commit_map', hostname, commit)
482
483    except Exception as e:
484        logging.error('Exception %s in get_target_commit()', str(e))
485        commit = None
486    return commit
487
488
489def download_installation_files(host, commit):
490    """ Download the chameleond installation bundle"""
491    src_path = GS_PUBLIC + BUNDLE_TEMPLATE.format(commit)
492    dest_path = '/tmp/' + BUNDLE_TEMPLATE.format(commit)
493    logging.debug('chamelond bundle path is %s', src_path)
494    logging.debug('bundle path in DUT is %s', dest_path)
495
496    cmd = 'gsutil cp {} {}'.format(src_path, dest_path)
497    try:
498        result = utils.run(cmd)
499        if result.exit_status != 0:
500            logging.error('Downloading the chameleond bundle failed with %d',
501                          result.exit_status)
502            return False
503        # Send file to DUT from the test server
504        host.send_file(dest_path, dest_path)
505        logging.debug('file send to %s %s',host, dest_path)
506        return True
507    except Exception as e:
508        logging.error('exception %s in download_installation_files', str(e))
509        return False
510
511
512def cleanup(host, commit):
513    """ Cleanup the installation file from server."""
514
515    dest_path = '/tmp/' + BUNDLE_TEMPLATE.format(commit)
516    # remove file from test server
517    if not os.path.exists(dest_path):
518        logging.debug('File %s not found', dest_path)
519        return True
520
521    try:
522        logging.debug('Remove file %s', dest_path)
523        os.remove(dest_path)
524
525        # remove file from the DUT
526        result = host.run('rm {}'.format(dest_path))
527        if result.exit_status != 0:
528            logging.error('Unable to delete %s on dut', dest_path)
529            return False
530        return True
531    except Exception as e:
532        logging.error('Exception %s in cleanup', str(e))
533        return False
534