autoupdater.py revision c2a15ebfadd2a3cf4ab369b3d0c4a868ce99505c
1# Copyright (c) 2012 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
5import glob
6import httplib
7import logging
8import multiprocessing
9import os
10import re
11import urlparse
12import urllib2
13
14from autotest_lib.client.bin import utils
15from autotest_lib.client.common_lib import error, global_config
16from autotest_lib.client.common_lib.cros import dev_server
17from autotest_lib.client.common_lib.cros.graphite import autotest_stats
18
19
20# Local stateful update path is relative to the CrOS source directory.
21LOCAL_STATEFUL_UPDATE_PATH = 'src/platform/dev/stateful_update'
22LOCAL_CHROOT_STATEFUL_UPDATE_PATH = '/usr/bin/stateful_update'
23UPDATER_IDLE = 'UPDATE_STATUS_IDLE'
24UPDATER_NEED_REBOOT = 'UPDATE_STATUS_UPDATED_NEED_REBOOT'
25# A list of update engine client states that occur after an update is triggered.
26UPDATER_PROCESSING_UPDATE = ['UPDATE_STATUS_CHECKING_FORUPDATE',
27                             'UPDATE_STATUS_UPDATE_AVAILABLE',
28                             'UPDATE_STATUS_DOWNLOADING',
29                             'UPDATE_STATUS_FINALIZING']
30
31class ChromiumOSError(error.InstallError):
32    """Generic error for ChromiumOS-specific exceptions."""
33
34
35class BrilloError(error.InstallError):
36    """Generic error for Brillo-specific exceptions."""
37
38
39class RootFSUpdateError(ChromiumOSError):
40    """Raised when the RootFS fails to update."""
41
42
43class StatefulUpdateError(ChromiumOSError):
44    """Raised when the stateful partition fails to update."""
45
46
47def url_to_version(update_url):
48    """Return the version based on update_url.
49
50    @param update_url: url to the image to update to.
51
52    """
53    # The Chrome OS version is generally the last element in the URL. The only
54    # exception is delta update URLs, which are rooted under the version; e.g.,
55    # http://.../update/.../0.14.755.0/au/0.14.754.0. In this case we want to
56    # strip off the au section of the path before reading the version.
57    return re.sub('/au/.*', '',
58                  urlparse.urlparse(update_url).path).split('/')[-1].strip()
59
60
61def url_to_image_name(update_url):
62    """Return the image name based on update_url.
63
64    From a URL like:
65        http://172.22.50.205:8082/update/lumpy-release/R27-3837.0.0
66    return lumpy-release/R27-3837.0.0
67
68    @param update_url: url to the image to update to.
69    @returns a string representing the image name in the update_url.
70
71    """
72    return '/'.join(urlparse.urlparse(update_url).path.split('/')[-2:])
73
74
75def _get_devserver_build_from_update_url(update_url):
76    """Get the devserver and build from the update url.
77
78    @param update_url: The url for update.
79        Eg: http://devserver:port/update/build.
80
81    @return: A tuple of (devserver url, build) or None if the update_url
82        doesn't match the expected pattern.
83
84    @raises ValueError: If the update_url doesn't match the expected pattern.
85    @raises ValueError: If no global_config was found, or it doesn't contain an
86        image_url_pattern.
87    """
88    pattern = global_config.global_config.get_config_value(
89            'CROS', 'image_url_pattern', type=str, default='')
90    if not pattern:
91        raise ValueError('Cannot parse update_url, the global config needs '
92                'an image_url_pattern.')
93    re_pattern = pattern.replace('%s', '(\S+)')
94    parts = re.search(re_pattern, update_url)
95    if not parts or len(parts.groups()) < 2:
96        raise ValueError('%s is not an update url' % update_url)
97    return parts.groups()
98
99
100def list_image_dir_contents(update_url):
101    """Lists the contents of the devserver for a given build/update_url.
102
103    @param update_url: An update url. Eg: http://devserver:port/update/build.
104    """
105    if not update_url:
106        logging.warning('Need update_url to list contents of the devserver.')
107        return
108    error_msg = 'Cannot check contents of devserver, update url %s' % update_url
109    try:
110        devserver_url, build = _get_devserver_build_from_update_url(update_url)
111    except ValueError as e:
112        logging.warning('%s: %s', error_msg, e)
113        return
114    devserver = dev_server.ImageServer(devserver_url)
115    try:
116        devserver.list_image_dir(build)
117    # The devserver will retry on URLError to avoid flaky connections, but will
118    # eventually raise the URLError if it persists. All HTTPErrors get
119    # converted to DevServerExceptions.
120    except (dev_server.DevServerException, urllib2.URLError) as e:
121        logging.warning('%s: %s', error_msg, e)
122
123
124# TODO(garnold) This implements shared updater functionality needed for
125# supporting the autoupdate_EndToEnd server-side test. We should probably
126# migrate more of the existing ChromiumOSUpdater functionality to it as we
127# expand non-CrOS support in other tests.
128class BaseUpdater(object):
129    """Platform-agnostic DUT update functionality."""
130
131    def __init__(self, updater_ctrl_bin, update_url, host):
132        """Initializes the object.
133
134        @param updater_ctrl_bin: Path to update_engine_client.
135        @param update_url: The URL we want the update to use.
136        @param host: A client.common_lib.hosts.Host implementation.
137        """
138        self.updater_ctrl_bin = updater_ctrl_bin
139        self.update_url = update_url
140        self.host = host
141        self._update_error_queue = multiprocessing.Queue(2)
142
143
144    def check_update_status(self):
145        """Returns the current update engine state.
146
147        We use the `update_engine_client -status' command and parse the line
148        indicating the update state, e.g. "CURRENT_OP=UPDATE_STATUS_IDLE".
149        """
150        update_status = self.host.run(
151            '%s -status 2>&1 | grep CURRENT_OP' % self.updater_ctrl_bin)
152        return update_status.stdout.strip().split('=')[-1]
153
154
155    def get_last_update_error(self):
156        """Get the last autoupdate error code."""
157        error_msg = self.host.run(
158                 '%s --last_attempt_error' % self.updater_ctrl_bin)
159        error_msg = (error_msg.stdout.strip()).replace('\n', ', ')
160        return error_msg
161
162
163    def _base_update_handler(self, run_args, err_msg_prefix=None):
164        """Base function to handle a remote update ssh call.
165
166        @param run_args: Dictionary of args passed to ssh_host.run function.
167        @param err_msg_prefix: Prefix of the exception error message.
168
169        @returns: The exception thrown, None if no exception.
170        """
171        to_raise = None
172        err_msg = err_msg_prefix
173        try:
174            self.host.run(**run_args)
175        except (error.AutoservSshPermissionDeniedError,
176                error.AutoservSSHTimeout) as e:
177            logging.exception(e)
178            err_msg += 'SSH reports an error: %s' % type(e).__name__
179            to_raise = RootFSUpdateError(err_msg)
180        except error.AutoservRunError as e:
181            logging.exception(e)
182            # Check if exit code is 255, if so it's probably a generic SSH error
183            result = e.args[1]
184            if result.exit_status == 255:
185                err_msg += ('SSH reports a generic error (255) which is '
186                            'probably a lab network failure')
187                to_raise = RootFSUpdateError(err_msg)
188
189            # We have ruled out all SSH cases, the error code is from
190            # update_engine_client.
191            else:
192                list_image_dir_contents(self.update_url)
193                err_msg += ('Update failed. Returned update_engine error code: '
194                            '%s. Reported error: %s' %
195                            (self.get_last_update_error(), type(e).__name__))
196                to_raise = RootFSUpdateError(err_msg)
197        except Exception as e:
198            to_raise = e
199
200        return to_raise
201
202
203    def trigger_update(self):
204        """Triggers a background update.
205
206        @raise RootFSUpdateError or unknown Exception if anything went wrong.
207        """
208        autoupdate_cmd = ('%s --check_for_update --omaha_url=%s' %
209                          (self.updater_ctrl_bin, self.update_url))
210        run_args = {'command': autoupdate_cmd}
211        err_prefix = 'Failed to trigger an update on %s. ' % self.host.hostname
212        logging.info('Triggering update via: %s', autoupdate_cmd)
213        to_raise = self._base_update_handler(run_args, err_prefix)
214        if to_raise:
215          raise to_raise
216
217
218    def _verify_update_completed(self):
219        """Verifies that an update has completed.
220
221        @raise RootFSUpdateError: if verification fails.
222        """
223        status = self.check_update_status()
224        if status != UPDATER_NEED_REBOOT:
225            error_msg = ''
226            if status == UPDATER_IDLE:
227                error_msg = 'Update error: %s' % self.get_last_update_error()
228            raise RootFSUpdateError('Update did not complete with correct '
229                                    'status. Expecting %s, actual %s. %s' %
230                                    (UPDATER_NEED_REBOOT, status, error_msg))
231
232
233    def update_image(self):
234        """Updates the device image and verifies success."""
235        autoupdate_cmd = ('%s --update --omaha_url=%s 2>&1' %
236                          (self.updater_ctrl_bin, self.update_url))
237        run_args = {'command': autoupdate_cmd, 'timeout': 3600}
238        err_prefix = ('Failed to install device image using payload at %s '
239                      'on %s. ' % (self.update_url, self.host.hostname))
240        logging.info('Updating image via: %s', autoupdate_cmd)
241        to_raise = self._base_update_handler(run_args, err_prefix)
242        if to_raise:
243            self._update_error_queue.put(to_raise)
244            raise to_raise
245
246        try:
247            self._verify_update_completed()
248        except RootFSUpdateError as e:
249            self._update_error_queue.put(e)
250            raise
251
252
253class ChromiumOSUpdater(BaseUpdater):
254    """Helper class used to update DUT with image of desired version."""
255    REMOTE_STATEUL_UPDATE_PATH = '/usr/local/bin/stateful_update'
256    UPDATER_BIN = '/usr/bin/update_engine_client'
257    STATEFUL_UPDATE = '/tmp/stateful_update'
258    UPDATED_MARKER = '/var/run/update_engine_autoupdate_completed'
259    UPDATER_LOGS = ['/var/log/messages', '/var/log/update_engine']
260
261    KERNEL_A = {'name': 'KERN-A', 'kernel': 2, 'root': 3}
262    KERNEL_B = {'name': 'KERN-B', 'kernel': 4, 'root': 5}
263    # Time to wait for new kernel to be marked successful after
264    # auto update.
265    KERNEL_UPDATE_TIMEOUT = 120
266
267    _timer = autotest_stats.Timer('cros_autoupdater')
268
269    def __init__(self, update_url, host=None, local_devserver=False):
270        super(ChromiumOSUpdater, self).__init__(self.UPDATER_BIN, update_url,
271                                                host)
272        self.local_devserver = local_devserver
273        if not local_devserver:
274            self.update_version = url_to_version(update_url)
275        else:
276            self.update_version = None
277
278
279    def reset_update_engine(self):
280        """Resets the host to prepare for a clean update regardless of state."""
281        self._run('rm -f %s' % self.UPDATED_MARKER)
282        self._run('stop ui || true')
283        self._run('stop update-engine || true')
284        self._run('start update-engine')
285
286        if self.check_update_status() != UPDATER_IDLE:
287            raise ChromiumOSError('%s is not in an installable state' %
288                                  self.host.hostname)
289
290
291    def _run(self, cmd, *args, **kwargs):
292        """Abbreviated form of self.host.run(...)"""
293        return self.host.run(cmd, *args, **kwargs)
294
295
296    def rootdev(self, options=''):
297        """Returns the stripped output of rootdev <options>.
298
299        @param options: options to run rootdev.
300
301        """
302        return self._run('rootdev %s' % options).stdout.strip()
303
304
305    def get_kernel_state(self):
306        """Returns the (<active>, <inactive>) kernel state as a pair."""
307        active_root = int(re.findall('\d+\Z', self.rootdev('-s'))[0])
308        if active_root == self.KERNEL_A['root']:
309            return self.KERNEL_A, self.KERNEL_B
310        elif active_root == self.KERNEL_B['root']:
311            return self.KERNEL_B, self.KERNEL_A
312        else:
313            raise ChromiumOSError('Encountered unknown root partition: %s' %
314                                  active_root)
315
316
317    def _cgpt(self, flag, kernel, dev='$(rootdev -s -d)'):
318        """Return numeric cgpt value for the specified flag, kernel, device. """
319        return int(self._run('cgpt show -n -i %d %s %s' % (
320            kernel['kernel'], flag, dev)).stdout.strip())
321
322
323    def get_kernel_priority(self, kernel):
324        """Return numeric priority for the specified kernel.
325
326        @param kernel: information of the given kernel, KERNEL_A or KERNEL_B.
327
328        """
329        return self._cgpt('-P', kernel)
330
331
332    def get_kernel_success(self, kernel):
333        """Return boolean success flag for the specified kernel.
334
335        @param kernel: information of the given kernel, KERNEL_A or KERNEL_B.
336
337        """
338        return self._cgpt('-S', kernel) != 0
339
340
341    def get_kernel_tries(self, kernel):
342        """Return tries count for the specified kernel.
343
344        @param kernel: information of the given kernel, KERNEL_A or KERNEL_B.
345
346        """
347        return self._cgpt('-T', kernel)
348
349
350    def get_stateful_update_script(self):
351        """Returns the path to the stateful update script on the target."""
352        # We attempt to load the local stateful update path in 3 different
353        # ways. First we use the location specified in the autotest global
354        # config. If this doesn't exist, we attempt to use the Chromium OS
355        # Chroot path to the installed script. If all else fails, we use the
356        # stateful update script on the host.
357        stateful_update_path = os.path.join(
358                global_config.global_config.get_config_value(
359                        'CROS', 'source_tree', default=''),
360                LOCAL_STATEFUL_UPDATE_PATH)
361
362        if not os.path.exists(stateful_update_path):
363            logging.warning('Could not find Chrome OS source location for '
364                            'stateful_update script at %s, falling back to '
365                            'chroot copy.', stateful_update_path)
366            stateful_update_path = LOCAL_CHROOT_STATEFUL_UPDATE_PATH
367
368        if not os.path.exists(stateful_update_path):
369            logging.warning('Could not chroot stateful_update script, falling '
370                            'back on client copy.')
371            statefuldev_script = self.REMOTE_STATEUL_UPDATE_PATH
372        else:
373            self.host.send_file(
374                    stateful_update_path, self.STATEFUL_UPDATE,
375                    delete_dest=True)
376            statefuldev_script = self.STATEFUL_UPDATE
377
378        return statefuldev_script
379
380
381    def reset_stateful_partition(self):
382        """Clear any pending stateful update request."""
383        statefuldev_cmd = [self.get_stateful_update_script()]
384        statefuldev_cmd += ['--stateful_change=reset', '2>&1']
385        self._run(' '.join(statefuldev_cmd))
386
387
388    def revert_boot_partition(self):
389        """Revert the boot partition."""
390        part = self.rootdev('-s')
391        logging.warning('Reverting update; Boot partition will be %s', part)
392        return self._run('/postinst %s 2>&1' % part)
393
394
395    def rollback_rootfs(self, powerwash):
396        """Triggers rollback and waits for it to complete.
397
398        @param powerwash: If true, powerwash as part of rollback.
399
400        @raise RootFSUpdateError if anything went wrong.
401
402        """
403        version = self.host.get_release_version()
404        # Introduced can_rollback in M36 (build 5772). # etc/lsb-release matches
405        # X.Y.Z. This version split just pulls the first part out.
406        try:
407            build_number = int(version.split('.')[0])
408        except ValueError:
409            logging.error('Could not parse build number.')
410            build_number = 0
411
412        if build_number >= 5772:
413            can_rollback_cmd = '%s --can_rollback' % self.UPDATER_BIN
414            logging.info('Checking for rollback.')
415            try:
416                self._run(can_rollback_cmd)
417            except error.AutoservRunError as e:
418                raise RootFSUpdateError("Rollback isn't possible on %s: %s" %
419                                        (self.host.hostname, str(e)))
420
421        rollback_cmd = '%s --rollback --follow' % self.UPDATER_BIN
422        if not powerwash:
423            rollback_cmd += ' --nopowerwash'
424
425        logging.info('Performing rollback.')
426        try:
427            self._run(rollback_cmd)
428        except error.AutoservRunError as e:
429            raise RootFSUpdateError('Rollback failed on %s: %s' %
430                                    (self.host.hostname, str(e)))
431
432        self._verify_update_completed()
433
434
435    # TODO(garnold) This is here for backward compatibility and should be
436    # deprecated once we shift to using update_image() everywhere.
437    @_timer.decorate
438    def update_rootfs(self):
439        """Run the standard command to force an update."""
440        return self.update_image()
441
442
443    @_timer.decorate
444    def update_stateful(self, clobber=True):
445        """Updates the stateful partition.
446
447        @param clobber: If True, a clean stateful installation.
448        """
449        logging.info('Updating stateful partition...')
450        statefuldev_url = self.update_url.replace('update',
451                                                  'static')
452
453        # Attempt stateful partition update; this must succeed so that the newly
454        # installed host is testable after update.
455        statefuldev_cmd = [self.get_stateful_update_script(), statefuldev_url]
456        if clobber:
457            statefuldev_cmd.append('--stateful_change=clean')
458
459        statefuldev_cmd.append('2>&1')
460        try:
461            self._run(' '.join(statefuldev_cmd), timeout=1200)
462        except error.AutoservRunError:
463            update_error = StatefulUpdateError(
464                    'Failed to perform stateful update on %s' %
465                    self.host.hostname)
466            self._update_error_queue.put(update_error)
467            raise update_error
468        except Exception as e:
469            # Don't allow other exceptions to not be caught.
470            self._update_error_queue.put(e)
471            raise e
472
473
474    @_timer.decorate
475    def run_update(self, update_root=True):
476        """Update the DUT with image of specific version.
477
478        @param update_root: True to force a rootfs update.
479        """
480        booted_version = self.host.get_release_version()
481        if self.update_version:
482            logging.info('Updating from version %s to %s.',
483                         booted_version, self.update_version)
484
485        # Check that Dev Server is accepting connections (from autoserv's host).
486        # If we can't talk to it, the machine host probably can't either.
487        auserver_host = urlparse.urlparse(self.update_url)[1]
488        try:
489            httplib.HTTPConnection(auserver_host).connect()
490        except IOError:
491            raise ChromiumOSError(
492                'Update server at %s not available' % auserver_host)
493
494        logging.info('Installing from %s to %s', self.update_url,
495                     self.host.hostname)
496
497        # Reset update state.
498        self.reset_update_engine()
499        self.reset_stateful_partition()
500
501        try:
502            updaters = [
503                multiprocessing.process.Process(target=self.update_rootfs),
504                multiprocessing.process.Process(target=self.update_stateful)
505                ]
506            if not update_root:
507                logging.info('Root update is skipped.')
508                updaters = updaters[1:]
509
510            # Run the updaters in parallel.
511            for updater in updaters: updater.start()
512            for updater in updaters: updater.join()
513
514            # Re-raise the first error that occurred.
515            if not self._update_error_queue.empty():
516                update_error = self._update_error_queue.get()
517                self.revert_boot_partition()
518                self.reset_stateful_partition()
519                raise update_error
520
521            logging.info('Update complete.')
522        except:
523            # Collect update engine logs in the event of failure.
524            if self.host.job:
525                logging.info('Collecting update engine logs...')
526                self.host.get_file(
527                        self.UPDATER_LOGS, self.host.job.sysinfo.sysinfodir,
528                        preserve_perm=False)
529            list_image_dir_contents(self.update_url)
530            raise
531        finally:
532            logging.info('Update engine log has downloaded in '
533                         'sysinfo/update_engine dir. Check the lastest.')
534
535
536    def check_version(self):
537        """Check the image running in DUT has the desired version.
538
539        @returns: True if the DUT's image version matches the version that
540            the autoupdater tries to update to.
541
542        """
543        booted_version = self.host.get_release_version()
544        return (self.update_version and
545                self.update_version.endswith(booted_version))
546
547
548    def check_version_to_confirm_install(self):
549        """Check image running in DUT has the desired version to be installed.
550
551        The method should not be used to check if DUT needs to have a full
552        reimage. Only use it to confirm a image is installed.
553
554        The method is designed to verify version for following 6 scenarios with
555        samples of version to update to and expected booted version:
556        1. trybot paladin build.
557        update version: trybot-lumpy-paladin/R27-3837.0.0-b123
558        booted version: 3837.0.2013_03_21_1340
559
560        2. trybot release build.
561        update version: trybot-lumpy-release/R27-3837.0.0-b456
562        booted version: 3837.0.0
563
564        3. buildbot official release build.
565        update version: lumpy-release/R27-3837.0.0
566        booted version: 3837.0.0
567
568        4. non-official paladin rc build.
569        update version: lumpy-paladin/R27-3878.0.0-rc7
570        booted version: 3837.0.0-rc7
571
572        5. chrome-perf build.
573        update version: lumpy-chrome-perf/R28-3837.0.0-b2996
574        booted version: 3837.0.0
575
576        6. pgo-generate build.
577        update version: lumpy-release-pgo-generate/R28-3837.0.0-b2996
578        booted version: 3837.0.0-pgo-generate
579
580        When we are checking if a DUT needs to do a full install, we should NOT
581        use this method to check if the DUT is running the same version, since
582        it may return false positive for a DUT running trybot paladin build to
583        be updated to another trybot paladin build.
584
585        TODO: This logic has a bug if a trybot paladin build failed to be
586        installed in a DUT running an older trybot paladin build with same
587        platform number, but different build number (-b###). So to conclusively
588        determine if a tryjob paladin build is imaged successfully, we may need
589        to find out the date string from update url.
590
591        @returns: True if the DUT's image version (without the date string if
592            the image is a trybot build), matches the version that the
593            autoupdater is trying to update to.
594
595        """
596        # In the local_devserver case, we can't know the expected
597        # build, so just pass.
598        if not self.update_version:
599            return True
600
601        # Always try the default check_version method first, this prevents
602        # any backward compatibility issue.
603        if self.check_version():
604            return True
605
606        return utils.version_match(self.update_version,
607                                   self.host.get_release_version(),
608                                   self.update_url)
609
610
611    def verify_boot_expectations(self, expected_kernel_state, rollback_message):
612        """Verifies that we fully booted given expected kernel state.
613
614        This method both verifies that we booted using the correct kernel
615        state and that the OS has marked the kernel as good.
616
617        @param expected_kernel_state: kernel state that we are verifying with
618            i.e. I expect to be booted onto partition 4 etc. See output of
619            get_kernel_state.
620        @param rollback_message: string to raise as a ChromiumOSError
621            if we booted with the wrong partition.
622
623        @raises ChromiumOSError: If we didn't.
624        """
625        # Figure out the newly active kernel.
626        active_kernel_state = self.get_kernel_state()[0]
627
628        # Check for rollback due to a bad build.
629        if (expected_kernel_state and
630                active_kernel_state != expected_kernel_state):
631
632            # Kernel crash reports should be wiped between test runs, but
633            # may persist from earlier parts of the test, or from problems
634            # with provisioning.
635            #
636            # Kernel crash reports will NOT be present if the crash happened
637            # before encrypted stateful is mounted.
638            #
639            # TODO(dgarrett): Integrate with server/crashcollect.py at some
640            # point.
641            kernel_crashes = glob.glob('/var/spool/crash/kernel.*.kcrash')
642            if kernel_crashes:
643                rollback_message += ': kernel_crash'
644                logging.debug('Found %d kernel crash reports:',
645                              len(kernel_crashes))
646                # The crash names contain timestamps that may be useful:
647                #   kernel.20131207.005945.0.kcrash
648                for crash in kernel_crashes:
649                    logging.debug('  %s', os.path.basename(crash))
650
651            # Print out some information to make it easier to debug
652            # the rollback.
653            logging.debug('Dumping partition table.')
654            self._run('cgpt show $(rootdev -s -d)')
655            logging.debug('Dumping crossystem for firmware debugging.')
656            self._run('crossystem --all')
657            raise ChromiumOSError(rollback_message)
658
659        # Make sure chromeos-setgoodkernel runs.
660        try:
661            utils.poll_for_condition(
662                lambda: (self.get_kernel_tries(active_kernel_state) == 0
663                         and self.get_kernel_success(active_kernel_state)),
664                exception=ChromiumOSError(),
665                timeout=self.KERNEL_UPDATE_TIMEOUT, sleep_interval=5)
666        except ChromiumOSError:
667            services_status = self._run('status system-services').stdout
668            if services_status != 'system-services start/running\n':
669                event = ('Chrome failed to reach login screen')
670            else:
671                event = ('update-engine failed to call '
672                         'chromeos-setgoodkernel')
673            raise ChromiumOSError(
674                    'After update and reboot, %s '
675                    'within %d seconds' % (event,
676                                           self.KERNEL_UPDATE_TIMEOUT))
677
678
679class BrilloUpdater(BaseUpdater):
680    """Helper class for updating a Brillo DUT."""
681
682    def __init__(self, update_url, host=None):
683        """Initialize the object.
684
685        @param update_url: The URL we want the update to use.
686        @param host: A client.common_lib.hosts.Host implementation.
687        """
688        super(BrilloUpdater, self).__init__(
689                '/system/bin/update_engine_client', update_url, host)
690