blob: fa069af0bca278e12cfc3cff65509074273817c8 [file] [log] [blame]
Amin Hassani92f6c4a2021-02-20 17:36:09 -08001# Copyright 2021 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
5"""Library containing functions to install an image on a Chromium OS device."""
6
Amin Hassanid4b3ff82021-02-20 23:05:14 -08007import abc
Amin Hassani92f6c4a2021-02-20 17:36:09 -08008import enum
9import os
10import re
11import sys
Amin Hassanid4b3ff82021-02-20 23:05:14 -080012import tempfile
13import threading
Amin Hassani55970562021-02-22 20:49:13 -080014import time
Amin Hassani92f6c4a2021-02-20 17:36:09 -080015from typing import Tuple, Dict
16
Amin Hassani55970562021-02-22 20:49:13 -080017from chromite.cli import command
Amin Hassanicf8f0042021-03-12 10:42:13 -080018from chromite.cli import flash
Amin Hassanid4b3ff82021-02-20 23:05:14 -080019from chromite.lib import constants
Amin Hassani92f6c4a2021-02-20 17:36:09 -080020from chromite.lib import cros_build_lib
21from chromite.lib import cros_logging as logging
22from chromite.lib import gs
Amin Hassanid4b3ff82021-02-20 23:05:14 -080023from chromite.lib import image_lib
Amin Hassani55970562021-02-22 20:49:13 -080024from chromite.lib import operation
Amin Hassanid4b3ff82021-02-20 23:05:14 -080025from chromite.lib import osutils
Amin Hassani92f6c4a2021-02-20 17:36:09 -080026from chromite.lib import parallel
27from chromite.lib import remote_access
28from chromite.lib import retry_util
Amin Hassani74403082021-02-22 11:40:09 -080029from chromite.lib import stateful_updater
Amin Hassani75c5f942021-02-20 23:56:53 -080030from chromite.lib.paygen import partition_lib
Amin Hassani74403082021-02-22 11:40:09 -080031from chromite.lib.paygen import paygen_stateful_payload_lib
Amin Hassani92f6c4a2021-02-20 17:36:09 -080032from chromite.lib.xbuddy import devserver_constants
33from chromite.lib.xbuddy import xbuddy
34
35
36assert sys.version_info >= (3, 6), 'This module requires Python 3.6+'
37
38
39class Error(Exception):
40 """Thrown when there is a general Chromium OS-specific flash error."""
41
42
43class ImageType(enum.Enum):
44 """Type of the image that is used for flashing the device."""
45
46 # The full image on disk (e.g. chromiumos_test_image.bin).
47 FULL = 0
48 # The remote directory path
49 # (e.g gs://chromeos-image-archive/eve-release/R90-x.x.x)
50 REMOTE_DIRECTORY = 1
51
52
53class Partition(enum.Enum):
54 """An enum for partition types like kernel and rootfs."""
55 KERNEL = 0
56 ROOTFS = 1
57
58
59class DeviceImager(object):
60 """A class to flash a Chromium OS device.
61
62 This utility uses parallelism as much as possible to achieve its goal as fast
63 as possible. For example, it uses parallel compressors, parallel transfers,
64 and simultaneous pipes.
65 """
66
67 # The parameters of the kernel and rootfs's two main partitions.
68 A = {Partition.KERNEL: 2, Partition.ROOTFS: 3}
69 B = {Partition.KERNEL: 4, Partition.ROOTFS: 5}
70
71 def __init__(self, device, image: str,
Amin Hassanicf8f0042021-03-12 10:42:13 -080072 board: str = None,
73 version: str = None,
Amin Hassani92f6c4a2021-02-20 17:36:09 -080074 no_rootfs_update: bool = False,
75 no_stateful_update: bool = False,
76 no_reboot: bool = False,
77 disable_verification: bool = False,
78 clobber_stateful: bool = False,
79 clear_tpm_owner: bool = False):
80 """Initialize DeviceImager for flashing a Chromium OS device.
81
82 Args:
83 device: The ChromiumOSDevice to be updated.
84 image: The target image path (can be xBuddy path).
Amin Hassanicf8f0042021-03-12 10:42:13 -080085 board: Board to use.
86 version: Image version to use.
Amin Hassani92f6c4a2021-02-20 17:36:09 -080087 no_rootfs_update: Whether to do rootfs partition update.
88 no_stateful_update: Whether to do stateful partition update.
89 no_reboot: Whether to reboot device after update. The default is True.
90 disable_verification: Whether to disabling rootfs verification on the
91 device.
92 clobber_stateful: Whether to do a clean stateful partition.
93 clear_tpm_owner: If true, it will clear the TPM owner on reboot.
94 """
95
96 self._device = device
97 self._image = image
Amin Hassanicf8f0042021-03-12 10:42:13 -080098 self._board = board
99 self._version = version
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800100 self._no_rootfs_update = no_rootfs_update
101 self._no_stateful_update = no_stateful_update
102 self._no_reboot = no_reboot
103 self._disable_verification = disable_verification
104 self._clobber_stateful = clobber_stateful
105 self._clear_tpm_owner = clear_tpm_owner
106
Amin Hassanib1993eb2021-04-28 12:00:11 -0700107 self._image_type = None
Amin Hassani0972d392021-03-31 19:04:19 -0700108 self._compression = cros_build_lib.COMP_GZIP
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800109 self._inactive_state = None
110
111 def Run(self):
112 """Update the device with image of specific version."""
Amin Hassanib1993eb2021-04-28 12:00:11 -0700113 self._LocateImage()
114 logging.notice('Preparing to update the remote device %s with image %s',
115 self._device.hostname, self._image)
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800116
117 try:
Amin Hassani55970562021-02-22 20:49:13 -0800118 if command.UseProgressBar():
119 op = DeviceImagerOperation()
120 op.Run(self._Run)
121 else:
122 self._Run()
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800123 except Exception as e:
124 raise Error(f'DeviceImager Failed with error: {e}')
125
Amin Hassani55970562021-02-22 20:49:13 -0800126 # DeviceImagerOperation will look for this log.
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800127 logging.info('DeviceImager completed.')
128
129 def _Run(self):
130 """Runs the various operations to install the image on device."""
Amin Hassanib1993eb2021-04-28 12:00:11 -0700131 # Override the compression as remote quick provision images are gzip
132 # compressed only.
133 if self._image_type == ImageType.REMOTE_DIRECTORY:
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800134 self._compression = cros_build_lib.COMP_GZIP
135
Amin Hassanib1993eb2021-04-28 12:00:11 -0700136 self._InstallPartitions()
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800137
138 if self._clear_tpm_owner:
139 self._device.ClearTpmOwner()
140
141 if not self._no_reboot:
142 self._Reboot()
143 self._VerifyBootExpectations()
144
145 if self._disable_verification:
146 self._device.DisableRootfsVerification()
147
Amin Hassanib1993eb2021-04-28 12:00:11 -0700148 def _LocateImage(self):
149 """Locates the path to the final image(s) that need to be installed.
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800150
151 If the paths is local, the image should be the Chromium OS GPT image
152 (e.g. chromiumos_test_image.bin). If the path is remote, it should be the
153 remote directory where we can find the quick-provision and stateful update
154 files (e.g. gs://chromeos-image-archive/eve-release/R90-x.x.x).
155
156 NOTE: At this point there is no caching involved. Hence we always download
157 the partition payloads or extract them from the Chromium OS image.
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800158 """
159 if os.path.isfile(self._image):
Amin Hassanib1993eb2021-04-28 12:00:11 -0700160 self._image_type = ImageType.FULL
161 return
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800162
163 # TODO(b/172212406): We could potentially also allow this by searching
164 # through the directory to see whether we have quick-provision and stateful
165 # payloads. This only makes sense when a user has their workstation at home
166 # and doesn't want to incur the bandwidth cost of downloading the same
167 # image multiple times. For that, they can simply download the GPT image
168 # image first and flash that instead.
169 if os.path.isdir(self._image):
170 raise ValueError(
171 f'{self._image}: input must be a disk image, not a directory.')
172
173 if gs.PathIsGs(self._image):
174 # TODO(b/172212406): Check whether it is a directory. If it wasn't a
175 # directory download the image into some temp location and use it instead.
Amin Hassanib1993eb2021-04-28 12:00:11 -0700176 self._image_type = ImageType.REMOTE_DIRECTORY
177 return
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800178
179 # Assuming it is an xBuddy path.
Amin Hassanicf8f0042021-03-12 10:42:13 -0800180 board = cros_build_lib.GetBoard(
181 device_board=self._device.board or flash.GetDefaultBoard(),
182 override_board=self._board, force=True)
183
Amin Hassania20d20d2021-04-28 10:18:18 -0700184 xb = xbuddy.XBuddy(board=board, version=self._version)
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800185 build_id, local_file = xb.Translate([self._image])
186 if build_id is None:
187 raise Error(f'{self._image}: unable to find matching xBuddy path.')
188 logging.info('XBuddy path translated to build ID %s', build_id)
189
190 if local_file:
Amin Hassanib1993eb2021-04-28 12:00:11 -0700191 self._image = local_file
192 self._image_type = ImageType.FULL
193 return
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800194
Amin Hassanib1993eb2021-04-28 12:00:11 -0700195 self._image = f'{devserver_constants.GS_IMAGE_DIR}/{build_id}'
196 self._image_type = ImageType.REMOTE_DIRECTORY
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800197
198 def _SplitDevPath(self, path: str) -> Tuple[str, int]:
199 """Splits the given /dev/x path into prefix and the dev number.
200
201 Args:
202 path: The path to a block dev device.
203
204 Returns:
205 A tuple of representing the prefix and the index of the dev path.
206 e.g.: '/dev/mmcblk0p1' -> ['/dev/mmcblk0p', 1]
207 """
208 match = re.search(r'(.*)([0-9]+)$', path)
209 if match is None:
210 raise Error(f'{path}: Could not parse root dev path.')
211
212 return match.group(1), int(match.group(2))
213
214 def _GetKernelState(self, root_num: int) -> Tuple[Dict, Dict]:
215 """Returns the kernel state.
216
217 Returns:
218 A tuple of two dictionaries: The current active kernel state and the
219 inactive kernel state. (Look at A and B constants in this class.)
220 """
221 if root_num == self.A[Partition.ROOTFS]:
222 return self.A, self.B
223 elif root_num == self.B[Partition.ROOTFS]:
224 return self.B, self.A
225 else:
226 raise Error(f'Invalid root partition number {root_num}')
227
Amin Hassanib1993eb2021-04-28 12:00:11 -0700228 def _InstallPartitions(self):
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800229 """The main method that installs the partitions of a Chrome OS device.
230
231 It uses parallelism to install the partitions as fast as possible.
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800232 """
Amin Hassanid684e982021-02-26 11:10:58 -0800233 prefix, root_num = self._SplitDevPath(self._device.root_dev)
234 active_state, self._inactive_state = self._GetKernelState(root_num)
235
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800236 updaters = []
Amin Hassanid684e982021-02-26 11:10:58 -0800237 if not self._no_rootfs_update:
Amin Hassani75c5f942021-02-20 23:56:53 -0800238 current_root = prefix + str(active_state[Partition.ROOTFS])
239 target_root = prefix + str(self._inactive_state[Partition.ROOTFS])
Amin Hassanib1993eb2021-04-28 12:00:11 -0700240 updaters.append(RootfsUpdater(current_root, self._device, self._image,
241 self._image_type, target_root,
242 self._compression))
Amin Hassani75c5f942021-02-20 23:56:53 -0800243
Amin Hassanid684e982021-02-26 11:10:58 -0800244 target_kernel = prefix + str(self._inactive_state[Partition.KERNEL])
Amin Hassanib1993eb2021-04-28 12:00:11 -0700245 updaters.append(KernelUpdater(self._device, self._image, self._image_type,
Amin Hassanid684e982021-02-26 11:10:58 -0800246 target_kernel, self._compression))
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800247
Amin Hassani74403082021-02-22 11:40:09 -0800248 if not self._no_stateful_update:
249 updaters.append(StatefulUpdater(self._clobber_stateful, self._device,
Amin Hassanib1993eb2021-04-28 12:00:11 -0700250 self._image, self._image_type, None,
251 None))
Amin Hassani74403082021-02-22 11:40:09 -0800252
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800253 # Retry the partitions updates that failed, in case a transient error (like
254 # SSH drop, etc) caused the error.
255 num_retries = 1
256 try:
257 retry_util.RetryException(Error, num_retries,
258 parallel.RunParallelSteps,
259 (x.Run for x in updaters if not x.IsFinished()),
260 halt_on_error=True)
261 except Exception:
262 # If one of the partitions failed to be installed, revert all partitions.
263 parallel.RunParallelSteps(x.Revert for x in updaters)
264 raise
265
266 def _Reboot(self):
267 """Reboots the device."""
268 try:
Amin Hassani9281b682021-03-08 16:38:25 -0800269 self._device.Reboot(timeout_sec=300)
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800270 except remote_access.RebootError:
271 raise Error('Could not recover from reboot. Once example reason'
272 ' could be the image provided was a non-test image'
273 ' or the system failed to boot after the update.')
274 except Exception as e:
275 raise Error(f'Failed to reboot to the device with error: {e}')
276
277 def _VerifyBootExpectations(self):
278 """Verify that we fully booted into the expected kernel state."""
279 # Discover the newly active kernel.
280 _, root_num = self._SplitDevPath(self._device.root_dev)
281 active_state, _ = self._GetKernelState(root_num)
282
283 # If this happens, we should rollback.
284 if active_state != self._inactive_state:
285 raise Error('The expected kernel state after update is invalid.')
286
287 logging.info('Verified boot expectations.')
Amin Hassanid4b3ff82021-02-20 23:05:14 -0800288
289
290class ReaderBase(threading.Thread):
291 """The base class for reading different inputs and writing into output.
292
293 This class extends threading.Thread, so it will be run on its own thread. Also
294 it can be used as a context manager. Internally, it opens necessary files for
295 writing to and reading from. This class cannot be instantiated, it needs to be
296 sub-classed first to provide necessary function implementations.
297 """
298
299 def __init__(self, use_named_pipes: bool = False):
300 """Initializes the class.
301
302 Args:
303 use_named_pipes: Whether to use a named pipe or anonymous file
304 descriptors.
305 """
306 super().__init__()
307 self._use_named_pipes = use_named_pipes
308 self._pipe_target = None
309 self._pipe_source = None
310
311 def __del__(self):
312 """Destructor.
313
314 Make sure to clean up any named pipes we might have created.
315 """
316 if self._use_named_pipes:
317 osutils.SafeUnlink(self._pipe_target)
318
319 def __enter__(self):
320 """Enters the context manager"""
321 if self._use_named_pipes:
322 # There is no need for the temp file, we only need its path. So the named
323 # pipe is created after this temp file is deleted.
324 with tempfile.NamedTemporaryFile(prefix='chromite-device-imager') as fp:
325 self._pipe_target = self._pipe_source = fp.name
326 os.mkfifo(self._pipe_target)
327 else:
328 self._pipe_target, self._pipe_source = os.pipe()
329
330 self.start()
331 return self
332
333 def __exit__(self, *args, **kwargs):
334 """Exits the context manager."""
335 self.join()
336
337 def _Source(self):
338 """Returns the source pipe to write data into.
339
340 Sub-classes can use this function to determine where to write their data
341 into.
342 """
343 return self._pipe_source
344
345 def _CloseSource(self):
346 """Closes the source pipe.
347
348 Sub-classes should use this function to close the pipe after they are done
349 writing into it. Failure to do so may result reader of the data to hang
350 indefinitely.
351 """
352 if not self._use_named_pipes:
353 os.close(self._pipe_source)
354
355 def Target(self):
356 """Returns the target pipe to read data from.
357
358 Users of this class can use this path to read data from.
359 """
360 return self._pipe_target
361
362 def CloseTarget(self):
363 """Closes the target pipe.
364
365 Users of this class should use this function to close the pipe after they
366 are done reading from it.
367 """
368 if self._use_named_pipes:
369 os.remove(self._pipe_target)
370 else:
371 os.close(self._pipe_target)
372
373
374class PartialFileReader(ReaderBase):
375 """A class to read specific offset and length from a file and compress it.
376
377 This class can be used to read from specific location and length in a file
378 (e.g. A partition in a GPT image). Then it compresses the input and writes it
379 out (to a pipe). Look at the base class for more information.
380 """
381
382 # The offset of different partitions in a Chromium OS image does not always
383 # align to larger values like 4096. It seems that 512 is the maximum value to
384 # be divisible by partition offsets. This size should not be increased just
385 # for 'performance reasons'. Since we are doing everything in parallel, in
386 # practice there is not much difference between this and larger block sizes as
387 # parallelism hides the possible extra latency provided by smaller block
388 # sizes.
389 _BLOCK_SIZE = 512
390
391 def __init__(self, image: str, offset: int, length: int, compression):
392 """Initializes the class.
393
394 Args:
395 image: The path to an image (local or remote directory).
396 offset: The offset (in bytes) to read from the image.
397 length: The length (in bytes) to read from the image.
398 compression: The compression type (see cros_build_lib.COMP_XXX).
399 """
400 super().__init__()
401
402 self._image = image
403 self._offset = offset
404 self._length = length
405 self._compression = compression
406
407 def run(self):
408 """Runs the reading and compression."""
409 cmd = [
410 'dd',
411 'status=none',
412 f'if={self._image}',
413 f'ibs={self._BLOCK_SIZE}',
414 f'skip={int(self._offset/self._BLOCK_SIZE)}',
415 f'count={int(self._length/self._BLOCK_SIZE)}',
416 '|',
417 cros_build_lib.FindCompressor(self._compression),
418 ]
419
420 try:
421 cros_build_lib.run(' '.join(cmd), stdout=self._Source(), shell=True)
422 finally:
423 self._CloseSource()
424
425
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800426class GsFileCopier(ReaderBase):
427 """A class for downloading gzip compressed file from GS bucket into a pipe."""
428
429 def __init__(self, image: str):
430 """Initializes the class.
431
432 Args:
433 image: The path to an image (local or remote directory).
434 """
435 super().__init__(use_named_pipes=True)
436 self._image = image
437
438 def run(self):
439 """Runs the download and write into the output pipe."""
440 try:
441 gs.GSContext().Copy(self._image, self._Source())
442 finally:
443 self._CloseSource()
444
445
Amin Hassanid4b3ff82021-02-20 23:05:14 -0800446class PartitionUpdaterBase(object):
447 """A base abstract class to use for installing an image into a partition.
448
449 Sub-classes should implement the abstract methods to provide the core
450 functionality.
451 """
452 def __init__(self, device, image: str, image_type, target: str, compression):
453 """Initializes this base class with values that most sub-classes will need.
454
455 Args:
456 device: The ChromiumOSDevice to be updated.
457 image: The target image path for the partition update.
458 image_type: The type of the image (ImageType).
459 target: The target path (e.g. block dev) to install the update.
460 compression: The compression used for compressing the update payload.
461 """
462 self._device = device
463 self._image = image
464 self._image_type = image_type
465 self._target = target
466 self._compression = compression
467 self._finished = False
468
469 def Run(self):
470 """The main function that does the partition update job."""
471 with cros_build_lib.TimedSection() as timer:
472 try:
473 self._Run()
474 finally:
475 self._finished = True
476
477 logging.debug('Completed %s in %s', self.__class__.__name__, timer.delta)
478
479 @abc.abstractmethod
480 def _Run(self):
481 """The method that need to be implemented by sub-classes."""
482 raise NotImplementedError('Sub-classes need to implement this.')
483
484 def IsFinished(self):
485 """Returns whether the partition update has been successful."""
486 return self._finished
487
488 @abc.abstractmethod
489 def Revert(self):
490 """Reverts the partition update.
491
492 Sub-classes need to implement this function to provide revert capability.
493 """
494 raise NotImplementedError('Sub-classes need to implement this.')
495
496
497class RawPartitionUpdater(PartitionUpdaterBase):
498 """A class to update a raw partition on a Chromium OS device."""
499
500 def _Run(self):
501 """The function that does the job of kernel partition update."""
502 if self._image_type == ImageType.FULL:
503 self._CopyPartitionFromImage(self._GetPartitionName())
504 elif self._image_type == ImageType.REMOTE_DIRECTORY:
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800505 self._RedirectPartition(self._GetRemotePartitionName())
Amin Hassanid4b3ff82021-02-20 23:05:14 -0800506 else:
507 raise ValueError(f'Invalid image type {self._image_type}')
508
509 def _GetPartitionName(self):
510 """Returns the name of the partition in a Chromium OS GPT layout.
511
512 Subclasses should override this function to return correct name.
513 """
514 raise NotImplementedError('Subclasses need to implement this.')
515
516 def _CopyPartitionFromImage(self, part_name: str):
517 """Updates the device's partition from a local Chromium OS image.
518
519 Args:
520 part_name: The name of the partition in the source image that needs to be
521 extracted.
522 """
523 cmd = self._GetWriteToTargetCommand()
524
525 offset, length = self._GetPartLocation(part_name)
526 offset, length = self._OptimizePartLocation(offset, length)
527 with PartialFileReader(self._image, offset, length,
528 self._compression) as generator:
529 try:
530 self._device.run(cmd, input=generator.Target(), shell=True)
531 finally:
532 generator.CloseTarget()
533
534 def _GetWriteToTargetCommand(self):
535 """Returns a write to target command to run on a Chromium OS device.
536
537 Returns:
538 A string command to run on a device to read data from stdin, uncompress it
539 and write it to the target partition.
540 """
541 cmd = self._device.GetDecompressor(self._compression)
542 # Using oflag=direct to tell the OS not to cache the writes (faster).
543 cmd += ['|', 'dd', 'bs=1M', 'oflag=direct', f'of={self._target}']
544 return ' '.join(cmd)
545
546 def _GetPartLocation(self, part_name: str):
547 """Extracts the location and size of the kernel partition from the image.
548
549 Args:
550 part_name: The name of the partition in the source image that needs to be
551 extracted.
552
553 Returns:
554 A tuple of offset and length (in bytes) from the image.
555 """
556 try:
557 parts = image_lib.GetImageDiskPartitionInfo(self._image)
558 part_info = [p for p in parts if p.name == part_name][0]
559 except IndexError:
560 raise Error(f'No partition named {part_name} found.')
561
562 return int(part_info.start), int(part_info.size)
563
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800564 def _GetRemotePartitionName(self):
565 """Returns the name of the quick-provision partition file.
566
567 Subclasses should override this function to return correct name.
568 """
569 raise NotImplementedError('Subclasses need to implement this.')
570
Amin Hassanid4b3ff82021-02-20 23:05:14 -0800571 def _OptimizePartLocation(self, offset: int, length: int):
572 """Optimizes the offset and length of the partition.
573
574 Subclasses can override this to provide better offset/length than what is
575 defined in the PGT partition layout.
576
577 Args:
578 offset: The offset (in bytes) of the partition in the image.
579 length: The length (in bytes) of the partition.
580
581 Returns:
582 A tuple of offset and length (in bytes) from the image.
583 """
584 return offset, length
Amin Hassanid684e982021-02-26 11:10:58 -0800585
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800586 def _RedirectPartition(self, file_name: str):
587 """Downloads the partition from a remote path and writes it into target.
588
589 Args:
590 file_name: The file name in the remote directory self._image.
591 """
592 cmd = self._GetWriteToTargetCommand()
593
594 image_path = os.path.join(self._image, file_name)
595 with GsFileCopier(image_path) as generator:
596 try:
597 with open(generator.Target(), 'rb') as fp:
598 self._device.run(cmd, input=fp, shell=True)
599 finally:
600 generator.CloseTarget()
601
Amin Hassanid684e982021-02-26 11:10:58 -0800602
603class KernelUpdater(RawPartitionUpdater):
604 """A class to update the kernel partition on a Chromium OS device."""
605
606 def _GetPartitionName(self):
607 """See RawPartitionUpdater._GetPartitionName()."""
608 return constants.PART_KERN_B
609
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800610 def _GetRemotePartitionName(self):
611 """See RawPartitionUpdater._GetRemotePartitionName()."""
612 return constants.QUICK_PROVISION_PAYLOAD_KERNEL
613
Amin Hassanid684e982021-02-26 11:10:58 -0800614 def Revert(self):
615 """Reverts the kernel partition update."""
616 # There is nothing to do for reverting kernel partition.
Amin Hassani75c5f942021-02-20 23:56:53 -0800617
618
619class RootfsUpdater(RawPartitionUpdater):
620 """A class to update the root partition on a Chromium OS device."""
621
622 def __init__(self, current_root: str, *args):
623 """Initializes the class.
624
625 Args:
626 current_root: The current root device path.
627 *args: See PartitionUpdaterBase
628 """
629 super().__init__(*args)
630
631 self._current_root = current_root
632 self._ran_postinst = False
633
634 def _GetPartitionName(self):
635 """See RawPartitionUpdater._GetPartitionName()."""
636 return constants.PART_ROOT_A
637
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800638 def _GetRemotePartitionName(self):
639 """See RawPartitionUpdater._GetRemotePartitionName()."""
640 return constants.QUICK_PROVISION_PAYLOAD_ROOTFS
641
Amin Hassani75c5f942021-02-20 23:56:53 -0800642 def _Run(self):
643 """The function that does the job of rootfs partition update."""
Amin Hassani55970562021-02-22 20:49:13 -0800644 with ProgressWatcher(self._device, self._target):
645 super()._Run()
Amin Hassani75c5f942021-02-20 23:56:53 -0800646
647 self._RunPostInst()
648
649 def _OptimizePartLocation(self, offset: int, length: int):
650 """Optimizes the size of the root partition of the image.
651
652 Normally the file system does not occupy the entire partition. Furthermore
653 we don't need the verity hash tree at the end of the root file system
654 because postinst will recreate it. This function reads the (approximate)
655 superblock of the ext4 partition and extracts the actual file system size in
656 the root partition.
657 """
658 superblock_size = 4096 * 2
659 with open(self._image, 'rb') as r:
660 r.seek(offset)
661 with tempfile.NamedTemporaryFile(delete=False) as fp:
662 fp.write(r.read(superblock_size))
663 fp.close()
664 return offset, partition_lib.Ext2FileSystemSize(fp.name)
665
666 def _RunPostInst(self, on_target: bool = True):
667 """Runs the postinst process in the root partition.
668
669 Args:
670 on_target: If true the postinst is run on the target (inactive)
671 partition. This is used when doing normal updates. If false, the
672 postinst is run on the current (active) partition. This is used when
673 reverting an update.
674 """
675 try:
676 postinst_dir = '/'
677 partition = self._current_root
678 if on_target:
679 postinst_dir = self._device.run(
680 ['mktemp', '-d', '-p', self._device.work_dir],
681 capture_output=True).stdout.strip()
682 self._device.run(['mount', '-o', 'ro', self._target, postinst_dir])
683 partition = self._target
684
685 self._ran_postinst = True
686 postinst = os.path.join(postinst_dir, 'postinst')
687 result = self._device.run([postinst, partition], capture_output=True)
688
689 logging.debug('Postinst result on %s: \n%s', postinst, result.stdout)
Amin Hassani55970562021-02-22 20:49:13 -0800690 # DeviceImagerOperation will look for this log.
Amin Hassani75c5f942021-02-20 23:56:53 -0800691 logging.info('Postinstall completed.')
692 finally:
693 if on_target:
694 self._device.run(['umount', postinst_dir])
695
696 def Revert(self):
697 """Reverts the root update install."""
698 logging.info('Reverting the rootfs partition update.')
699 if self._ran_postinst:
700 # We don't have to do anything for revert if we haven't changed the kernel
701 # priorities yet.
702 self._RunPostInst(on_target=False)
Amin Hassani74403082021-02-22 11:40:09 -0800703
704
705class StatefulPayloadGenerator(ReaderBase):
706 """A class for generating a stateful update payload in a separate thread."""
707 def __init__(self, image: str):
708 """Initializes that class.
709
710 Args:
711 image: The path to a local Chromium OS image.
712 """
713 super().__init__()
714 self._image = image
715
716 def run(self):
717 """Generates the stateful update and writes it into the output pipe."""
718 try:
719 paygen_stateful_payload_lib.GenerateStatefulPayload(
720 self._image, self._Source())
721 finally:
722 self._CloseSource()
723
724
725class StatefulUpdater(PartitionUpdaterBase):
726 """A class to update the stateful partition on a device."""
727 def __init__(self, clobber_stateful: bool, *args):
728 """Initializes the class
729
730 Args:
731 clobber_stateful: Whether to clobber the stateful or not.
732 *args: Look at PartitionUpdaterBase.
733 """
734 super().__init__(*args)
735 self._clobber_stateful = clobber_stateful
736
737 def _Run(self):
738 """Reads/Downloads the stateful updates and writes it into the device."""
739 if self._image_type == ImageType.FULL:
740 generator_cls = StatefulPayloadGenerator
741 elif self._image_type == ImageType.REMOTE_DIRECTORY:
742 generator_cls = GsFileCopier
743 self._image = os.path.join(self._image,
744 paygen_stateful_payload_lib.STATEFUL_FILE)
745 else:
746 raise ValueError(f'Invalid image type {self._image_type}')
747
748 with generator_cls(self._image) as generator:
749 try:
750 updater = stateful_updater.StatefulUpdater(self._device)
751 updater.Update(
752 generator.Target(),
753 is_payload_on_device=False,
754 update_type=(stateful_updater.StatefulUpdater.UPDATE_TYPE_CLOBBER if
755 self._clobber_stateful else None))
756 finally:
757 generator.CloseTarget()
758
759 def Revert(self):
760 """Reverts the stateful partition update."""
761 logging.info('Reverting the stateful update.')
762 stateful_updater.StatefulUpdater(self._device).Reset()
Amin Hassani55970562021-02-22 20:49:13 -0800763
764
765class ProgressWatcher(threading.Thread):
766 """A class used for watching the progress of rootfs update."""
767
768 def __init__(self, device, target_root: str):
769 """Initializes the class.
770
771 Args:
772 device: The ChromiumOSDevice to be updated.
773 target_root: The target root partition to monitor the progress of.
774 """
775 super().__init__()
776
777 self._device = device
778 self._target_root = target_root
779 self._exit = False
780
781 def __enter__(self):
782 """Starts the thread."""
783 self.start()
784 return self
785
786 def __exit__(self, *args, **kwargs):
787 """Exists the thread."""
788 self._exit = True
789 self.join()
790
791 def _ShouldExit(self):
792 return self._exit
793
794 def run(self):
795 """Monitors the progress of the target root partitions' update.
796
797 This is done by periodically, reading the fd position of the process that is
798 writing into the target partition and reporting it back. Then the position
799 is divided by the size of the block device to report an approximate
800 progress.
801 """
802 cmd = ['blockdev', '--getsize64', self._target_root]
803 output = self._device.run(cmd, capture_output=True).stdout.strip()
804 if output is None:
805 raise Error(f'Cannot get the block device size from {output}.')
806 dev_size = int(output)
807
808 # Using lsof to find out which process is writing to the target rootfs.
809 cmd = f'lsof 2>/dev/null | grep {self._target_root}'
810 while not self._ShouldExit():
811 try:
812 output = self._device.run(cmd, capture_output=True,
813 shell=True).stdout.strip()
814 if output:
815 break
816 except cros_build_lib.RunCommandError:
817 continue
818 finally:
819 time.sleep(1)
820
821 # Now that we know which process is writing to it, we can look the fdinfo of
822 # stdout of that process to get its offset. We're assuming there will be no
823 # seek, which is correct.
824 pid = output.split()[1]
825 cmd = ['cat', f'/proc/{pid}/fdinfo/1']
826 while not self._ShouldExit():
827 try:
828 output = self._device.run(cmd, capture_output=True).stdout.strip()
829 m = re.search(r'^pos:\s*(\d+)$', output, flags=re.M)
830 if m:
831 offset = int(m.group(1))
832 # DeviceImagerOperation will look for this log.
833 logging.info('RootFS progress: %f', offset/dev_size)
834 except cros_build_lib.RunCommandError:
835 continue
836 finally:
837 time.sleep(1)
838
839
840class DeviceImagerOperation(operation.ProgressBarOperation):
841 """A class to provide a progress bar for DeviceImager operation."""
842
843 def __init__(self):
844 """Initializes the class."""
845 super().__init__()
846
847 self._progress = 0.0
848
849 def ParseOutput(self, output=None):
850 """Override function to parse the output and provide progress.
851
852 Args:
853 output: The stderr or stdout.
854 """
855 output = self._stdout.read()
856 match = re.findall(r'RootFS progress: (\d+(?:\.\d+)?)', output)
857 if match:
858 progress = float(match[0])
859 self._progress = max(self._progress, progress)
860
861 # If postinstall completes, move half of the remaining progress.
862 if re.findall(r'Postinstall completed', output):
863 self._progress += (1.0 - self._progress) / 2
864
865 # While waiting for reboot, each time, move half of the remaining progress.
866 if re.findall(r'Unable to get new boot_id', output):
867 self._progress += (1.0 - self._progress) / 2
868
869 if re.findall(r'DeviceImager completed.', output):
870 self._progress = 1.0
871
872 self.ProgressBar(self._progress)