| # Copyright (c) 2012 The Chromium OS Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| |
| import logging |
| import os |
| import shutil |
| import subprocess |
| import sys |
| import threading |
| import traceback |
| from urlparse import urlparse |
| import uuid |
| |
| |
| from cros.factory.test import factory |
| from cros.factory.test import shopfloor |
| from cros.factory.utils.process_utils import Spawn |
| |
| |
| class UpdaterException(Exception): |
| pass |
| |
| |
| def CheckCriticalFiles(new_path): |
| '''Raises an exception if certain critical files are missing.''' |
| critical_files = [ |
| os.path.join(new_path, f) |
| for f in ['factory/MD5SUM', |
| 'factory/py_pkg/cros/factory/goofy/goofy.py', |
| 'factory/py/test/pytests/finalize/finalize.py']] |
| missing_files = [f for f in critical_files |
| if not os.path.exists(f)] |
| if missing_files: |
| raise UpdaterException( |
| 'Aborting update: Missing critical files %r' % missing_files) |
| |
| |
| def RunRsync(*rsync_command): |
| '''Runs rsync with the given command.''' |
| rsync = Spawn(rsync_command, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.STDOUT, |
| log=factory.console) |
| stdout, _ = rsync.communicate() |
| if stdout: |
| factory.console.info('rsync output: %s', stdout) |
| if rsync.returncode: |
| raise UpdaterException('rsync returned status %d; aborting' % |
| rsync.returncode) |
| factory.console.info('rsync succeeded') |
| |
| |
| def TryUpdate(pre_update_hook=None, timeout=15): |
| '''Attempts to update the factory and autotest directory on the device. |
| |
| Atomically replaces the factory and autotest directory with new contents. |
| This routine will always fail in the chroot (to avoid destroying |
| the user's working directory). |
| |
| Args: |
| pre_update_hook: A routine to be invoked before the |
| autotest directory is swapped out. |
| timeout: This timeout serves at two places. |
| 1. Timeout in seconds for RPC calls on the proxy which provides |
| remote services on shopfloor server. |
| 2. I/O timeout of rsync in seconds. |
| |
| Returns: |
| True if an update was performed and the machine should be |
| rebooted. |
| ''' |
| # On a real device, this will resolve to 'autotest' (since 'client' |
| # is a symlink to that). In the chroot, this will resolve to the |
| # 'client' directory. |
| # Determine whether an update is necessary. |
| current_md5sum = factory.get_current_md5sum() |
| |
| url = shopfloor.get_server_url() or shopfloor.detect_default_server_url() |
| factory.console.info( |
| 'Checking for updates at <%s>... (current MD5SUM is %s)', |
| url, current_md5sum) |
| |
| shopfloor_client = shopfloor.get_instance(detect=True, timeout=timeout) |
| new_md5sum = shopfloor_client.GetTestMd5sum() |
| factory.console.info('MD5SUM from server is %s', new_md5sum) |
| if current_md5sum == new_md5sum or new_md5sum is None: |
| factory.console.info('Factory software is up to date') |
| return False |
| |
| # /usr/local on the device (parent to both factory and autotest) |
| parent_dir = os.path.dirname(factory.FACTORY_PATH) |
| |
| # An update is necessary. Construct the rsync command. |
| update_port = shopfloor_client.GetUpdatePort() |
| |
| # For autotest, do not use hard links and new directory. Since the |
| # files in autotests are too big. Also, they will be different |
| # binaries than those in the updater if they are from different images. |
| # Just rsync them in place. If it fail, it can still get update again. |
| autotest_path = os.path.join(parent_dir, 'autotest') |
| RunRsync( |
| 'rsync', |
| '-a', '--delete', '--stats', |
| '--timeout=%s' % timeout, |
| 'rsync://%s:%d/factory/%s/autotest/' % ( |
| urlparse(url).hostname, |
| update_port, |
| new_md5sum), |
| '%s/' % autotest_path) |
| |
| |
| new_path = os.path.join(parent_dir, 'updater.new') |
| # rsync --link-dest considers any existing files to be definitive, |
| # so wipe anything that's already there. |
| if os.path.exists(new_path): |
| shutil.rmtree(new_path) |
| |
| RunRsync( |
| 'rsync', |
| '-a', '--delete', '--stats', |
| '--timeout=%s' % timeout, |
| # Use hard links of identical files from the old directories to |
| # save network bandwidth and temporary space on disk. |
| '--link-dest=%s' % parent_dir, |
| 'rsync://%s:%d/factory/%s/factory' % ( |
| urlparse(url).hostname, |
| update_port, |
| new_md5sum), |
| '%s/' % new_path) |
| |
| hwid_path = os.path.join(factory.FACTORY_PATH, 'hwid') |
| new_hwid_path = os.path.join(new_path, 'factory', 'hwid') |
| if os.path.exists(hwid_path) and not os.path.exists(new_hwid_path): |
| RunRsync( |
| 'rsync', '-a', |
| hwid_path, '%s/factory' % new_path) |
| |
| CheckCriticalFiles(new_path) |
| |
| new_md5sum_path = os.path.join(new_path, 'factory', 'MD5SUM') |
| new_md5sum_from_fs = open(new_md5sum_path).read().strip() |
| if new_md5sum != new_md5sum_from_fs: |
| raise UpdaterException( |
| 'Unexpected MD5SUM in %s: expected %s but found %s' % |
| new_md5sum_path, new_md5sum, new_md5sum_from_fs) |
| |
| if factory.in_chroot(): |
| raise UpdaterException('Aborting update: In chroot') |
| |
| # Alright, here we go! This is the point of no return. |
| if pre_update_hook: |
| pre_update_hook() |
| |
| old_path = os.path.join(parent_dir, 'updater.old.%s' % uuid.uuid4()) |
| # If one of these fails, we're screwed. |
| for d in ['factory']: |
| shutil.move(os.path.join(parent_dir, d), old_path) |
| shutil.move(os.path.join(new_path, d), parent_dir) |
| # Delete the old and new trees |
| shutil.rmtree(old_path, ignore_errors=True) |
| shutil.rmtree(new_path, ignore_errors=True) |
| factory.console.info('Update successful') |
| return True |
| |
| |
| def CheckForUpdate(timeout): |
| '''Checks for an update synchronously. |
| |
| Raises: |
| An exception if unable to contact the shopfloor server. |
| |
| Returns: |
| A tuple (md5sum, needs_update): |
| md5sum: the MD5SUM returned by shopfloor server, or None if it is not |
| available or test environment is not installed on the shopfloor server |
| yet. |
| needs_update: is True if an update is necessary (i.e., md5sum is not None, |
| and md5sum isn't the same as the MD5SUM in the current autotest |
| directory). |
| ''' |
| shopfloor_client = shopfloor.get_instance(detect=True, timeout=timeout) |
| new_md5sum = shopfloor_client.GetTestMd5sum() |
| current_md5sum = factory.get_current_md5sum() |
| needs_update = shopfloor_client.NeedsUpdate(current_md5sum) |
| return (new_md5sum, needs_update) |
| |
| |
| def CheckForUpdateAsync(callback, timeout): |
| '''Checks for an update asynchronously. |
| |
| Launches a separate thread, checks for an update, and invokes callback (in |
| at most timeout seconds) in that separate thread with the following arguments: |
| |
| callback(reached_shopfloor, md5sum, needs_update) |
| |
| reached_shopfloor is True if the updater was actually able to communicate |
| with the shopfloor server, or False on timeout. |
| |
| md5sum and needs_update are as in the return value for CheckForUpdate. |
| ''' |
| def Run(): |
| try: |
| callback(True, *CheckForUpdate(timeout)) |
| except: # pylint: disable=W0702 |
| # Just an info, not a trace, since this is pretty common (and not |
| # necessarily an error) and we don't want logs to get out of control. |
| logging.info( |
| 'Unable to contact shopfloor server to check for updates: %s', |
| '\n'.join(traceback.format_exception_only(*sys.exc_info()[:2])).strip()) |
| callback(False, None, False) |
| |
| update_thread = threading.Thread(target=Run, name='UpdateThread') |
| update_thread.daemon = True |
| update_thread.start() |
| return |