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