• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# -*- coding: utf-8 -*-
2# Copyright 2019 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"""Library containing functions to transfer files onto a remote device.
7
8Transfer Base class includes:
9
10  ----Tranfer----
11  * @retry functionality for all public transfer functions.
12
13LocalTransfer includes:
14
15  ----Precheck---
16  * Pre-check payload's existence before auto-update.
17
18  ----Tranfer----
19  * Transfer update-utils (nebraska, et. al.) package at first.
20  * Transfer rootfs update files if rootfs update is required.
21  * Transfer stateful update files if stateful update is required.
22
23LabTransfer includes:
24
25  ----Precheck---
26  * Pre-check payload's existence on the staging server before auto-update.
27
28  ----Tranfer----
29  * Download the update-utils (nebraska, et. al.) package onto the DUT directly
30    from the staging server at first.
31  * Download rootfs update files onto the DUT directly from the staging server
32    if rootfs update is required.
33  * Download stateful update files onto the DUT directly from the staging server
34    if stateful update is required.
35"""
36
37from __future__ import absolute_import
38from __future__ import division
39from __future__ import print_function
40
41import abc
42import json
43import os
44import re
45
46import six
47from six.moves import urllib
48
49from autotest_lib.utils.frozen_chromite.lib import cros_build_lib
50from autotest_lib.utils.frozen_chromite.lib import cros_logging as logging
51from autotest_lib.utils.frozen_chromite.lib import nebraska_wrapper
52from autotest_lib.utils.frozen_chromite.lib import osutils
53from autotest_lib.utils.frozen_chromite.lib import retry_util
54
55# Naming conventions for global variables:
56#   Path on remote host with slash: REMOTE_XXX_PATH
57#   File on local server without slash: LOCAL_XXX_FILENAME
58#   Path on local server: LOCAL_XXX_PATH
59
60# Max number of the times for retry:
61# 1. for transfer functions to be retried.
62# 2. for some retriable commands to be retried.
63_MAX_RETRY = 5
64
65# The delay between retriable tasks.
66_DELAY_SEC_FOR_RETRY = 5
67
68# Update file names for rootfs+kernel and stateful partitions.
69ROOTFS_FILENAME = 'update.gz'
70STATEFUL_FILENAME = 'stateful.tgz'
71
72# Regular expression that is used to evaluate payload names to determine payload
73# validity.
74_PAYLOAD_PATTERN = r'payloads/chromeos_(?P<image_version>[^_]+)_.*'
75
76# File copying modes.
77_SCP = 'scp'
78
79
80class Error(Exception):
81  """A generic auto updater transfer error."""
82
83
84class ChromiumOSTransferError(Error):
85  """Thrown when there is a general transfer specific error."""
86
87
88def GetPayloadPropertiesFileName(payload):
89  """Returns the payload properties file given the path to the payload."""
90  return payload + '.json'
91
92
93class Transfer(six.with_metaclass(abc.ABCMeta, object)):
94  """Abstract Base Class that handles payload precheck and transfer."""
95
96  PAYLOAD_DIR_NAME = 'payloads'
97
98  def __init__(self, device, payload_dir, tempdir,
99               payload_name, cmd_kwargs, device_payload_dir,
100               payload_mode='scp', transfer_stateful_update=True,
101               transfer_rootfs_update=True):
102    """Initialize Base Class for transferring payloads functionality.
103
104    Args:
105      device: The ChromiumOSDevice to be updated.
106      payload_dir: The directory of payload(s).
107      tempdir: The temp directory in caller, not in the device. For example,
108          the tempdir for cros flash is /tmp/cros-flash****/, used to
109          temporarily keep files when transferring update-utils package, and
110          reserve nebraska and update engine logs.
111      payload_name: Filename of exact payload file to use for update.
112      cmd_kwargs: Keyword arguments that are sent along with the commands that
113          are run on the device.
114      device_payload_dir: Path to the payload directory in the device's work
115          directory.
116      payload_mode: The payload mode - it can be 'parallel' or 'scp'.
117      transfer_stateful_update: Whether to transfer payloads necessary for
118          stateful update. The default is True.
119      transfer_rootfs_update: Whether to transfer payloads necessary for
120          rootfs update. The default is True.
121    """
122    self._device = device
123    self._payload_dir = payload_dir
124    self._tempdir = tempdir
125    self._payload_name = payload_name
126    self._cmd_kwargs = cmd_kwargs
127    self._device_payload_dir = device_payload_dir
128    if payload_mode not in ('scp', 'parallel'):
129      raise ValueError('The given value %s for payload mode is not valid.' %
130                       payload_mode)
131    self._payload_mode = payload_mode
132    self._transfer_stateful_update = transfer_stateful_update
133    self._transfer_rootfs_update = transfer_rootfs_update
134    self._local_payload_props_path = None
135
136  @abc.abstractmethod
137  def CheckPayloads(self):
138    """Verify that all required payloads are in |self.payload_dir|."""
139
140  def TransferUpdateUtilsPackage(self):
141    """Transfer update-utils package to work directory of the remote device."""
142    retry_util.RetryException(
143        cros_build_lib.RunCommandError,
144        _MAX_RETRY,
145        self._TransferUpdateUtilsPackage,
146        delay_sec=_DELAY_SEC_FOR_RETRY)
147
148  def TransferRootfsUpdate(self):
149    """Transfer files for rootfs update.
150
151    The corresponding payloads are copied to the remote device for rootfs
152    update.
153    """
154    retry_util.RetryException(
155        cros_build_lib.RunCommandError,
156        _MAX_RETRY,
157        self._TransferRootfsUpdate,
158        delay_sec=_DELAY_SEC_FOR_RETRY)
159
160  def TransferStatefulUpdate(self):
161    """Transfer files for stateful update.
162
163    The stateful update bin and the corresponding payloads are copied to the
164    target remote device for stateful update.
165    """
166    retry_util.RetryException(
167        cros_build_lib.RunCommandError,
168        _MAX_RETRY,
169        self._TransferStatefulUpdate,
170        delay_sec=_DELAY_SEC_FOR_RETRY)
171
172  def _EnsureDeviceDirectory(self, directory):
173    """Mkdir the directory no matther whether this directory exists on host.
174
175    Args:
176      directory: The directory to be made on the device.
177    """
178    self._device.run(['mkdir', '-p', directory], **self._cmd_kwargs)
179
180  @abc.abstractmethod
181  def GetPayloadPropsFile(self):
182    """Get the payload properties file path."""
183
184  @abc.abstractmethod
185  def GetPayloadProps(self):
186    """Gets properties necessary to fix the payload properties file.
187
188    Returns:
189      Dict in the format: {'image_version': 12345.0.0, 'size': 123456789}.
190    """
191
192  def _GetPayloadFormat(self):
193    """Gets the payload format that should be evaluated.
194
195    Returns:
196      The payload name as a string.
197    """
198    return self._payload_name
199
200  def _GetPayloadPattern(self):
201    """The regex pattern that the payload format must match.
202
203    Returns:
204      Regular expression.
205    """
206    return _PAYLOAD_PATTERN
207
208
209class LocalTransfer(Transfer):
210  """Abstracts logic that handles transferring local files to the DUT."""
211
212  def __init__(self, *args, **kwargs):
213    """Initialize LocalTransfer to handle transferring files from local to DUT.
214
215    Args:
216      *args: The list of arguments to be passed. See Base class for a complete
217          list of accepted arguments.
218      **kwargs: Any keyword arguments to be passed. See Base class for a
219          complete list of accepted keyword arguments.
220    """
221    super(LocalTransfer, self).__init__(*args, **kwargs)
222
223  def CheckPayloads(self):
224    """Verify that all required payloads are in |self.payload_dir|."""
225    logging.debug('Checking if payloads have been stored in directory %s...',
226                  self._payload_dir)
227    filenames = []
228
229    if self._transfer_rootfs_update:
230      filenames += [self._payload_name,
231                    GetPayloadPropertiesFileName(self._payload_name)]
232
233    if self._transfer_stateful_update:
234      filenames += [STATEFUL_FILENAME]
235
236    for fname in filenames:
237      payload = os.path.join(self._payload_dir, fname)
238      if not os.path.exists(payload):
239        raise ChromiumOSTransferError('Payload %s does not exist!' % payload)
240
241  def _TransferUpdateUtilsPackage(self):
242    """Transfer update-utils package to work directory of the remote device."""
243    logging.notice('Copying update script to device...')
244    source_dir = os.path.join(self._tempdir, 'src')
245    osutils.SafeMakedirs(source_dir)
246    nebraska_wrapper.RemoteNebraskaWrapper.GetNebraskaSrcFile(source_dir)
247
248    # Make sure the device.work_dir exists after any installation and reboot.
249    self._EnsureDeviceDirectory(self._device.work_dir)
250    # Python packages are plain text files.
251    self._device.CopyToWorkDir(source_dir, mode=_SCP, log_output=True,
252                               **self._cmd_kwargs)
253
254  def _TransferRootfsUpdate(self):
255    """Transfer files for rootfs update.
256
257    Copy the update payload to the remote device for rootfs update.
258    """
259    self._EnsureDeviceDirectory(self._device_payload_dir)
260    logging.notice('Copying rootfs payload to device...')
261    payload = os.path.join(self._payload_dir, self._payload_name)
262    self._device.CopyToWorkDir(payload, self.PAYLOAD_DIR_NAME,
263                               mode=self._payload_mode,
264                               log_output=True, **self._cmd_kwargs)
265    payload_properties_path = GetPayloadPropertiesFileName(payload)
266    self._device.CopyToWorkDir(payload_properties_path, self.PAYLOAD_DIR_NAME,
267                               mode=self._payload_mode,
268                               log_output=True, **self._cmd_kwargs)
269
270  def _TransferStatefulUpdate(self):
271    """Transfer files for stateful update.
272
273    The stateful update payloads are copied to the target remote device for
274    stateful update.
275    """
276    logging.notice('Copying target stateful payload to device...')
277    payload = os.path.join(self._payload_dir, STATEFUL_FILENAME)
278    self._device.CopyToWorkDir(payload, mode=self._payload_mode,
279                               log_output=True, **self._cmd_kwargs)
280
281  def GetPayloadPropsFile(self):
282    """Finds the local payload properties file."""
283    # Payload properties file is available locally so just catch it next to the
284    # payload file.
285    if self._local_payload_props_path is None:
286      self._local_payload_props_path = os.path.join(
287          self._payload_dir, GetPayloadPropertiesFileName(self._payload_name))
288    return self._local_payload_props_path
289
290  def GetPayloadProps(self):
291    """Gets image_version from the payload_name and size of the payload.
292
293    The payload_dir must be in the format <board>/Rxx-12345.0.0 for a complete
294    match; else a ValueError will be raised. In case the payload filename is
295    update.gz, then image_version cannot be extracted from its name; therefore,
296    image_version is set to a dummy 99999.0.0.
297
298    Returns:
299      Dict - See parent class's function for full details.
300    """
301    payload_filepath = os.path.join(self._payload_dir, self._payload_name)
302    values = {
303        'image_version': '99999.0.0',
304        'size': os.path.getsize(payload_filepath)
305    }
306    if self._payload_name != ROOTFS_FILENAME:
307      payload_format = self._GetPayloadFormat()
308      payload_pattern = self._GetPayloadPattern()
309      m = re.match(payload_pattern, payload_format)
310      if not m:
311        raise ValueError(
312            'Regular expression %r did not match the expected payload format '
313            '%s' % (payload_pattern, payload_format))
314      values.update(m.groupdict())
315    return values
316
317
318class LabTransfer(Transfer):
319  """Abstracts logic that transfers files from staging server to the DUT."""
320
321  def __init__(self, staging_server, *args, **kwargs):
322    """Initialize LabTransfer to transfer files from staging server to DUT.
323
324    Args:
325      staging_server: Url of the server that's staging the payload files.
326      *args: The list of arguments to be passed. See Base class for a complete
327          list of accepted arguments.
328      **kwargs: Any keyword arguments to be passed. See Base class for a
329          complete list of accepted keyword arguments.
330    """
331    self._staging_server = staging_server
332    super(LabTransfer, self).__init__(*args, **kwargs)
333
334  def _GetPayloadFormat(self):
335    """Gets the payload format that should be evaluated.
336
337    Returns:
338      The payload dir as a string.
339    """
340    return self._payload_dir
341
342  def _GetPayloadPattern(self):
343    """The regex pattern that the payload format must match.
344
345    Returns:
346      Regular expression.
347    """
348    return r'.*/(R[0-9]+-)(?P<image_version>.+)'
349
350  def _RemoteDevserverCall(self, cmd, stdout=False):
351    """Runs a command on a remote devserver by sshing into it.
352
353    Raises cros_build_lib.RunCommandError() if the command could not be run
354    successfully.
355
356    Args:
357      cmd: (list) the command to be run.
358      stdout: True if the stdout of the command should be captured.
359    """
360    ip = urllib.parse.urlparse(self._staging_server).hostname
361    return cros_build_lib.run(['ssh', ip] + cmd, log_output=True, stdout=stdout)
362
363  def _CheckPayloads(self, payload_name):
364    """Runs the curl command that checks if payloads have been staged."""
365    payload_url = self._GetStagedUrl(staged_filename=payload_name,
366                                     build_id=self._payload_dir)
367    cmd = ['curl', '-I', payload_url, '--fail']
368    try:
369      self._RemoteDevserverCall(cmd)
370    except cros_build_lib.RunCommandError as e:
371      raise ChromiumOSTransferError(
372          'Could not verify if %s was staged at %s. Received exception: %s' %
373          (payload_name, payload_url, e))
374
375  def CheckPayloads(self):
376    """Verify that all required payloads are staged on staging server."""
377    logging.debug('Checking if payloads have been staged on server %s...',
378                  self._staging_server)
379
380    if self._transfer_rootfs_update:
381      self._CheckPayloads(self._payload_name)
382      self._CheckPayloads(GetPayloadPropertiesFileName(self._payload_name))
383
384    if self._transfer_stateful_update:
385      self._CheckPayloads(STATEFUL_FILENAME)
386
387  def _GetStagedUrl(self, staged_filename, build_id=None):
388    """Returns a valid url to check availability of staged files.
389
390    Args:
391      staged_filename: Name of the staged file.
392      build_id: This is the path at which the needed file can be found. It
393        is usually of the format <board_name>-release/R79-12345.6.0. By default,
394        the path is set to be None.
395
396    Returns:
397      A URL in the format:
398        http://<ip>:<port>/static/<board>-release/<version>/<staged_filename>
399    """
400    # Formulate the download URL out of components.
401    url = urllib.parse.urljoin(self._staging_server, 'static/')
402    if build_id:
403      # Add slash at the end of image_name if necessary.
404      if not build_id.endswith('/'):
405        build_id = build_id + '/'
406      url = urllib.parse.urljoin(url, build_id)
407    return urllib.parse.urljoin(url, staged_filename)
408
409  def _GetCurlCmdForPayloadDownload(self, payload_dir, payload_filename,
410                                    build_id=None):
411    """Returns a valid curl command to download payloads into device tmp dir.
412
413    Args:
414      payload_dir: Path to the payload directory on the device.
415      payload_filename: Name of the file by which the downloaded payload should
416        be saved. This is assumed to be the same as the name of the payload.
417      build_id: This is the path at which the needed payload can be found. It
418        is usually of the format <board_name>-release/R79-12345.6.0. By default,
419        the path is set to None.
420
421    Returns:
422      A fully formed curl command in the format:
423        ['curl', '-o', '<path where payload should be saved>',
424         '<payload download URL>']
425    """
426    return ['curl', '-o', os.path.join(payload_dir, payload_filename),
427            self._GetStagedUrl(payload_filename, build_id)]
428
429  def _TransferUpdateUtilsPackage(self):
430    """Transfer update-utils package to work directory of the remote device.
431
432    The update-utils package will be transferred to the device from the
433    staging server via curl.
434    """
435    logging.notice('Copying update script to device...')
436    source_dir = os.path.join(self._device.work_dir, 'src')
437    self._EnsureDeviceDirectory(source_dir)
438
439    self._device.run(self._GetCurlCmdForPayloadDownload(
440        payload_dir=source_dir,
441        payload_filename=nebraska_wrapper.NEBRASKA_FILENAME))
442
443    # Make sure the device.work_dir exists after any installation and reboot.
444    self._EnsureDeviceDirectory(self._device.work_dir)
445
446  def _TransferStatefulUpdate(self):
447    """Transfer files for stateful update.
448
449    The stateful update bin and the corresponding payloads are copied to the
450    target remote device for stateful update from the staging server via curl.
451    """
452    self._EnsureDeviceDirectory(self._device_payload_dir)
453
454    # TODO(crbug.com/1024639): Another way to make the payloads available is
455    # to make update_engine download it directly from the staging_server. This
456    # will avoid a disk copy but has the potential to be harder to debug if
457    # update engine does not report the error clearly.
458
459    logging.notice('Copying target stateful payload to device...')
460    self._device.run(self._GetCurlCmdForPayloadDownload(
461        payload_dir=self._device.work_dir, build_id=self._payload_dir,
462        payload_filename=STATEFUL_FILENAME))
463
464  def _TransferRootfsUpdate(self):
465    """Transfer files for rootfs update.
466
467    Copy the update payload to the remote device for rootfs update from the
468    staging server via curl.
469    """
470    self._EnsureDeviceDirectory(self._device_payload_dir)
471
472    logging.notice('Copying rootfs payload to device...')
473
474    # TODO(crbug.com/1024639): Another way to make the payloads available is
475    # to make update_engine download it directly from the staging_server. This
476    # will avoid a disk copy but has the potential to be harder to debug if
477    # update engine does not report the error clearly.
478
479    self._device.run(self._GetCurlCmdForPayloadDownload(
480        payload_dir=self._device_payload_dir, build_id=self._payload_dir,
481        payload_filename=self._payload_name))
482
483    self._device.CopyToWorkDir(src=self._local_payload_props_path,
484                               dest=self.PAYLOAD_DIR_NAME,
485                               mode=self._payload_mode,
486                               log_output=True, **self._cmd_kwargs)
487
488  def GetPayloadPropsFile(self):
489    """Downloads the PayloadProperties file onto the drone.
490
491    The payload properties file may be required to be updated in
492    auto_updater.ResolveAppIsMismatchIfAny(). Download the file from where it
493    has been staged on the staging server into the tempdir of the drone, so that
494    the file is available locally for any updates.
495    """
496    if self._local_payload_props_path is None:
497      payload_props_filename = GetPayloadPropertiesFileName(self._payload_name)
498      payload_props_path = os.path.join(self._tempdir, payload_props_filename)
499
500      # Get command to retrieve contents of the properties file.
501      cmd = ['curl',
502             self._GetStagedUrl(payload_props_filename, self._payload_dir)]
503      try:
504        result = self._RemoteDevserverCall(cmd, stdout=True)
505        json.loads(result.output)
506        osutils.WriteFile(payload_props_path, result.output, 'wb',
507                          makedirs=True)
508      except cros_build_lib.RunCommandError as e:
509        raise ChromiumOSTransferError(
510            'Unable to get payload properties file by running %s due to '
511            'exception: %s.' % (' '.join(cmd), e))
512      except ValueError:
513        raise ChromiumOSTransferError(
514            'Could not create %s as %s not valid json.' %
515            (payload_props_path, result.output))
516
517      self._local_payload_props_path = payload_props_path
518    return self._local_payload_props_path
519
520  def _GetPayloadSize(self):
521    """Returns the size of the payload by running a curl -I command.
522
523    Returns:
524      Payload size in bytes.
525    """
526    payload_url = self._GetStagedUrl(staged_filename=self._payload_name,
527                                     build_id=self._payload_dir)
528    cmd = ['curl', '-I', payload_url, '--fail']
529    try:
530      proc = self._RemoteDevserverCall(cmd, stdout=True)
531    except cros_build_lib.RunCommandError as e:
532      raise ChromiumOSTransferError(
533          'Unable to get payload size by running command %s due to exception: '
534          '%s.' % (' '.join(cmd), e))
535
536    pattern = re.compile(r'Content-Length: [0-9]+', re.I)
537    match = pattern.findall(str(proc.output))
538    if not match:
539      raise ChromiumOSTransferError('Could not get payload size from output: '
540                                    '%s ' % proc.output)
541    return int(match[0].split()[1].strip())
542
543  def GetPayloadProps(self):
544    """Gets image_version from the payload_dir name and gets payload size.
545
546    The payload_dir must be in the format <board>/Rxx-12345.0.0 for a complete
547    match; else a ValueError will be raised.
548
549    Returns:
550      Dict - See parent class's function for full details.
551    """
552    values = {'size': self._GetPayloadSize()}
553    payload_format = self._GetPayloadFormat()
554    payload_pattern = self._GetPayloadPattern()
555    m = re.match(payload_pattern, payload_format)
556    if not m:
557      raise ValueError('Regular expression %r did not match the expected '
558                       'payload format %s' % (payload_pattern, payload_format))
559    values.update(m.groupdict())
560    return values
561
562
563class LabEndToEndPayloadTransfer(LabTransfer):
564  """Abstracts logic that transfers files from staging server to the DUT.
565
566  TODO(crbug.com/1061570): AutoUpdate_endToEnd tests stage their payloads in a
567  different location on the devserver in comparison to the provision_AutoUpdate
568  test. Since we are removing the use of the cros_au RPC (see crbug.com/1049708
569  and go/devserver-deprecation) from the EndToEnd tests, it is necessary to
570  extend LabTransfer class to support this new payload staging location.
571  Ideally, the URL at which the payload is staged should be abstracted from the
572  actual transfer of payloads.
573  """
574
575  def _GetPayloadFormat(self):
576    """Gets the payload format that should be evaluated.
577
578    Returns:
579      The payload name as a string.
580    """
581    return self._payload_name
582
583  def _GetPayloadPattern(self):
584    """The regex pattern that the payload format must match.
585
586    Returns:
587      Regular expression.
588    """
589    if "payloads/" in self._GetPayloadFormat():
590      # Ex: payloads/chromeos_14698.0.0_octopus_dev-channel_full_test.bin-gyzdkobygyzdck3swpkou632wan55vgx
591      return _PAYLOAD_PATTERN
592    else:
593      # Ex: chromeos_R102-14692.0.0_octopus_full_dev.bin
594      return r'.*(R[0-9]+-)(?P<image_version>.+)'
595
596  def _GetCurlCmdForPayloadDownload(self, payload_dir, payload_filename,
597                                    build_id=None):
598    """Returns a valid curl command to download payloads into device tmp dir.
599
600    Args:
601      payload_dir: Path to the payload directory on the device.
602      payload_filename: Name of the file by which the downloaded payload should
603        be saved. This is assumed to be the same as the name of the payload.
604        If the payload_name must is in this format:
605        payloads/whatever_file_name, the 'payloads/' at the start will be
606        removed while saving the file as the files need to be saved in specific
607        directories for their subsequent installation. Keeping the 'payloads/'
608        at the beginning of the payload_filename, adds a new directory that
609        messes up its installation.
610      build_id: This is the path at which the needed payload can be found. It
611        is usually of the format <board_name>-release/R79-12345.6.0. By default,
612        the path is set to None.
613
614    Returns:
615      A fully formed curl command in the format:
616      ['curl', '-o', '<path where payload should be saved>',
617      '<payload download URL>']
618    """
619    saved_filename = payload_filename
620    if saved_filename.startswith('payloads/'):
621      saved_filename = '/'.join(saved_filename.split('/')[1:])
622    cmd = ['curl', '-o', os.path.join(payload_dir, saved_filename),
623           self._GetStagedUrl(payload_filename, build_id)]
624    return cmd
625
626  def _TransferUpdateUtilsPackage(self):
627    """Transfer update-utils package to work directory of the remote device."""
628    try:
629      logging.notice('Copying update script to device from googlesource...')
630      source_dir = os.path.join(self._tempdir, 'src')
631      osutils.SafeMakedirs(source_dir)
632      nebraska_wrapper.RemoteNebraskaWrapper.GetNebraskaSrcFile(
633        source_dir, force_download=True)
634
635      # Make sure the device.work_dir exists after any installation and reboot.
636      self._EnsureDeviceDirectory(self._device.work_dir)
637      # Python packages are plain text files.
638      self._device.CopyToWorkDir(source_dir, mode=_SCP, log_output=True,
639                                 **self._cmd_kwargs)
640    except Exception as e:
641      logging.exception('Falling back to getting nebraska from devserver')
642      super(LabEndToEndPayloadTransfer, self)._TransferUpdateUtilsPackage()
643