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