blob: 945d95db356fbab9a8b64ec901737530205eb5e6 [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
399class PartitionUpdaterBase(object):
400 """A base abstract class to use for installing an image into a partition.
401
402 Sub-classes should implement the abstract methods to provide the core
403 functionality.
404 """
405 def __init__(self, device, image: str, image_type, target: str, compression):
406 """Initializes this base class with values that most sub-classes will need.
407
408 Args:
409 device: The ChromiumOSDevice to be updated.
410 image: The target image path for the partition update.
411 image_type: The type of the image (ImageType).
412 target: The target path (e.g. block dev) to install the update.
413 compression: The compression used for compressing the update payload.
414 """
415 self._device = device
416 self._image = image
417 self._image_type = image_type
418 self._target = target
419 self._compression = compression
420 self._finished = False
421
422 def Run(self):
423 """The main function that does the partition update job."""
424 with cros_build_lib.TimedSection() as timer:
425 try:
426 self._Run()
427 finally:
428 self._finished = True
429
430 logging.debug('Completed %s in %s', self.__class__.__name__, timer.delta)
431
432 @abc.abstractmethod
433 def _Run(self):
434 """The method that need to be implemented by sub-classes."""
435 raise NotImplementedError('Sub-classes need to implement this.')
436
437 def IsFinished(self):
438 """Returns whether the partition update has been successful."""
439 return self._finished
440
441 @abc.abstractmethod
442 def Revert(self):
443 """Reverts the partition update.
444
445 Sub-classes need to implement this function to provide revert capability.
446 """
447 raise NotImplementedError('Sub-classes need to implement this.')
448
449
450class RawPartitionUpdater(PartitionUpdaterBase):
451 """A class to update a raw partition on a Chromium OS device."""
452
453 def _Run(self):
454 """The function that does the job of kernel partition update."""
455 if self._image_type == ImageType.FULL:
456 self._CopyPartitionFromImage(self._GetPartitionName())
457 elif self._image_type == ImageType.REMOTE_DIRECTORY:
458 raise NotImplementedError('Not yet implemented.')
459 else:
460 raise ValueError(f'Invalid image type {self._image_type}')
461
462 def _GetPartitionName(self):
463 """Returns the name of the partition in a Chromium OS GPT layout.
464
465 Subclasses should override this function to return correct name.
466 """
467 raise NotImplementedError('Subclasses need to implement this.')
468
469 def _CopyPartitionFromImage(self, part_name: str):
470 """Updates the device's partition from a local Chromium OS image.
471
472 Args:
473 part_name: The name of the partition in the source image that needs to be
474 extracted.
475 """
476 cmd = self._GetWriteToTargetCommand()
477
478 offset, length = self._GetPartLocation(part_name)
479 offset, length = self._OptimizePartLocation(offset, length)
480 with PartialFileReader(self._image, offset, length,
481 self._compression) as generator:
482 try:
483 self._device.run(cmd, input=generator.Target(), shell=True)
484 finally:
485 generator.CloseTarget()
486
487 def _GetWriteToTargetCommand(self):
488 """Returns a write to target command to run on a Chromium OS device.
489
490 Returns:
491 A string command to run on a device to read data from stdin, uncompress it
492 and write it to the target partition.
493 """
494 cmd = self._device.GetDecompressor(self._compression)
495 # Using oflag=direct to tell the OS not to cache the writes (faster).
496 cmd += ['|', 'dd', 'bs=1M', 'oflag=direct', f'of={self._target}']
497 return ' '.join(cmd)
498
499 def _GetPartLocation(self, part_name: str):
500 """Extracts the location and size of the kernel partition from the image.
501
502 Args:
503 part_name: The name of the partition in the source image that needs to be
504 extracted.
505
506 Returns:
507 A tuple of offset and length (in bytes) from the image.
508 """
509 try:
510 parts = image_lib.GetImageDiskPartitionInfo(self._image)
511 part_info = [p for p in parts if p.name == part_name][0]
512 except IndexError:
513 raise Error(f'No partition named {part_name} found.')
514
515 return int(part_info.start), int(part_info.size)
516
517 def _OptimizePartLocation(self, offset: int, length: int):
518 """Optimizes the offset and length of the partition.
519
520 Subclasses can override this to provide better offset/length than what is
521 defined in the PGT partition layout.
522
523 Args:
524 offset: The offset (in bytes) of the partition in the image.
525 length: The length (in bytes) of the partition.
526
527 Returns:
528 A tuple of offset and length (in bytes) from the image.
529 """
530 return offset, length
Amin Hassanid684e982021-02-26 11:10:58 -0800531
532
533class KernelUpdater(RawPartitionUpdater):
534 """A class to update the kernel partition on a Chromium OS device."""
535
536 def _GetPartitionName(self):
537 """See RawPartitionUpdater._GetPartitionName()."""
538 return constants.PART_KERN_B
539
540 def Revert(self):
541 """Reverts the kernel partition update."""
542 # There is nothing to do for reverting kernel partition.
Amin Hassani75c5f942021-02-20 23:56:53 -0800543
544
545class RootfsUpdater(RawPartitionUpdater):
546 """A class to update the root partition on a Chromium OS device."""
547
548 def __init__(self, current_root: str, *args):
549 """Initializes the class.
550
551 Args:
552 current_root: The current root device path.
553 *args: See PartitionUpdaterBase
554 """
555 super().__init__(*args)
556
557 self._current_root = current_root
558 self._ran_postinst = False
559
560 def _GetPartitionName(self):
561 """See RawPartitionUpdater._GetPartitionName()."""
562 return constants.PART_ROOT_A
563
564 def _Run(self):
565 """The function that does the job of rootfs partition update."""
566 super()._Run()
567
568 self._RunPostInst()
569
570 def _OptimizePartLocation(self, offset: int, length: int):
571 """Optimizes the size of the root partition of the image.
572
573 Normally the file system does not occupy the entire partition. Furthermore
574 we don't need the verity hash tree at the end of the root file system
575 because postinst will recreate it. This function reads the (approximate)
576 superblock of the ext4 partition and extracts the actual file system size in
577 the root partition.
578 """
579 superblock_size = 4096 * 2
580 with open(self._image, 'rb') as r:
581 r.seek(offset)
582 with tempfile.NamedTemporaryFile(delete=False) as fp:
583 fp.write(r.read(superblock_size))
584 fp.close()
585 return offset, partition_lib.Ext2FileSystemSize(fp.name)
586
587 def _RunPostInst(self, on_target: bool = True):
588 """Runs the postinst process in the root partition.
589
590 Args:
591 on_target: If true the postinst is run on the target (inactive)
592 partition. This is used when doing normal updates. If false, the
593 postinst is run on the current (active) partition. This is used when
594 reverting an update.
595 """
596 try:
597 postinst_dir = '/'
598 partition = self._current_root
599 if on_target:
600 postinst_dir = self._device.run(
601 ['mktemp', '-d', '-p', self._device.work_dir],
602 capture_output=True).stdout.strip()
603 self._device.run(['mount', '-o', 'ro', self._target, postinst_dir])
604 partition = self._target
605
606 self._ran_postinst = True
607 postinst = os.path.join(postinst_dir, 'postinst')
608 result = self._device.run([postinst, partition], capture_output=True)
609
610 logging.debug('Postinst result on %s: \n%s', postinst, result.stdout)
611 logging.info('Postinstall completed.')
612 finally:
613 if on_target:
614 self._device.run(['umount', postinst_dir])
615
616 def Revert(self):
617 """Reverts the root update install."""
618 logging.info('Reverting the rootfs partition update.')
619 if self._ran_postinst:
620 # We don't have to do anything for revert if we haven't changed the kernel
621 # priorities yet.
622 self._RunPostInst(on_target=False)