blob: 1ad081ba333cffd6fb6e5b35e979894e285f9c0d [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
Chris McDonald14ac61d2021-07-21 11:49:56 -06009import logging
Amin Hassani92f6c4a2021-02-20 17:36:09 -080010import os
11import re
Amin Hassanid4b3ff82021-02-20 23:05:14 -080012import tempfile
13import threading
Amin Hassani55970562021-02-22 20:49:13 -080014import time
Chris McDonald14ac61d2021-07-21 11:49:56 -060015from typing import Dict, Tuple
Amin Hassani92f6c4a2021-02-20 17:36:09 -080016
Amin Hassani55970562021-02-22 20:49:13 -080017from chromite.cli import command
Amin Hassanicf8f0042021-03-12 10:42:13 -080018from chromite.cli import flash
Jae Hoon Kimcc723e02021-08-16 21:03:21 +000019from chromite.lib import cgpt
Amin Hassanid4b3ff82021-02-20 23:05:14 -080020from chromite.lib import constants
Amin Hassani92f6c4a2021-02-20 17:36:09 -080021from chromite.lib import cros_build_lib
Amin Hassani92f6c4a2021-02-20 17:36:09 -080022from 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
Amin Hassani92f6c4a2021-02-20 17:36:09 -080036class Error(Exception):
37 """Thrown when there is a general Chromium OS-specific flash error."""
38
39
40class ImageType(enum.Enum):
41 """Type of the image that is used for flashing the device."""
42
43 # The full image on disk (e.g. chromiumos_test_image.bin).
44 FULL = 0
45 # The remote directory path
46 # (e.g gs://chromeos-image-archive/eve-release/R90-x.x.x)
47 REMOTE_DIRECTORY = 1
48
49
50class Partition(enum.Enum):
51 """An enum for partition types like kernel and rootfs."""
52 KERNEL = 0
53 ROOTFS = 1
Jae Hoon Kimcc723e02021-08-16 21:03:21 +000054 MINIOS = 2
Amin Hassani92f6c4a2021-02-20 17:36:09 -080055
56
57class DeviceImager(object):
58 """A class to flash a Chromium OS device.
59
60 This utility uses parallelism as much as possible to achieve its goal as fast
61 as possible. For example, it uses parallel compressors, parallel transfers,
62 and simultaneous pipes.
63 """
64
65 # The parameters of the kernel and rootfs's two main partitions.
66 A = {Partition.KERNEL: 2, Partition.ROOTFS: 3}
67 B = {Partition.KERNEL: 4, Partition.ROOTFS: 5}
68
Jae Hoon Kimcc723e02021-08-16 21:03:21 +000069 MINIOS_A = {Partition.MINIOS: 9}
70 MINIOS_B = {Partition.MINIOS: 10}
71
Amin Hassani92f6c4a2021-02-20 17:36:09 -080072 def __init__(self, device, image: str,
Amin Hassanicf8f0042021-03-12 10:42:13 -080073 board: str = None,
74 version: str = None,
Amin Hassani92f6c4a2021-02-20 17:36:09 -080075 no_rootfs_update: bool = False,
76 no_stateful_update: bool = False,
Jae Hoon Kimcc723e02021-08-16 21:03:21 +000077 no_minios_update: bool = False,
Amin Hassani92f6c4a2021-02-20 17:36:09 -080078 no_reboot: bool = False,
79 disable_verification: bool = False,
80 clobber_stateful: bool = False,
81 clear_tpm_owner: bool = False):
82 """Initialize DeviceImager for flashing a Chromium OS device.
83
84 Args:
85 device: The ChromiumOSDevice to be updated.
86 image: The target image path (can be xBuddy path).
Amin Hassanicf8f0042021-03-12 10:42:13 -080087 board: Board to use.
88 version: Image version to use.
Amin Hassani92f6c4a2021-02-20 17:36:09 -080089 no_rootfs_update: Whether to do rootfs partition update.
90 no_stateful_update: Whether to do stateful partition update.
Jae Hoon Kimcc723e02021-08-16 21:03:21 +000091 no_minios_update: Whether to do minios partition update.
Amin Hassani92f6c4a2021-02-20 17:36:09 -080092 no_reboot: Whether to reboot device after update. The default is True.
93 disable_verification: Whether to disabling rootfs verification on the
94 device.
95 clobber_stateful: Whether to do a clean stateful partition.
96 clear_tpm_owner: If true, it will clear the TPM owner on reboot.
97 """
98
99 self._device = device
100 self._image = image
Amin Hassanicf8f0042021-03-12 10:42:13 -0800101 self._board = board
102 self._version = version
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800103 self._no_rootfs_update = no_rootfs_update
104 self._no_stateful_update = no_stateful_update
Jae Hoon Kimcc723e02021-08-16 21:03:21 +0000105 self._no_minios_update = no_minios_update
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800106 self._no_reboot = no_reboot
107 self._disable_verification = disable_verification
108 self._clobber_stateful = clobber_stateful
109 self._clear_tpm_owner = clear_tpm_owner
110
Amin Hassanib1993eb2021-04-28 12:00:11 -0700111 self._image_type = None
Amin Hassani0972d392021-03-31 19:04:19 -0700112 self._compression = cros_build_lib.COMP_GZIP
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800113 self._inactive_state = None
114
115 def Run(self):
116 """Update the device with image of specific version."""
Amin Hassanib1993eb2021-04-28 12:00:11 -0700117 self._LocateImage()
118 logging.notice('Preparing to update the remote device %s with image %s',
119 self._device.hostname, self._image)
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800120
121 try:
Amin Hassani55970562021-02-22 20:49:13 -0800122 if command.UseProgressBar():
123 op = DeviceImagerOperation()
124 op.Run(self._Run)
125 else:
126 self._Run()
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800127 except Exception as e:
128 raise Error(f'DeviceImager Failed with error: {e}')
129
Amin Hassani55970562021-02-22 20:49:13 -0800130 # DeviceImagerOperation will look for this log.
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800131 logging.info('DeviceImager completed.')
132
133 def _Run(self):
134 """Runs the various operations to install the image on device."""
Amin Hassanib1993eb2021-04-28 12:00:11 -0700135 # Override the compression as remote quick provision images are gzip
136 # compressed only.
137 if self._image_type == ImageType.REMOTE_DIRECTORY:
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800138 self._compression = cros_build_lib.COMP_GZIP
139
Amin Hassanib1993eb2021-04-28 12:00:11 -0700140 self._InstallPartitions()
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800141
142 if self._clear_tpm_owner:
143 self._device.ClearTpmOwner()
144
145 if not self._no_reboot:
146 self._Reboot()
147 self._VerifyBootExpectations()
148
149 if self._disable_verification:
150 self._device.DisableRootfsVerification()
151
Amin Hassanib1993eb2021-04-28 12:00:11 -0700152 def _LocateImage(self):
153 """Locates the path to the final image(s) that need to be installed.
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800154
155 If the paths is local, the image should be the Chromium OS GPT image
156 (e.g. chromiumos_test_image.bin). If the path is remote, it should be the
157 remote directory where we can find the quick-provision and stateful update
158 files (e.g. gs://chromeos-image-archive/eve-release/R90-x.x.x).
159
160 NOTE: At this point there is no caching involved. Hence we always download
161 the partition payloads or extract them from the Chromium OS image.
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800162 """
163 if os.path.isfile(self._image):
Amin Hassanib1993eb2021-04-28 12:00:11 -0700164 self._image_type = ImageType.FULL
165 return
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800166
167 # TODO(b/172212406): We could potentially also allow this by searching
168 # through the directory to see whether we have quick-provision and stateful
169 # payloads. This only makes sense when a user has their workstation at home
170 # and doesn't want to incur the bandwidth cost of downloading the same
171 # image multiple times. For that, they can simply download the GPT image
172 # image first and flash that instead.
173 if os.path.isdir(self._image):
174 raise ValueError(
175 f'{self._image}: input must be a disk image, not a directory.')
176
177 if gs.PathIsGs(self._image):
178 # TODO(b/172212406): Check whether it is a directory. If it wasn't a
179 # directory download the image into some temp location and use it instead.
Amin Hassanib1993eb2021-04-28 12:00:11 -0700180 self._image_type = ImageType.REMOTE_DIRECTORY
181 return
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800182
183 # Assuming it is an xBuddy path.
Amin Hassanicf8f0042021-03-12 10:42:13 -0800184 board = cros_build_lib.GetBoard(
185 device_board=self._device.board or flash.GetDefaultBoard(),
186 override_board=self._board, force=True)
187
Amin Hassania20d20d2021-04-28 10:18:18 -0700188 xb = xbuddy.XBuddy(board=board, version=self._version)
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800189 build_id, local_file = xb.Translate([self._image])
190 if build_id is None:
191 raise Error(f'{self._image}: unable to find matching xBuddy path.')
192 logging.info('XBuddy path translated to build ID %s', build_id)
193
194 if local_file:
Amin Hassanib1993eb2021-04-28 12:00:11 -0700195 self._image = local_file
196 self._image_type = ImageType.FULL
197 return
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800198
Amin Hassanib1993eb2021-04-28 12:00:11 -0700199 self._image = f'{devserver_constants.GS_IMAGE_DIR}/{build_id}'
200 self._image_type = ImageType.REMOTE_DIRECTORY
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800201
202 def _SplitDevPath(self, path: str) -> Tuple[str, int]:
203 """Splits the given /dev/x path into prefix and the dev number.
204
205 Args:
206 path: The path to a block dev device.
207
208 Returns:
209 A tuple of representing the prefix and the index of the dev path.
210 e.g.: '/dev/mmcblk0p1' -> ['/dev/mmcblk0p', 1]
211 """
212 match = re.search(r'(.*)([0-9]+)$', path)
213 if match is None:
214 raise Error(f'{path}: Could not parse root dev path.')
215
216 return match.group(1), int(match.group(2))
217
218 def _GetKernelState(self, root_num: int) -> Tuple[Dict, Dict]:
219 """Returns the kernel state.
220
221 Returns:
222 A tuple of two dictionaries: The current active kernel state and the
223 inactive kernel state. (Look at A and B constants in this class.)
224 """
225 if root_num == self.A[Partition.ROOTFS]:
226 return self.A, self.B
227 elif root_num == self.B[Partition.ROOTFS]:
228 return self.B, self.A
229 else:
230 raise Error(f'Invalid root partition number {root_num}')
231
Jae Hoon Kimcc723e02021-08-16 21:03:21 +0000232 def _GetMiniOSState(self, minios_num: int) -> Tuple[Dict, Dict]:
233 """Returns the miniOS state.
234
235 Returns:
236 A tuple of dictionaries: The current active miniOS state and the inactive
237 miniOS state.
238 """
239 if minios_num == self.MINIOS_A[Partition.MINIOS]:
240 return self.MINIOS_A, self.MINIOS_B
241 elif minios_num == self.MINIOS_B[Partition.MINIOS]:
242 return self.MINIOS_B, self.MINIOS_A
243 else:
244 raise Error(f'Invalid minios partition number {minios_num}')
245
Amin Hassanib1993eb2021-04-28 12:00:11 -0700246 def _InstallPartitions(self):
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800247 """The main method that installs the partitions of a Chrome OS device.
248
249 It uses parallelism to install the partitions as fast as possible.
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800250 """
Amin Hassanid684e982021-02-26 11:10:58 -0800251 prefix, root_num = self._SplitDevPath(self._device.root_dev)
252 active_state, self._inactive_state = self._GetKernelState(root_num)
253
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800254 updaters = []
Amin Hassanid684e982021-02-26 11:10:58 -0800255 if not self._no_rootfs_update:
Amin Hassani75c5f942021-02-20 23:56:53 -0800256 current_root = prefix + str(active_state[Partition.ROOTFS])
257 target_root = prefix + str(self._inactive_state[Partition.ROOTFS])
Amin Hassanib1993eb2021-04-28 12:00:11 -0700258 updaters.append(RootfsUpdater(current_root, self._device, self._image,
259 self._image_type, target_root,
260 self._compression))
Amin Hassani75c5f942021-02-20 23:56:53 -0800261
Amin Hassanid684e982021-02-26 11:10:58 -0800262 target_kernel = prefix + str(self._inactive_state[Partition.KERNEL])
Amin Hassanib1993eb2021-04-28 12:00:11 -0700263 updaters.append(KernelUpdater(self._device, self._image, self._image_type,
Amin Hassanid684e982021-02-26 11:10:58 -0800264 target_kernel, self._compression))
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800265
Amin Hassani74403082021-02-22 11:40:09 -0800266 if not self._no_stateful_update:
267 updaters.append(StatefulUpdater(self._clobber_stateful, self._device,
Amin Hassanib1993eb2021-04-28 12:00:11 -0700268 self._image, self._image_type, None,
269 None))
Amin Hassani74403082021-02-22 11:40:09 -0800270
Jae Hoon Kimcc723e02021-08-16 21:03:21 +0000271 if not self._no_minios_update:
272 # Reference disk_layout_v3 for partition numbering.
273 _, inactive_minios_state = self._GetMiniOSState(
274 9 if self._device.run(
275 ['crossystem', constants.MINIOS_PRIORITY]).output == 'A' else 10)
276 target_minios = prefix + str(inactive_minios_state[Partition.MINIOS])
277 updaters.append(MiniOSUpdater(self._device, self._image, self._image_type,
278 target_minios, self._compression))
279
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800280 # Retry the partitions updates that failed, in case a transient error (like
281 # SSH drop, etc) caused the error.
282 num_retries = 1
283 try:
284 retry_util.RetryException(Error, num_retries,
285 parallel.RunParallelSteps,
286 (x.Run for x in updaters if not x.IsFinished()),
287 halt_on_error=True)
288 except Exception:
289 # If one of the partitions failed to be installed, revert all partitions.
290 parallel.RunParallelSteps(x.Revert for x in updaters)
291 raise
292
293 def _Reboot(self):
294 """Reboots the device."""
295 try:
Amin Hassani9281b682021-03-08 16:38:25 -0800296 self._device.Reboot(timeout_sec=300)
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800297 except remote_access.RebootError:
298 raise Error('Could not recover from reboot. Once example reason'
299 ' could be the image provided was a non-test image'
300 ' or the system failed to boot after the update.')
301 except Exception as e:
302 raise Error(f'Failed to reboot to the device with error: {e}')
303
304 def _VerifyBootExpectations(self):
305 """Verify that we fully booted into the expected kernel state."""
306 # Discover the newly active kernel.
307 _, root_num = self._SplitDevPath(self._device.root_dev)
308 active_state, _ = self._GetKernelState(root_num)
309
310 # If this happens, we should rollback.
311 if active_state != self._inactive_state:
312 raise Error('The expected kernel state after update is invalid.')
313
314 logging.info('Verified boot expectations.')
Amin Hassanid4b3ff82021-02-20 23:05:14 -0800315
316
317class ReaderBase(threading.Thread):
318 """The base class for reading different inputs and writing into output.
319
320 This class extends threading.Thread, so it will be run on its own thread. Also
321 it can be used as a context manager. Internally, it opens necessary files for
322 writing to and reading from. This class cannot be instantiated, it needs to be
323 sub-classed first to provide necessary function implementations.
324 """
325
326 def __init__(self, use_named_pipes: bool = False):
327 """Initializes the class.
328
329 Args:
330 use_named_pipes: Whether to use a named pipe or anonymous file
331 descriptors.
332 """
333 super().__init__()
334 self._use_named_pipes = use_named_pipes
335 self._pipe_target = None
336 self._pipe_source = None
337
338 def __del__(self):
339 """Destructor.
340
341 Make sure to clean up any named pipes we might have created.
342 """
343 if self._use_named_pipes:
344 osutils.SafeUnlink(self._pipe_target)
345
346 def __enter__(self):
347 """Enters the context manager"""
348 if self._use_named_pipes:
349 # There is no need for the temp file, we only need its path. So the named
350 # pipe is created after this temp file is deleted.
351 with tempfile.NamedTemporaryFile(prefix='chromite-device-imager') as fp:
352 self._pipe_target = self._pipe_source = fp.name
353 os.mkfifo(self._pipe_target)
354 else:
355 self._pipe_target, self._pipe_source = os.pipe()
356
357 self.start()
358 return self
359
360 def __exit__(self, *args, **kwargs):
361 """Exits the context manager."""
362 self.join()
363
364 def _Source(self):
365 """Returns the source pipe to write data into.
366
367 Sub-classes can use this function to determine where to write their data
368 into.
369 """
370 return self._pipe_source
371
372 def _CloseSource(self):
373 """Closes the source pipe.
374
375 Sub-classes should use this function to close the pipe after they are done
376 writing into it. Failure to do so may result reader of the data to hang
377 indefinitely.
378 """
379 if not self._use_named_pipes:
380 os.close(self._pipe_source)
381
382 def Target(self):
383 """Returns the target pipe to read data from.
384
385 Users of this class can use this path to read data from.
386 """
387 return self._pipe_target
388
389 def CloseTarget(self):
390 """Closes the target pipe.
391
392 Users of this class should use this function to close the pipe after they
393 are done reading from it.
394 """
395 if self._use_named_pipes:
396 os.remove(self._pipe_target)
397 else:
398 os.close(self._pipe_target)
399
400
401class PartialFileReader(ReaderBase):
402 """A class to read specific offset and length from a file and compress it.
403
404 This class can be used to read from specific location and length in a file
405 (e.g. A partition in a GPT image). Then it compresses the input and writes it
406 out (to a pipe). Look at the base class for more information.
407 """
408
409 # The offset of different partitions in a Chromium OS image does not always
410 # align to larger values like 4096. It seems that 512 is the maximum value to
411 # be divisible by partition offsets. This size should not be increased just
412 # for 'performance reasons'. Since we are doing everything in parallel, in
413 # practice there is not much difference between this and larger block sizes as
414 # parallelism hides the possible extra latency provided by smaller block
415 # sizes.
416 _BLOCK_SIZE = 512
417
418 def __init__(self, image: str, offset: int, length: int, compression):
419 """Initializes the class.
420
421 Args:
422 image: The path to an image (local or remote directory).
423 offset: The offset (in bytes) to read from the image.
424 length: The length (in bytes) to read from the image.
425 compression: The compression type (see cros_build_lib.COMP_XXX).
426 """
427 super().__init__()
428
429 self._image = image
430 self._offset = offset
431 self._length = length
432 self._compression = compression
433
434 def run(self):
435 """Runs the reading and compression."""
436 cmd = [
437 'dd',
438 'status=none',
439 f'if={self._image}',
440 f'ibs={self._BLOCK_SIZE}',
441 f'skip={int(self._offset/self._BLOCK_SIZE)}',
442 f'count={int(self._length/self._BLOCK_SIZE)}',
443 '|',
444 cros_build_lib.FindCompressor(self._compression),
445 ]
446
447 try:
448 cros_build_lib.run(' '.join(cmd), stdout=self._Source(), shell=True)
449 finally:
450 self._CloseSource()
451
452
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800453class GsFileCopier(ReaderBase):
454 """A class for downloading gzip compressed file from GS bucket into a pipe."""
455
456 def __init__(self, image: str):
457 """Initializes the class.
458
459 Args:
460 image: The path to an image (local or remote directory).
461 """
462 super().__init__(use_named_pipes=True)
463 self._image = image
464
465 def run(self):
466 """Runs the download and write into the output pipe."""
467 try:
468 gs.GSContext().Copy(self._image, self._Source())
469 finally:
470 self._CloseSource()
471
472
Amin Hassanid4b3ff82021-02-20 23:05:14 -0800473class PartitionUpdaterBase(object):
474 """A base abstract class to use for installing an image into a partition.
475
476 Sub-classes should implement the abstract methods to provide the core
477 functionality.
478 """
479 def __init__(self, device, image: str, image_type, target: str, compression):
480 """Initializes this base class with values that most sub-classes will need.
481
482 Args:
483 device: The ChromiumOSDevice to be updated.
484 image: The target image path for the partition update.
485 image_type: The type of the image (ImageType).
486 target: The target path (e.g. block dev) to install the update.
487 compression: The compression used for compressing the update payload.
488 """
489 self._device = device
490 self._image = image
491 self._image_type = image_type
492 self._target = target
493 self._compression = compression
494 self._finished = False
495
496 def Run(self):
497 """The main function that does the partition update job."""
498 with cros_build_lib.TimedSection() as timer:
499 try:
500 self._Run()
501 finally:
502 self._finished = True
503
504 logging.debug('Completed %s in %s', self.__class__.__name__, timer.delta)
505
506 @abc.abstractmethod
507 def _Run(self):
508 """The method that need to be implemented by sub-classes."""
509 raise NotImplementedError('Sub-classes need to implement this.')
510
511 def IsFinished(self):
512 """Returns whether the partition update has been successful."""
513 return self._finished
514
515 @abc.abstractmethod
516 def Revert(self):
517 """Reverts the partition update.
518
519 Sub-classes need to implement this function to provide revert capability.
520 """
521 raise NotImplementedError('Sub-classes need to implement this.')
522
523
524class RawPartitionUpdater(PartitionUpdaterBase):
525 """A class to update a raw partition on a Chromium OS device."""
526
527 def _Run(self):
528 """The function that does the job of kernel partition update."""
529 if self._image_type == ImageType.FULL:
530 self._CopyPartitionFromImage(self._GetPartitionName())
531 elif self._image_type == ImageType.REMOTE_DIRECTORY:
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800532 self._RedirectPartition(self._GetRemotePartitionName())
Amin Hassanid4b3ff82021-02-20 23:05:14 -0800533 else:
534 raise ValueError(f'Invalid image type {self._image_type}')
535
536 def _GetPartitionName(self):
537 """Returns the name of the partition in a Chromium OS GPT layout.
538
539 Subclasses should override this function to return correct name.
540 """
541 raise NotImplementedError('Subclasses need to implement this.')
542
543 def _CopyPartitionFromImage(self, part_name: str):
544 """Updates the device's partition from a local Chromium OS image.
545
546 Args:
547 part_name: The name of the partition in the source image that needs to be
548 extracted.
549 """
550 cmd = self._GetWriteToTargetCommand()
551
552 offset, length = self._GetPartLocation(part_name)
553 offset, length = self._OptimizePartLocation(offset, length)
554 with PartialFileReader(self._image, offset, length,
555 self._compression) as generator:
556 try:
557 self._device.run(cmd, input=generator.Target(), shell=True)
558 finally:
559 generator.CloseTarget()
560
561 def _GetWriteToTargetCommand(self):
562 """Returns a write to target command to run on a Chromium OS device.
563
564 Returns:
565 A string command to run on a device to read data from stdin, uncompress it
566 and write it to the target partition.
567 """
568 cmd = self._device.GetDecompressor(self._compression)
569 # Using oflag=direct to tell the OS not to cache the writes (faster).
570 cmd += ['|', 'dd', 'bs=1M', 'oflag=direct', f'of={self._target}']
571 return ' '.join(cmd)
572
573 def _GetPartLocation(self, part_name: str):
Jae Hoon Kimcc723e02021-08-16 21:03:21 +0000574 """Extracts the location and size of the raw partition from the image.
Amin Hassanid4b3ff82021-02-20 23:05:14 -0800575
576 Args:
577 part_name: The name of the partition in the source image that needs to be
578 extracted.
579
580 Returns:
581 A tuple of offset and length (in bytes) from the image.
582 """
583 try:
584 parts = image_lib.GetImageDiskPartitionInfo(self._image)
585 part_info = [p for p in parts if p.name == part_name][0]
586 except IndexError:
587 raise Error(f'No partition named {part_name} found.')
588
589 return int(part_info.start), int(part_info.size)
590
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800591 def _GetRemotePartitionName(self):
592 """Returns the name of the quick-provision partition file.
593
594 Subclasses should override this function to return correct name.
595 """
596 raise NotImplementedError('Subclasses need to implement this.')
597
Amin Hassanid4b3ff82021-02-20 23:05:14 -0800598 def _OptimizePartLocation(self, offset: int, length: int):
599 """Optimizes the offset and length of the partition.
600
601 Subclasses can override this to provide better offset/length than what is
602 defined in the PGT partition layout.
603
604 Args:
605 offset: The offset (in bytes) of the partition in the image.
606 length: The length (in bytes) of the partition.
607
608 Returns:
609 A tuple of offset and length (in bytes) from the image.
610 """
611 return offset, length
Amin Hassanid684e982021-02-26 11:10:58 -0800612
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800613 def _RedirectPartition(self, file_name: str):
614 """Downloads the partition from a remote path and writes it into target.
615
616 Args:
617 file_name: The file name in the remote directory self._image.
618 """
619 cmd = self._GetWriteToTargetCommand()
620
621 image_path = os.path.join(self._image, file_name)
622 with GsFileCopier(image_path) as generator:
623 try:
624 with open(generator.Target(), 'rb') as fp:
625 self._device.run(cmd, input=fp, shell=True)
626 finally:
627 generator.CloseTarget()
628
Amin Hassanid684e982021-02-26 11:10:58 -0800629
630class KernelUpdater(RawPartitionUpdater):
631 """A class to update the kernel partition on a Chromium OS device."""
632
633 def _GetPartitionName(self):
634 """See RawPartitionUpdater._GetPartitionName()."""
635 return constants.PART_KERN_B
636
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800637 def _GetRemotePartitionName(self):
638 """See RawPartitionUpdater._GetRemotePartitionName()."""
639 return constants.QUICK_PROVISION_PAYLOAD_KERNEL
640
Amin Hassanid684e982021-02-26 11:10:58 -0800641 def Revert(self):
642 """Reverts the kernel partition update."""
643 # There is nothing to do for reverting kernel partition.
Amin Hassani75c5f942021-02-20 23:56:53 -0800644
645
646class RootfsUpdater(RawPartitionUpdater):
647 """A class to update the root partition on a Chromium OS device."""
648
649 def __init__(self, current_root: str, *args):
650 """Initializes the class.
651
652 Args:
653 current_root: The current root device path.
654 *args: See PartitionUpdaterBase
655 """
656 super().__init__(*args)
657
658 self._current_root = current_root
659 self._ran_postinst = False
660
661 def _GetPartitionName(self):
662 """See RawPartitionUpdater._GetPartitionName()."""
663 return constants.PART_ROOT_A
664
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800665 def _GetRemotePartitionName(self):
666 """See RawPartitionUpdater._GetRemotePartitionName()."""
667 return constants.QUICK_PROVISION_PAYLOAD_ROOTFS
668
Amin Hassani75c5f942021-02-20 23:56:53 -0800669 def _Run(self):
670 """The function that does the job of rootfs partition update."""
Amin Hassani55970562021-02-22 20:49:13 -0800671 with ProgressWatcher(self._device, self._target):
672 super()._Run()
Amin Hassani75c5f942021-02-20 23:56:53 -0800673
674 self._RunPostInst()
675
676 def _OptimizePartLocation(self, offset: int, length: int):
677 """Optimizes the size of the root partition of the image.
678
679 Normally the file system does not occupy the entire partition. Furthermore
680 we don't need the verity hash tree at the end of the root file system
681 because postinst will recreate it. This function reads the (approximate)
682 superblock of the ext4 partition and extracts the actual file system size in
683 the root partition.
684 """
685 superblock_size = 4096 * 2
686 with open(self._image, 'rb') as r:
687 r.seek(offset)
688 with tempfile.NamedTemporaryFile(delete=False) as fp:
689 fp.write(r.read(superblock_size))
690 fp.close()
691 return offset, partition_lib.Ext2FileSystemSize(fp.name)
692
693 def _RunPostInst(self, on_target: bool = True):
694 """Runs the postinst process in the root partition.
695
696 Args:
697 on_target: If true the postinst is run on the target (inactive)
698 partition. This is used when doing normal updates. If false, the
699 postinst is run on the current (active) partition. This is used when
700 reverting an update.
701 """
702 try:
703 postinst_dir = '/'
704 partition = self._current_root
705 if on_target:
706 postinst_dir = self._device.run(
707 ['mktemp', '-d', '-p', self._device.work_dir],
708 capture_output=True).stdout.strip()
709 self._device.run(['mount', '-o', 'ro', self._target, postinst_dir])
710 partition = self._target
711
712 self._ran_postinst = True
713 postinst = os.path.join(postinst_dir, 'postinst')
714 result = self._device.run([postinst, partition], capture_output=True)
715
716 logging.debug('Postinst result on %s: \n%s', postinst, result.stdout)
Amin Hassani55970562021-02-22 20:49:13 -0800717 # DeviceImagerOperation will look for this log.
Amin Hassani75c5f942021-02-20 23:56:53 -0800718 logging.info('Postinstall completed.')
719 finally:
720 if on_target:
721 self._device.run(['umount', postinst_dir])
722
723 def Revert(self):
724 """Reverts the root update install."""
725 logging.info('Reverting the rootfs partition update.')
726 if self._ran_postinst:
727 # We don't have to do anything for revert if we haven't changed the kernel
728 # priorities yet.
729 self._RunPostInst(on_target=False)
Amin Hassani74403082021-02-22 11:40:09 -0800730
731
Jae Hoon Kimcc723e02021-08-16 21:03:21 +0000732class MiniOSUpdater(RawPartitionUpdater):
733 """A class to update the miniOS partition on a Chromium OS device."""
734
735 def __init__(self, *args):
736 """Initializes the class.
737
738 Args:
739 *args: See PartitionUpdaterBase
740 """
741 super().__init__(*args)
742
743 self._ran_postinst = False
744
745 def _GetPartitionName(self):
746 """See RawPartitionUpdater._GetPartitionName()."""
747 return constants.PART_MINIOS_A
748
749 def _GetRemotePartitionName(self):
750 """See RawPartitionUpdater._GetRemotePartitionName()."""
751 # TODO(b/190631159, b/196056723): Allow fetching once miniOS payloads exist.
752 raise NotImplementedError("MiniOS payloads aren't uploaded yet.")
753
754 def _Run(self):
755 """The function that does the job of rootfs partition update."""
756 if self._image_type in [ImageType.FULL, ImageType.REMOTE_DIRECTORY]:
757 if self._MiniOSPartitionExists():
758 logging.info('Updating miniOS partition.')
759 super()._Run()
760 else:
761 logging.info('Not updating miniOS partition as it does not exist.')
762 else:
763 # Let super() handle this error.
764 super()._Run()
765
766 self._RunPostInstall()
767
768 def _RunPostInstall(self):
769 """The function will change the priority of the miniOS partitions."""
770 self._FlipMiniOSPriority()
771 self._ran_postinst = True
772
773 def Revert(self):
774 """Reverts the miniOS partition update."""
775 if self._ran_postinst:
776 self._FlipMiniOSPriority()
777
778 def _GetMiniOSPriority(self):
779 return self._device.run(['crossystem', constants.MINIOS_PRIORITY]).output
780
781 def _SetMiniOSPriority(self, priority: str):
782 self._device.run(
783 ['crossystem', f'{constants.MINIOS_PRIORITY}={priority}'])
784
785 def _FlipMiniOSPriority(self):
786 inactive_minios_priority = 'B' if self._GetMiniOSPriority() == 'A' else 'A'
787 logging.info('Setting miniOS priority to %s', inactive_minios_priority)
788 self._SetMiniOSPriority(inactive_minios_priority)
789
790 def _MiniOSPartitionExists(self):
791 """Checks if miniOS partition exists."""
792 if self._image_type == ImageType.FULL:
793 d = cgpt.Disk.FromImage(self._image)
794 try:
795 d.GetPartitionByTypeGuid(cgpt.MINIOS_TYPE_GUID)
796 return True
797 except KeyError:
798 return False
799 elif self._image_type == ImageType.REMOTE_DIRECTORY:
800 # TODO(b/190631159, b/196056723): Check miniOS payload in remote.
801 return False
802
803
Amin Hassani74403082021-02-22 11:40:09 -0800804class StatefulPayloadGenerator(ReaderBase):
805 """A class for generating a stateful update payload in a separate thread."""
806 def __init__(self, image: str):
807 """Initializes that class.
808
809 Args:
810 image: The path to a local Chromium OS image.
811 """
812 super().__init__()
813 self._image = image
814
815 def run(self):
816 """Generates the stateful update and writes it into the output pipe."""
817 try:
818 paygen_stateful_payload_lib.GenerateStatefulPayload(
819 self._image, self._Source())
820 finally:
821 self._CloseSource()
822
823
824class StatefulUpdater(PartitionUpdaterBase):
825 """A class to update the stateful partition on a device."""
826 def __init__(self, clobber_stateful: bool, *args):
827 """Initializes the class
828
829 Args:
830 clobber_stateful: Whether to clobber the stateful or not.
831 *args: Look at PartitionUpdaterBase.
832 """
833 super().__init__(*args)
834 self._clobber_stateful = clobber_stateful
835
836 def _Run(self):
837 """Reads/Downloads the stateful updates and writes it into the device."""
838 if self._image_type == ImageType.FULL:
839 generator_cls = StatefulPayloadGenerator
840 elif self._image_type == ImageType.REMOTE_DIRECTORY:
841 generator_cls = GsFileCopier
842 self._image = os.path.join(self._image,
843 paygen_stateful_payload_lib.STATEFUL_FILE)
844 else:
845 raise ValueError(f'Invalid image type {self._image_type}')
846
847 with generator_cls(self._image) as generator:
848 try:
849 updater = stateful_updater.StatefulUpdater(self._device)
850 updater.Update(
851 generator.Target(),
852 is_payload_on_device=False,
853 update_type=(stateful_updater.StatefulUpdater.UPDATE_TYPE_CLOBBER if
854 self._clobber_stateful else None))
855 finally:
856 generator.CloseTarget()
857
858 def Revert(self):
859 """Reverts the stateful partition update."""
860 logging.info('Reverting the stateful update.')
861 stateful_updater.StatefulUpdater(self._device).Reset()
Amin Hassani55970562021-02-22 20:49:13 -0800862
863
864class ProgressWatcher(threading.Thread):
865 """A class used for watching the progress of rootfs update."""
866
867 def __init__(self, device, target_root: str):
868 """Initializes the class.
869
870 Args:
871 device: The ChromiumOSDevice to be updated.
872 target_root: The target root partition to monitor the progress of.
873 """
874 super().__init__()
875
876 self._device = device
877 self._target_root = target_root
878 self._exit = False
879
880 def __enter__(self):
881 """Starts the thread."""
882 self.start()
883 return self
884
885 def __exit__(self, *args, **kwargs):
886 """Exists the thread."""
887 self._exit = True
888 self.join()
889
890 def _ShouldExit(self):
891 return self._exit
892
893 def run(self):
894 """Monitors the progress of the target root partitions' update.
895
896 This is done by periodically, reading the fd position of the process that is
897 writing into the target partition and reporting it back. Then the position
898 is divided by the size of the block device to report an approximate
899 progress.
900 """
901 cmd = ['blockdev', '--getsize64', self._target_root]
902 output = self._device.run(cmd, capture_output=True).stdout.strip()
903 if output is None:
904 raise Error(f'Cannot get the block device size from {output}.')
905 dev_size = int(output)
906
907 # Using lsof to find out which process is writing to the target rootfs.
908 cmd = f'lsof 2>/dev/null | grep {self._target_root}'
909 while not self._ShouldExit():
910 try:
911 output = self._device.run(cmd, capture_output=True,
912 shell=True).stdout.strip()
913 if output:
914 break
915 except cros_build_lib.RunCommandError:
916 continue
917 finally:
918 time.sleep(1)
919
920 # Now that we know which process is writing to it, we can look the fdinfo of
921 # stdout of that process to get its offset. We're assuming there will be no
922 # seek, which is correct.
923 pid = output.split()[1]
924 cmd = ['cat', f'/proc/{pid}/fdinfo/1']
925 while not self._ShouldExit():
926 try:
927 output = self._device.run(cmd, capture_output=True).stdout.strip()
928 m = re.search(r'^pos:\s*(\d+)$', output, flags=re.M)
929 if m:
930 offset = int(m.group(1))
931 # DeviceImagerOperation will look for this log.
932 logging.info('RootFS progress: %f', offset/dev_size)
933 except cros_build_lib.RunCommandError:
934 continue
935 finally:
936 time.sleep(1)
937
938
939class DeviceImagerOperation(operation.ProgressBarOperation):
940 """A class to provide a progress bar for DeviceImager operation."""
941
942 def __init__(self):
943 """Initializes the class."""
944 super().__init__()
945
946 self._progress = 0.0
947
948 def ParseOutput(self, output=None):
949 """Override function to parse the output and provide progress.
950
951 Args:
952 output: The stderr or stdout.
953 """
954 output = self._stdout.read()
955 match = re.findall(r'RootFS progress: (\d+(?:\.\d+)?)', output)
956 if match:
957 progress = float(match[0])
958 self._progress = max(self._progress, progress)
959
960 # If postinstall completes, move half of the remaining progress.
961 if re.findall(r'Postinstall completed', output):
962 self._progress += (1.0 - self._progress) / 2
963
964 # While waiting for reboot, each time, move half of the remaining progress.
965 if re.findall(r'Unable to get new boot_id', output):
966 self._progress += (1.0 - self._progress) / 2
967
968 if re.findall(r'DeviceImager completed.', output):
969 self._progress = 1.0
970
971 self.ProgressBar(self._progress)