blob: ea939fd9e6880d6c2a8b69a5395f8ce8f488f86f [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 """
211 updaters = []
212
213 # Retry the partitions updates that failed, in case a transient error (like
214 # SSH drop, etc) caused the error.
215 num_retries = 1
216 try:
217 retry_util.RetryException(Error, num_retries,
218 parallel.RunParallelSteps,
219 (x.Run for x in updaters if not x.IsFinished()),
220 halt_on_error=True)
221 except Exception:
222 # If one of the partitions failed to be installed, revert all partitions.
223 parallel.RunParallelSteps(x.Revert for x in updaters)
224 raise
225
226 def _Reboot(self):
227 """Reboots the device."""
228 try:
229 self._device.Reboot(timeout_sec=60)
230 except remote_access.RebootError:
231 raise Error('Could not recover from reboot. Once example reason'
232 ' could be the image provided was a non-test image'
233 ' or the system failed to boot after the update.')
234 except Exception as e:
235 raise Error(f'Failed to reboot to the device with error: {e}')
236
237 def _VerifyBootExpectations(self):
238 """Verify that we fully booted into the expected kernel state."""
239 # Discover the newly active kernel.
240 _, root_num = self._SplitDevPath(self._device.root_dev)
241 active_state, _ = self._GetKernelState(root_num)
242
243 # If this happens, we should rollback.
244 if active_state != self._inactive_state:
245 raise Error('The expected kernel state after update is invalid.')
246
247 logging.info('Verified boot expectations.')
Amin Hassanid4b3ff82021-02-20 23:05:14 -0800248
249
250class ReaderBase(threading.Thread):
251 """The base class for reading different inputs and writing into output.
252
253 This class extends threading.Thread, so it will be run on its own thread. Also
254 it can be used as a context manager. Internally, it opens necessary files for
255 writing to and reading from. This class cannot be instantiated, it needs to be
256 sub-classed first to provide necessary function implementations.
257 """
258
259 def __init__(self, use_named_pipes: bool = False):
260 """Initializes the class.
261
262 Args:
263 use_named_pipes: Whether to use a named pipe or anonymous file
264 descriptors.
265 """
266 super().__init__()
267 self._use_named_pipes = use_named_pipes
268 self._pipe_target = None
269 self._pipe_source = None
270
271 def __del__(self):
272 """Destructor.
273
274 Make sure to clean up any named pipes we might have created.
275 """
276 if self._use_named_pipes:
277 osutils.SafeUnlink(self._pipe_target)
278
279 def __enter__(self):
280 """Enters the context manager"""
281 if self._use_named_pipes:
282 # There is no need for the temp file, we only need its path. So the named
283 # pipe is created after this temp file is deleted.
284 with tempfile.NamedTemporaryFile(prefix='chromite-device-imager') as fp:
285 self._pipe_target = self._pipe_source = fp.name
286 os.mkfifo(self._pipe_target)
287 else:
288 self._pipe_target, self._pipe_source = os.pipe()
289
290 self.start()
291 return self
292
293 def __exit__(self, *args, **kwargs):
294 """Exits the context manager."""
295 self.join()
296
297 def _Source(self):
298 """Returns the source pipe to write data into.
299
300 Sub-classes can use this function to determine where to write their data
301 into.
302 """
303 return self._pipe_source
304
305 def _CloseSource(self):
306 """Closes the source pipe.
307
308 Sub-classes should use this function to close the pipe after they are done
309 writing into it. Failure to do so may result reader of the data to hang
310 indefinitely.
311 """
312 if not self._use_named_pipes:
313 os.close(self._pipe_source)
314
315 def Target(self):
316 """Returns the target pipe to read data from.
317
318 Users of this class can use this path to read data from.
319 """
320 return self._pipe_target
321
322 def CloseTarget(self):
323 """Closes the target pipe.
324
325 Users of this class should use this function to close the pipe after they
326 are done reading from it.
327 """
328 if self._use_named_pipes:
329 os.remove(self._pipe_target)
330 else:
331 os.close(self._pipe_target)
332
333
334class PartialFileReader(ReaderBase):
335 """A class to read specific offset and length from a file and compress it.
336
337 This class can be used to read from specific location and length in a file
338 (e.g. A partition in a GPT image). Then it compresses the input and writes it
339 out (to a pipe). Look at the base class for more information.
340 """
341
342 # The offset of different partitions in a Chromium OS image does not always
343 # align to larger values like 4096. It seems that 512 is the maximum value to
344 # be divisible by partition offsets. This size should not be increased just
345 # for 'performance reasons'. Since we are doing everything in parallel, in
346 # practice there is not much difference between this and larger block sizes as
347 # parallelism hides the possible extra latency provided by smaller block
348 # sizes.
349 _BLOCK_SIZE = 512
350
351 def __init__(self, image: str, offset: int, length: int, compression):
352 """Initializes the class.
353
354 Args:
355 image: The path to an image (local or remote directory).
356 offset: The offset (in bytes) to read from the image.
357 length: The length (in bytes) to read from the image.
358 compression: The compression type (see cros_build_lib.COMP_XXX).
359 """
360 super().__init__()
361
362 self._image = image
363 self._offset = offset
364 self._length = length
365 self._compression = compression
366
367 def run(self):
368 """Runs the reading and compression."""
369 cmd = [
370 'dd',
371 'status=none',
372 f'if={self._image}',
373 f'ibs={self._BLOCK_SIZE}',
374 f'skip={int(self._offset/self._BLOCK_SIZE)}',
375 f'count={int(self._length/self._BLOCK_SIZE)}',
376 '|',
377 cros_build_lib.FindCompressor(self._compression),
378 ]
379
380 try:
381 cros_build_lib.run(' '.join(cmd), stdout=self._Source(), shell=True)
382 finally:
383 self._CloseSource()
384
385
386class PartitionUpdaterBase(object):
387 """A base abstract class to use for installing an image into a partition.
388
389 Sub-classes should implement the abstract methods to provide the core
390 functionality.
391 """
392 def __init__(self, device, image: str, image_type, target: str, compression):
393 """Initializes this base class with values that most sub-classes will need.
394
395 Args:
396 device: The ChromiumOSDevice to be updated.
397 image: The target image path for the partition update.
398 image_type: The type of the image (ImageType).
399 target: The target path (e.g. block dev) to install the update.
400 compression: The compression used for compressing the update payload.
401 """
402 self._device = device
403 self._image = image
404 self._image_type = image_type
405 self._target = target
406 self._compression = compression
407 self._finished = False
408
409 def Run(self):
410 """The main function that does the partition update job."""
411 with cros_build_lib.TimedSection() as timer:
412 try:
413 self._Run()
414 finally:
415 self._finished = True
416
417 logging.debug('Completed %s in %s', self.__class__.__name__, timer.delta)
418
419 @abc.abstractmethod
420 def _Run(self):
421 """The method that need to be implemented by sub-classes."""
422 raise NotImplementedError('Sub-classes need to implement this.')
423
424 def IsFinished(self):
425 """Returns whether the partition update has been successful."""
426 return self._finished
427
428 @abc.abstractmethod
429 def Revert(self):
430 """Reverts the partition update.
431
432 Sub-classes need to implement this function to provide revert capability.
433 """
434 raise NotImplementedError('Sub-classes need to implement this.')
435
436
437class RawPartitionUpdater(PartitionUpdaterBase):
438 """A class to update a raw partition on a Chromium OS device."""
439
440 def _Run(self):
441 """The function that does the job of kernel partition update."""
442 if self._image_type == ImageType.FULL:
443 self._CopyPartitionFromImage(self._GetPartitionName())
444 elif self._image_type == ImageType.REMOTE_DIRECTORY:
445 raise NotImplementedError('Not yet implemented.')
446 else:
447 raise ValueError(f'Invalid image type {self._image_type}')
448
449 def _GetPartitionName(self):
450 """Returns the name of the partition in a Chromium OS GPT layout.
451
452 Subclasses should override this function to return correct name.
453 """
454 raise NotImplementedError('Subclasses need to implement this.')
455
456 def _CopyPartitionFromImage(self, part_name: str):
457 """Updates the device's partition from a local Chromium OS image.
458
459 Args:
460 part_name: The name of the partition in the source image that needs to be
461 extracted.
462 """
463 cmd = self._GetWriteToTargetCommand()
464
465 offset, length = self._GetPartLocation(part_name)
466 offset, length = self._OptimizePartLocation(offset, length)
467 with PartialFileReader(self._image, offset, length,
468 self._compression) as generator:
469 try:
470 self._device.run(cmd, input=generator.Target(), shell=True)
471 finally:
472 generator.CloseTarget()
473
474 def _GetWriteToTargetCommand(self):
475 """Returns a write to target command to run on a Chromium OS device.
476
477 Returns:
478 A string command to run on a device to read data from stdin, uncompress it
479 and write it to the target partition.
480 """
481 cmd = self._device.GetDecompressor(self._compression)
482 # Using oflag=direct to tell the OS not to cache the writes (faster).
483 cmd += ['|', 'dd', 'bs=1M', 'oflag=direct', f'of={self._target}']
484 return ' '.join(cmd)
485
486 def _GetPartLocation(self, part_name: str):
487 """Extracts the location and size of the kernel partition from the image.
488
489 Args:
490 part_name: The name of the partition in the source image that needs to be
491 extracted.
492
493 Returns:
494 A tuple of offset and length (in bytes) from the image.
495 """
496 try:
497 parts = image_lib.GetImageDiskPartitionInfo(self._image)
498 part_info = [p for p in parts if p.name == part_name][0]
499 except IndexError:
500 raise Error(f'No partition named {part_name} found.')
501
502 return int(part_info.start), int(part_info.size)
503
504 def _OptimizePartLocation(self, offset: int, length: int):
505 """Optimizes the offset and length of the partition.
506
507 Subclasses can override this to provide better offset/length than what is
508 defined in the PGT partition layout.
509
510 Args:
511 offset: The offset (in bytes) of the partition in the image.
512 length: The length (in bytes) of the partition.
513
514 Returns:
515 A tuple of offset and length (in bytes) from the image.
516 """
517 return offset, length