blob: c17e8a6a6274859273fde8ac25b4199bd8ad9cd2 [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 Hassani92f6c4a2021-02-20 17:36:09 -080014from typing import Tuple, Dict
15
Amin Hassanid4b3ff82021-02-20 23:05:14 -080016from chromite.lib import constants
Amin Hassani92f6c4a2021-02-20 17:36:09 -080017from chromite.lib import cros_build_lib
18from chromite.lib import cros_logging as logging
19from chromite.lib import gs
Amin Hassanid4b3ff82021-02-20 23:05:14 -080020from chromite.lib import image_lib
21from chromite.lib import osutils
Amin Hassani92f6c4a2021-02-20 17:36:09 -080022from chromite.lib import parallel
23from chromite.lib import remote_access
24from chromite.lib import retry_util
Amin Hassani75c5f942021-02-20 23:56:53 -080025from chromite.lib.paygen import partition_lib
Amin Hassani92f6c4a2021-02-20 17:36:09 -080026from chromite.lib.xbuddy import devserver_constants
27from chromite.lib.xbuddy import xbuddy
28
29
30assert sys.version_info >= (3, 6), 'This module requires Python 3.6+'
31
32
33class Error(Exception):
34 """Thrown when there is a general Chromium OS-specific flash error."""
35
36
37class ImageType(enum.Enum):
38 """Type of the image that is used for flashing the device."""
39
40 # The full image on disk (e.g. chromiumos_test_image.bin).
41 FULL = 0
42 # The remote directory path
43 # (e.g gs://chromeos-image-archive/eve-release/R90-x.x.x)
44 REMOTE_DIRECTORY = 1
45
46
47class Partition(enum.Enum):
48 """An enum for partition types like kernel and rootfs."""
49 KERNEL = 0
50 ROOTFS = 1
51
52
53class DeviceImager(object):
54 """A class to flash a Chromium OS device.
55
56 This utility uses parallelism as much as possible to achieve its goal as fast
57 as possible. For example, it uses parallel compressors, parallel transfers,
58 and simultaneous pipes.
59 """
60
61 # The parameters of the kernel and rootfs's two main partitions.
62 A = {Partition.KERNEL: 2, Partition.ROOTFS: 3}
63 B = {Partition.KERNEL: 4, Partition.ROOTFS: 5}
64
65 def __init__(self, device, image: str,
66 no_rootfs_update: bool = False,
67 no_stateful_update: bool = False,
68 no_reboot: bool = False,
69 disable_verification: bool = False,
70 clobber_stateful: bool = False,
71 clear_tpm_owner: bool = False):
72 """Initialize DeviceImager for flashing a Chromium OS device.
73
74 Args:
75 device: The ChromiumOSDevice to be updated.
76 image: The target image path (can be xBuddy path).
77 no_rootfs_update: Whether to do rootfs partition update.
78 no_stateful_update: Whether to do stateful partition update.
79 no_reboot: Whether to reboot device after update. The default is True.
80 disable_verification: Whether to disabling rootfs verification on the
81 device.
82 clobber_stateful: Whether to do a clean stateful partition.
83 clear_tpm_owner: If true, it will clear the TPM owner on reboot.
84 """
85
86 self._device = device
87 self._image = image
88 self._no_rootfs_update = no_rootfs_update
89 self._no_stateful_update = no_stateful_update
90 self._no_reboot = no_reboot
91 self._disable_verification = disable_verification
92 self._clobber_stateful = clobber_stateful
93 self._clear_tpm_owner = clear_tpm_owner
94
95 self._compression = cros_build_lib.COMP_XZ
96 self._inactive_state = None
97
98 def Run(self):
99 """Update the device with image of specific version."""
100
101 try:
102 self._Run()
103 except Exception as e:
104 raise Error(f'DeviceImager Failed with error: {e}')
105
106 logging.info('DeviceImager completed.')
107
108 def _Run(self):
109 """Runs the various operations to install the image on device."""
110 image, image_type = self._GetImage()
111 logging.info('Using image %s of type %s', image, image_type )
112
113 if image_type == ImageType.REMOTE_DIRECTORY:
114 self._compression = cros_build_lib.COMP_GZIP
115
116 self._InstallPartitions(image, image_type)
117
118 if self._clear_tpm_owner:
119 self._device.ClearTpmOwner()
120
121 if not self._no_reboot:
122 self._Reboot()
123 self._VerifyBootExpectations()
124
125 if self._disable_verification:
126 self._device.DisableRootfsVerification()
127
128 def _GetImage(self) -> Tuple[str, ImageType]:
129 """Returns the path to the final image(s) that need to be installed.
130
131 If the paths is local, the image should be the Chromium OS GPT image
132 (e.g. chromiumos_test_image.bin). If the path is remote, it should be the
133 remote directory where we can find the quick-provision and stateful update
134 files (e.g. gs://chromeos-image-archive/eve-release/R90-x.x.x).
135
136 NOTE: At this point there is no caching involved. Hence we always download
137 the partition payloads or extract them from the Chromium OS image.
138
139 Returns:
140 A tuple of image path and image type.
141 """
142 if os.path.isfile(self._image):
143 return self._image, ImageType.FULL
144
145 # TODO(b/172212406): We could potentially also allow this by searching
146 # through the directory to see whether we have quick-provision and stateful
147 # payloads. This only makes sense when a user has their workstation at home
148 # and doesn't want to incur the bandwidth cost of downloading the same
149 # image multiple times. For that, they can simply download the GPT image
150 # image first and flash that instead.
151 if os.path.isdir(self._image):
152 raise ValueError(
153 f'{self._image}: input must be a disk image, not a directory.')
154
155 if gs.PathIsGs(self._image):
156 # TODO(b/172212406): Check whether it is a directory. If it wasn't a
157 # directory download the image into some temp location and use it instead.
158 return self._image, ImageType.REMOTE_DIRECTORY
159
160 # Assuming it is an xBuddy path.
161 xb = xbuddy.XBuddy(log_screen=False)
162 build_id, local_file = xb.Translate([self._image])
163 if build_id is None:
164 raise Error(f'{self._image}: unable to find matching xBuddy path.')
165 logging.info('XBuddy path translated to build ID %s', build_id)
166
167 if local_file:
168 return local_file, ImageType.FULL
169
170 return (f'{devserver_constants.GS_IMAGE_DIR}/{build_id}',
171 ImageType.REMOTE_DIRECTORY)
172
173 def _SplitDevPath(self, path: str) -> Tuple[str, int]:
174 """Splits the given /dev/x path into prefix and the dev number.
175
176 Args:
177 path: The path to a block dev device.
178
179 Returns:
180 A tuple of representing the prefix and the index of the dev path.
181 e.g.: '/dev/mmcblk0p1' -> ['/dev/mmcblk0p', 1]
182 """
183 match = re.search(r'(.*)([0-9]+)$', path)
184 if match is None:
185 raise Error(f'{path}: Could not parse root dev path.')
186
187 return match.group(1), int(match.group(2))
188
189 def _GetKernelState(self, root_num: int) -> Tuple[Dict, Dict]:
190 """Returns the kernel state.
191
192 Returns:
193 A tuple of two dictionaries: The current active kernel state and the
194 inactive kernel state. (Look at A and B constants in this class.)
195 """
196 if root_num == self.A[Partition.ROOTFS]:
197 return self.A, self.B
198 elif root_num == self.B[Partition.ROOTFS]:
199 return self.B, self.A
200 else:
201 raise Error(f'Invalid root partition number {root_num}')
202
203 def _InstallPartitions(self, image: str, image_type):
204 """The main method that installs the partitions of a Chrome OS device.
205
206 It uses parallelism to install the partitions as fast as possible.
207
208 Args:
209 image: The image path (local file or remote directory).
210 image_type: The type of the image (ImageType).
211 """
Amin Hassanid684e982021-02-26 11:10:58 -0800212 prefix, root_num = self._SplitDevPath(self._device.root_dev)
213 active_state, self._inactive_state = self._GetKernelState(root_num)
214
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800215 updaters = []
Amin Hassanid684e982021-02-26 11:10:58 -0800216 if not self._no_rootfs_update:
Amin Hassani75c5f942021-02-20 23:56:53 -0800217 current_root = prefix + str(active_state[Partition.ROOTFS])
218 target_root = prefix + str(self._inactive_state[Partition.ROOTFS])
219 updaters.append(RootfsUpdater(current_root, self._device, image,
220 image_type, target_root, self._compression))
221
Amin Hassanid684e982021-02-26 11:10:58 -0800222 target_kernel = prefix + str(self._inactive_state[Partition.KERNEL])
223 updaters.append(KernelUpdater(self._device, image, image_type,
224 target_kernel, self._compression))
Amin Hassani92f6c4a2021-02-20 17:36:09 -0800225
226 # Retry the partitions updates that failed, in case a transient error (like
227 # SSH drop, etc) caused the error.
228 num_retries = 1
229 try:
230 retry_util.RetryException(Error, num_retries,
231 parallel.RunParallelSteps,
232 (x.Run for x in updaters if not x.IsFinished()),
233 halt_on_error=True)
234 except Exception:
235 # If one of the partitions failed to be installed, revert all partitions.
236 parallel.RunParallelSteps(x.Revert for x in updaters)
237 raise
238
239 def _Reboot(self):
240 """Reboots the device."""
241 try:
242 self._device.Reboot(timeout_sec=60)
243 except remote_access.RebootError:
244 raise Error('Could not recover from reboot. Once example reason'
245 ' could be the image provided was a non-test image'
246 ' or the system failed to boot after the update.')
247 except Exception as e:
248 raise Error(f'Failed to reboot to the device with error: {e}')
249
250 def _VerifyBootExpectations(self):
251 """Verify that we fully booted into the expected kernel state."""
252 # Discover the newly active kernel.
253 _, root_num = self._SplitDevPath(self._device.root_dev)
254 active_state, _ = self._GetKernelState(root_num)
255
256 # If this happens, we should rollback.
257 if active_state != self._inactive_state:
258 raise Error('The expected kernel state after update is invalid.')
259
260 logging.info('Verified boot expectations.')
Amin Hassanid4b3ff82021-02-20 23:05:14 -0800261
262
263class ReaderBase(threading.Thread):
264 """The base class for reading different inputs and writing into output.
265
266 This class extends threading.Thread, so it will be run on its own thread. Also
267 it can be used as a context manager. Internally, it opens necessary files for
268 writing to and reading from. This class cannot be instantiated, it needs to be
269 sub-classed first to provide necessary function implementations.
270 """
271
272 def __init__(self, use_named_pipes: bool = False):
273 """Initializes the class.
274
275 Args:
276 use_named_pipes: Whether to use a named pipe or anonymous file
277 descriptors.
278 """
279 super().__init__()
280 self._use_named_pipes = use_named_pipes
281 self._pipe_target = None
282 self._pipe_source = None
283
284 def __del__(self):
285 """Destructor.
286
287 Make sure to clean up any named pipes we might have created.
288 """
289 if self._use_named_pipes:
290 osutils.SafeUnlink(self._pipe_target)
291
292 def __enter__(self):
293 """Enters the context manager"""
294 if self._use_named_pipes:
295 # There is no need for the temp file, we only need its path. So the named
296 # pipe is created after this temp file is deleted.
297 with tempfile.NamedTemporaryFile(prefix='chromite-device-imager') as fp:
298 self._pipe_target = self._pipe_source = fp.name
299 os.mkfifo(self._pipe_target)
300 else:
301 self._pipe_target, self._pipe_source = os.pipe()
302
303 self.start()
304 return self
305
306 def __exit__(self, *args, **kwargs):
307 """Exits the context manager."""
308 self.join()
309
310 def _Source(self):
311 """Returns the source pipe to write data into.
312
313 Sub-classes can use this function to determine where to write their data
314 into.
315 """
316 return self._pipe_source
317
318 def _CloseSource(self):
319 """Closes the source pipe.
320
321 Sub-classes should use this function to close the pipe after they are done
322 writing into it. Failure to do so may result reader of the data to hang
323 indefinitely.
324 """
325 if not self._use_named_pipes:
326 os.close(self._pipe_source)
327
328 def Target(self):
329 """Returns the target pipe to read data from.
330
331 Users of this class can use this path to read data from.
332 """
333 return self._pipe_target
334
335 def CloseTarget(self):
336 """Closes the target pipe.
337
338 Users of this class should use this function to close the pipe after they
339 are done reading from it.
340 """
341 if self._use_named_pipes:
342 os.remove(self._pipe_target)
343 else:
344 os.close(self._pipe_target)
345
346
347class PartialFileReader(ReaderBase):
348 """A class to read specific offset and length from a file and compress it.
349
350 This class can be used to read from specific location and length in a file
351 (e.g. A partition in a GPT image). Then it compresses the input and writes it
352 out (to a pipe). Look at the base class for more information.
353 """
354
355 # The offset of different partitions in a Chromium OS image does not always
356 # align to larger values like 4096. It seems that 512 is the maximum value to
357 # be divisible by partition offsets. This size should not be increased just
358 # for 'performance reasons'. Since we are doing everything in parallel, in
359 # practice there is not much difference between this and larger block sizes as
360 # parallelism hides the possible extra latency provided by smaller block
361 # sizes.
362 _BLOCK_SIZE = 512
363
364 def __init__(self, image: str, offset: int, length: int, compression):
365 """Initializes the class.
366
367 Args:
368 image: The path to an image (local or remote directory).
369 offset: The offset (in bytes) to read from the image.
370 length: The length (in bytes) to read from the image.
371 compression: The compression type (see cros_build_lib.COMP_XXX).
372 """
373 super().__init__()
374
375 self._image = image
376 self._offset = offset
377 self._length = length
378 self._compression = compression
379
380 def run(self):
381 """Runs the reading and compression."""
382 cmd = [
383 'dd',
384 'status=none',
385 f'if={self._image}',
386 f'ibs={self._BLOCK_SIZE}',
387 f'skip={int(self._offset/self._BLOCK_SIZE)}',
388 f'count={int(self._length/self._BLOCK_SIZE)}',
389 '|',
390 cros_build_lib.FindCompressor(self._compression),
391 ]
392
393 try:
394 cros_build_lib.run(' '.join(cmd), stdout=self._Source(), shell=True)
395 finally:
396 self._CloseSource()
397
398
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800399class GsFileCopier(ReaderBase):
400 """A class for downloading gzip compressed file from GS bucket into a pipe."""
401
402 def __init__(self, image: str):
403 """Initializes the class.
404
405 Args:
406 image: The path to an image (local or remote directory).
407 """
408 super().__init__(use_named_pipes=True)
409 self._image = image
410
411 def run(self):
412 """Runs the download and write into the output pipe."""
413 try:
414 gs.GSContext().Copy(self._image, self._Source())
415 finally:
416 self._CloseSource()
417
418
Amin Hassanid4b3ff82021-02-20 23:05:14 -0800419class PartitionUpdaterBase(object):
420 """A base abstract class to use for installing an image into a partition.
421
422 Sub-classes should implement the abstract methods to provide the core
423 functionality.
424 """
425 def __init__(self, device, image: str, image_type, target: str, compression):
426 """Initializes this base class with values that most sub-classes will need.
427
428 Args:
429 device: The ChromiumOSDevice to be updated.
430 image: The target image path for the partition update.
431 image_type: The type of the image (ImageType).
432 target: The target path (e.g. block dev) to install the update.
433 compression: The compression used for compressing the update payload.
434 """
435 self._device = device
436 self._image = image
437 self._image_type = image_type
438 self._target = target
439 self._compression = compression
440 self._finished = False
441
442 def Run(self):
443 """The main function that does the partition update job."""
444 with cros_build_lib.TimedSection() as timer:
445 try:
446 self._Run()
447 finally:
448 self._finished = True
449
450 logging.debug('Completed %s in %s', self.__class__.__name__, timer.delta)
451
452 @abc.abstractmethod
453 def _Run(self):
454 """The method that need to be implemented by sub-classes."""
455 raise NotImplementedError('Sub-classes need to implement this.')
456
457 def IsFinished(self):
458 """Returns whether the partition update has been successful."""
459 return self._finished
460
461 @abc.abstractmethod
462 def Revert(self):
463 """Reverts the partition update.
464
465 Sub-classes need to implement this function to provide revert capability.
466 """
467 raise NotImplementedError('Sub-classes need to implement this.')
468
469
470class RawPartitionUpdater(PartitionUpdaterBase):
471 """A class to update a raw partition on a Chromium OS device."""
472
473 def _Run(self):
474 """The function that does the job of kernel partition update."""
475 if self._image_type == ImageType.FULL:
476 self._CopyPartitionFromImage(self._GetPartitionName())
477 elif self._image_type == ImageType.REMOTE_DIRECTORY:
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800478 self._RedirectPartition(self._GetRemotePartitionName())
Amin Hassanid4b3ff82021-02-20 23:05:14 -0800479 else:
480 raise ValueError(f'Invalid image type {self._image_type}')
481
482 def _GetPartitionName(self):
483 """Returns the name of the partition in a Chromium OS GPT layout.
484
485 Subclasses should override this function to return correct name.
486 """
487 raise NotImplementedError('Subclasses need to implement this.')
488
489 def _CopyPartitionFromImage(self, part_name: str):
490 """Updates the device's partition from a local Chromium OS image.
491
492 Args:
493 part_name: The name of the partition in the source image that needs to be
494 extracted.
495 """
496 cmd = self._GetWriteToTargetCommand()
497
498 offset, length = self._GetPartLocation(part_name)
499 offset, length = self._OptimizePartLocation(offset, length)
500 with PartialFileReader(self._image, offset, length,
501 self._compression) as generator:
502 try:
503 self._device.run(cmd, input=generator.Target(), shell=True)
504 finally:
505 generator.CloseTarget()
506
507 def _GetWriteToTargetCommand(self):
508 """Returns a write to target command to run on a Chromium OS device.
509
510 Returns:
511 A string command to run on a device to read data from stdin, uncompress it
512 and write it to the target partition.
513 """
514 cmd = self._device.GetDecompressor(self._compression)
515 # Using oflag=direct to tell the OS not to cache the writes (faster).
516 cmd += ['|', 'dd', 'bs=1M', 'oflag=direct', f'of={self._target}']
517 return ' '.join(cmd)
518
519 def _GetPartLocation(self, part_name: str):
520 """Extracts the location and size of the kernel partition from the image.
521
522 Args:
523 part_name: The name of the partition in the source image that needs to be
524 extracted.
525
526 Returns:
527 A tuple of offset and length (in bytes) from the image.
528 """
529 try:
530 parts = image_lib.GetImageDiskPartitionInfo(self._image)
531 part_info = [p for p in parts if p.name == part_name][0]
532 except IndexError:
533 raise Error(f'No partition named {part_name} found.')
534
535 return int(part_info.start), int(part_info.size)
536
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800537 def _GetRemotePartitionName(self):
538 """Returns the name of the quick-provision partition file.
539
540 Subclasses should override this function to return correct name.
541 """
542 raise NotImplementedError('Subclasses need to implement this.')
543
Amin Hassanid4b3ff82021-02-20 23:05:14 -0800544 def _OptimizePartLocation(self, offset: int, length: int):
545 """Optimizes the offset and length of the partition.
546
547 Subclasses can override this to provide better offset/length than what is
548 defined in the PGT partition layout.
549
550 Args:
551 offset: The offset (in bytes) of the partition in the image.
552 length: The length (in bytes) of the partition.
553
554 Returns:
555 A tuple of offset and length (in bytes) from the image.
556 """
557 return offset, length
Amin Hassanid684e982021-02-26 11:10:58 -0800558
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800559 def _RedirectPartition(self, file_name: str):
560 """Downloads the partition from a remote path and writes it into target.
561
562 Args:
563 file_name: The file name in the remote directory self._image.
564 """
565 cmd = self._GetWriteToTargetCommand()
566
567 image_path = os.path.join(self._image, file_name)
568 with GsFileCopier(image_path) as generator:
569 try:
570 with open(generator.Target(), 'rb') as fp:
571 self._device.run(cmd, input=fp, shell=True)
572 finally:
573 generator.CloseTarget()
574
Amin Hassanid684e982021-02-26 11:10:58 -0800575
576class KernelUpdater(RawPartitionUpdater):
577 """A class to update the kernel partition on a Chromium OS device."""
578
579 def _GetPartitionName(self):
580 """See RawPartitionUpdater._GetPartitionName()."""
581 return constants.PART_KERN_B
582
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800583 def _GetRemotePartitionName(self):
584 """See RawPartitionUpdater._GetRemotePartitionName()."""
585 return constants.QUICK_PROVISION_PAYLOAD_KERNEL
586
Amin Hassanid684e982021-02-26 11:10:58 -0800587 def Revert(self):
588 """Reverts the kernel partition update."""
589 # There is nothing to do for reverting kernel partition.
Amin Hassani75c5f942021-02-20 23:56:53 -0800590
591
592class RootfsUpdater(RawPartitionUpdater):
593 """A class to update the root partition on a Chromium OS device."""
594
595 def __init__(self, current_root: str, *args):
596 """Initializes the class.
597
598 Args:
599 current_root: The current root device path.
600 *args: See PartitionUpdaterBase
601 """
602 super().__init__(*args)
603
604 self._current_root = current_root
605 self._ran_postinst = False
606
607 def _GetPartitionName(self):
608 """See RawPartitionUpdater._GetPartitionName()."""
609 return constants.PART_ROOT_A
610
Amin Hassani0fe49ae2021-02-21 23:41:58 -0800611 def _GetRemotePartitionName(self):
612 """See RawPartitionUpdater._GetRemotePartitionName()."""
613 return constants.QUICK_PROVISION_PAYLOAD_ROOTFS
614
Amin Hassani75c5f942021-02-20 23:56:53 -0800615 def _Run(self):
616 """The function that does the job of rootfs partition update."""
617 super()._Run()
618
619 self._RunPostInst()
620
621 def _OptimizePartLocation(self, offset: int, length: int):
622 """Optimizes the size of the root partition of the image.
623
624 Normally the file system does not occupy the entire partition. Furthermore
625 we don't need the verity hash tree at the end of the root file system
626 because postinst will recreate it. This function reads the (approximate)
627 superblock of the ext4 partition and extracts the actual file system size in
628 the root partition.
629 """
630 superblock_size = 4096 * 2
631 with open(self._image, 'rb') as r:
632 r.seek(offset)
633 with tempfile.NamedTemporaryFile(delete=False) as fp:
634 fp.write(r.read(superblock_size))
635 fp.close()
636 return offset, partition_lib.Ext2FileSystemSize(fp.name)
637
638 def _RunPostInst(self, on_target: bool = True):
639 """Runs the postinst process in the root partition.
640
641 Args:
642 on_target: If true the postinst is run on the target (inactive)
643 partition. This is used when doing normal updates. If false, the
644 postinst is run on the current (active) partition. This is used when
645 reverting an update.
646 """
647 try:
648 postinst_dir = '/'
649 partition = self._current_root
650 if on_target:
651 postinst_dir = self._device.run(
652 ['mktemp', '-d', '-p', self._device.work_dir],
653 capture_output=True).stdout.strip()
654 self._device.run(['mount', '-o', 'ro', self._target, postinst_dir])
655 partition = self._target
656
657 self._ran_postinst = True
658 postinst = os.path.join(postinst_dir, 'postinst')
659 result = self._device.run([postinst, partition], capture_output=True)
660
661 logging.debug('Postinst result on %s: \n%s', postinst, result.stdout)
662 logging.info('Postinstall completed.')
663 finally:
664 if on_target:
665 self._device.run(['umount', postinst_dir])
666
667 def Revert(self):
668 """Reverts the root update install."""
669 logging.info('Reverting the rootfs partition update.')
670 if self._ran_postinst:
671 # We don't have to do anything for revert if we haven't changed the kernel
672 # priorities yet.
673 self._RunPostInst(on_target=False)