blob: b3cce747e90b84991b5c34dbf157c9bb9e3b146c [file] [log] [blame]
// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package btpeer
import (
"context"
"fmt"
"strings"
"time"
"go.chromium.org/luci/common/errors"
"infra/cros/recovery/internal/components/btpeer"
"infra/cros/recovery/internal/components/cache"
"infra/cros/recovery/internal/execs"
"infra/cros/recovery/internal/execs/wifirouter/ssh"
"infra/cros/recovery/internal/log"
)
const (
// rootASizeGB is the size of the new A partition rootFS after shrinking (in GB).
rootASizeGB = 16
// bPartitionsSizeGB is the combined space for the B root+boot partitions (in GB), this
// is smaller than A to make sure that we can fit it all on a 32GB SD card.
bPartitionsSizeGB = 12
// localOSImageStorePath is the path to the local image file after downloading it to the device.
localOSImageStorePath = "/tmp/rpi.img"
// Filesystem labels.
bootALabel = "BOOT_A"
rootALabel = "ROOT_A"
bootBLabel = "BOOT_B"
rootBLabel = "ROOT_B"
// The GCS bucket and folder which all btpeer images are stored under.
imageBaseGCSPath = "gs://chromeos-connectivity-test-artifacts/btpeer/raspios-cros-btpeer/"
)
// enableInitrdExec enables initrd on the btpeer.
func enableInitrdExec(ctx context.Context, info *execs.ExecInfo) error {
runner := btpeer.NewSshRunner(info.GetAccess(), info.GetActiveResource())
// Enable building initrd images in the kernel.
if err := btpeer.AddLineToFile(ctx, runner.Run, "/etc/default/raspberrypi-kernel", "INITRD=Yes"); err != nil {
return errors.Annotate(err, "enable initrd: failed to enable initrd building on Raspberry Pi").Err()
}
if err := btpeer.BuildInitrd(ctx, runner.Run); err != nil {
return errors.Annotate(err, "enable initrd: failed to build initial initrd image").Err()
}
bootPath, err := btpeer.GetCurrentBootPath(ctx, runner.Run)
if err != nil {
return errors.Annotate(err, "enable initrd: failed to get current boot path").Err()
}
// Tell the kernel to use initrd image that we just built and renamed.
if err := btpeer.AddLineToFile(ctx, runner.Run, bootPath+"config.txt", "initramfs initrd.img followkernel"); err != nil {
return errors.Annotate(err, "enable initrd: failed to build initial initrd image").Err()
}
argsMap := info.GetActionArgs(ctx)
rebootTime := argsMap.AsDuration(ctx, "wait_reboot", 300, time.Second)
if err := ssh.Reboot(ctx, runner, 10*time.Second, 10*time.Second, rebootTime); err != nil {
return errors.Annotate(err, "enable initrd: failed to reboot btpeer").Err()
}
// Check that we find initrd messages in the dmesg after rebooting.
if _, err := runner.Run(ctx, 30*time.Second, "dmesg -T | grep \"initrd\""); err != nil {
return errors.Annotate(err, "enable initrd: failed to verify initrd is enabled on device after reboot").Err()
}
return nil
}
// disableInitrdExec disables initrd/initramfs on the btpeer.
func disableInitrdExec(ctx context.Context, info *execs.ExecInfo) error {
runner := btpeer.NewSshRunner(info.GetAccess(), info.GetActiveResource())
bootPath, err := btpeer.GetCurrentBootPath(ctx, runner.Run)
if err != nil {
return errors.Annotate(err, "disable initrd: failed to get current boot path").Err()
}
// Remove initramfs line from boot config.
if err := btpeer.RemoveLineFromFile(ctx, runner.Run, bootPath+"config.txt", "initramfs initrd.img followkernel"); err != nil {
return errors.Annotate(err, "disable initrd: failed to build initial initrd image").Err()
}
argsMap := info.GetActionArgs(ctx)
rebootTime := argsMap.AsDuration(ctx, "wait_reboot", 300, time.Second)
if err := ssh.Reboot(ctx, runner, 10*time.Second, 10*time.Second, rebootTime); err != nil {
return errors.Annotate(err, "disable initr: failed to reboot btpeer").Err()
}
// Check that the initrd message is not in dmesg.
if _, err := runner.Run(ctx, 30*time.Second, "dmesg -T | grep \"initrd\""); err == nil {
return errors.Annotate(err, "disable initrd: failed to verify initrd is disabled on device after reboot").Err()
}
return nil
}
// shrinkRootFSExec shrinks the root partition using a pre-mount hook that is executed
// prior to mounting the root partition.
func shrinkRootFSExec(ctx context.Context, info *execs.ExecInfo) error {
runner := btpeer.NewSshRunner(info.GetAccess(), info.GetActiveResource())
argsMap := info.GetActionArgs(ctx)
rootSize := int64(argsMap.AsInt(ctx, "root_a_size", rootASizeGB) * 1e9)
clean, err := btpeer.CreateShrinkInitrdHook(ctx, runner.Run, rootSize)
if err != nil {
return errors.Annotate(err, "shrink rootfs: failed to create shrink hook").Err()
}
// Reboot device to run hook.
if err := ssh.Reboot(ctx, runner, 10*time.Second, 10*time.Second, 10*time.Minute); err != nil {
return errors.Annotate(err, "shrink rootfs: failed to reboot device").Err()
}
if err := clean(ctx); err != nil {
return errors.Annotate(err, "shrink rootfs: failed to clean up shrink hook").Err()
}
return nil
}
// hasRoomToPartition verifies that the device has enough unused space to create the A/B partitioning.
func hasRoomToPartitionExec(ctx context.Context, info *execs.ExecInfo) error {
runner := btpeer.NewSshRunner(info.GetAccess(), info.GetActiveResource())
argsMap := info.GetActionArgs(ctx)
rootSize := int64(argsMap.AsInt(ctx, "root_a_size", rootASizeGB) * 1e9)
usedFSSpace, err := btpeer.GetUsedFSSpace(ctx, runner.Run, "/")
if err != nil {
return errors.Reason("has room to partition: failed to get current used bytes").Err()
} else if usedFSSpace > rootSize {
return errors.Reason("has room to partition: current file system %dB is too large to resize, need %dB", usedFSSpace, rootSize).Err()
}
log.Infof(ctx, "ROOT_A used space: %dB", usedFSSpace)
helper, err := btpeer.NewPartitionHelperForPath(ctx, runner.Run, "/")
if err != nil {
return errors.Annotate(err, "has room to partition: failed to create partition helper").Err()
}
freeSpace, err := helper.GetFreeSpace(ctx)
if err != nil {
return errors.Annotate(err, "has room to partition: failed to create partition helper").Err()
}
log.Infof(ctx, "ROOT_A partition free space: %dB", freeSpace)
partitionSize, err := helper.GetPartitionSize(ctx, "/")
if err != nil {
return errors.Annotate(err, "has room to partition: failed to get current partition size").Err()
}
log.Infof(ctx, "ROOT_A partition size: %dB", partitionSize)
// Calculate final space available for the new partition.
newPartitionSize := int64(argsMap.AsInt(ctx, "b_partition_size", bPartitionsSizeGB) * 1e9)
partitionSpace := partitionSize + freeSpace - rootSize
if partitionSpace < newPartitionSize {
return errors.Reason("has room to partition: not enough room for new partitions, have %dB space available, want %dB", partitionSpace, newPartitionSize).Err()
}
return nil
}
// hasStandardPartitioningExec checks that the device has the normal OS partition scheme consisting of
// 1 boot (fat32) and 1 rootfs (ext4) partition.
func hasStandardPartitioningExec(ctx context.Context, info *execs.ExecInfo) error {
runner := btpeer.NewSshRunner(info.GetAccess(), info.GetActiveResource())
helper, err := btpeer.NewPartitionHelperForPath(ctx, runner.Run, "/")
if err != nil {
return errors.Annotate(err, "has standard partitioning: failed to create partition helper").Err()
}
parts, err := helper.GetPartitionInfo(ctx, "%", true)
if err != nil {
return errors.Annotate(err, "has standard partitioning: failed to get partition info").Err()
}
if len(parts) != 2 {
return errors.Reason("has standard partitioning: expected 2 partitions, got %d", len(parts)).Err()
}
if !strings.EqualFold(parts[0].Type, "fat32") {
return errors.Reason("has standard partitioning: expected first partition type fat32, got %q", parts[0].Type).Err()
}
if !strings.EqualFold(parts[1].Type, "ext4") {
return errors.Reason("has standard partitioning: expected second partition type ext4, got %q", parts[1].Type).Err()
}
return nil
}
// createPartitionsExec creates two new "B" partitions in the free space on the
// btpeer so the device can be AB updated.
//
// The final partioning looks like:
//
// BOOT_A(FAT32) - existing partition
// ROOT_A(EXT4) - existing partition
// BOOT_B(FAT32) - newly created
// ROOT_B(EXT4) - newly created
//
// The A partitions correspond to the "recovery" partitions while the B partitions
// are the ones that the B new OS versions will actually be flashed onto.
func createPartitionsExec(ctx context.Context, info *execs.ExecInfo) error {
runner := btpeer.NewSshRunner(info.GetAccess(), info.GetActiveResource())
helper, err := btpeer.NewPartitionHelperForPath(ctx, runner.Run, "/")
if err != nil {
return errors.Annotate(err, "create partitions: failed to create partition helper").Err()
}
bootPath, err := btpeer.GetCurrentBootPath(ctx, runner.Run)
if err != nil {
return errors.Annotate(err, "createn partitions: failed to get current boot path").Err()
}
// Add label to existing BOOT_A partition.
if err := btpeer.SetLabelForFAT32Path(ctx, runner.Run, bootPath, bootALabel); err != nil {
return errors.Annotate(err, "create partitions: failed to set BOOT_A label").Err()
}
// Add label to existing ROOT_A partition.
if err := btpeer.SetLabelForEXTPath(ctx, runner.Run, "/", rootALabel); err != nil {
return errors.Annotate(err, "create partitions: failed to set ROOT_A label").Err()
}
// Create the B boot partition with a fixed size of 1GB.
if bootB, err := helper.CreateNewPrimaryPartitionBytes(ctx, "fat32", int64(1e9)); err != nil {
return errors.Annotate(err, "create partitions: failed to create boot partition").Err()
// Initialize an empty filesystem on the newly created boot partition and add the BOOT_B label.
} else if err := btpeer.InitFAT32FS(ctx, runner.Run, bootB, bootBLabel); err != nil {
return errors.Annotate(err, "create partitions: failed initialize ROOT_B fs").Err()
}
// Create the B root partition using all of the remaining free space.
if rootB, err := helper.CreateNewPrimaryPartitionPercent(ctx, "ext4", 100); err != nil {
return errors.Annotate(err, "create partitions: failed to create root partition").Err()
// Initialize an empty filesystem on the newly created root partition and add the ROOT_B label.
} else if err := btpeer.InitEXT4FS(ctx, runner.Run, rootB, rootBLabel); err != nil {
return errors.Annotate(err, "create partitions: failed initialize ROOT_B fs").Err()
}
return nil
}
// downloadImageExec downloads an OS Image to the btpeer from GCS through the
// cache server and decompresses it. Once complete, the decompressed image will
// be located at localOSImageStorePath.
//
// Supports xz (*.img.xz) and gz (*.img.gz) compressed images based on file
// extension.
//
// Image file must be stored in GCS under imageBaseGCSPath.
func downloadImageExec(ctx context.Context, info *execs.ExecInfo) error {
argsMap := info.GetActionArgs(ctx)
runner := info.DefaultRunner()
externalDownloadURL := argsMap.AsString(ctx, "image_path", "")
downloadTimeout := argsMap.AsDuration(ctx, "download_timeout", 600, time.Second)
decompressTimeout := argsMap.AsDuration(ctx, "decompress_timeout", 300, time.Second)
// Validate/parse image URL.
if externalDownloadURL == "" {
return errors.Reason("download image: required image_path argument missing").Err()
}
if !strings.HasPrefix(externalDownloadURL, imageBaseGCSPath) {
return errors.Reason("download image: image_path expected to be located in GCS under %q, got %q", imageBaseGCSPath, externalDownloadURL).Err()
}
var xzCompression bool
downloadDst := localOSImageStorePath
if strings.HasSuffix(externalDownloadURL, ".img.xz") {
xzCompression = true
downloadDst += ".xz"
} else if strings.HasSuffix(externalDownloadURL, ".img.gz") {
xzCompression = false
downloadDst += ".gz"
} else {
return errors.Reason("download image: image %q not identified as having xz or gz compression", externalDownloadURL).Err()
}
// Download compressed image through cache server.
cacheDownloadURL, err := info.GetAccess().GetCacheUrl(ctx, info.GetDut().Name, externalDownloadURL)
if err != nil {
return errors.Annotate(err, "failed to get download URL from cache server for file path %q", externalDownloadURL).Err()
}
if _, err := cache.CurlFile(ctx, runner, cacheDownloadURL, downloadDst, downloadTimeout); err != nil {
return errors.Annotate(err, "failed to download image %q to btpeer at %q", externalDownloadURL, downloadDst).Err()
}
// Decompress image in-place (removes compression file extension).
if xzCompression {
if _, err := runner(ctx, decompressTimeout, "unxz", downloadDst); err != nil {
return errors.Annotate(err, "download image: failed to extract xz image").Err()
}
} else {
// Is gz compression.
if _, err := runner(ctx, decompressTimeout, "gzip", "-d", downloadDst); err != nil {
return errors.Annotate(err, "download image: failed to extract gz image").Err()
}
}
return nil
}
// hasPartitionsWithLabelsExec verifies that partitions can be found with the requested labels.
func hasPartitionsWithLabelsExec(ctx context.Context, info *execs.ExecInfo) error {
argsMap := info.GetActionArgs(ctx)
runner := btpeer.NewSshRunner(info.GetAccess(), info.GetActiveResource())
labels := argsMap.AsStringSlice(ctx, "labels", []string{})
if len(labels) == 0 {
return errors.Reason("partitions with labels exist: required argument: 'labels' not provided").Err()
}
for _, label := range labels {
device, err := btpeer.GetFSWithLabel(ctx, runner.Run, label)
if err != nil {
return errors.Annotate(err, "partitions with labels exist: failed to find partition with label: %q", label).Err()
}
log.Infof(ctx, "Found device: %q with label: %q", label, device)
}
return nil
}
// provisionExec flashes a raspberry pi image to the ROOT_B/BOOT_B partitions.
func provisionExec(ctx context.Context, info *execs.ExecInfo) error {
runner := btpeer.NewSshRunner(info.GetAccess(), info.GetActiveResource())
argsMap := info.GetActionArgs(ctx)
flashBootTimeout := argsMap.AsDuration(ctx, "flash_boot_timeout", 300, time.Second)
flashRootTimeout := argsMap.AsDuration(ctx, "flash_root_timeout", 1800, time.Second)
// First find the partitions we are going to flash.
rootB, err := btpeer.GetFSWithLabel(ctx, runner.Run, rootBLabel)
if err != nil {
return errors.Annotate(err, "provision: failed to find ROOT_B partition").Err()
}
bootB, err := btpeer.GetFSWithLabel(ctx, runner.Run, bootBLabel)
if err != nil {
return errors.Annotate(err, "provision: failed to find BOOT_B partition").Err()
}
// Load the image loopback device so we can interact with it as if it's just a normal device.
device, err := btpeer.LoadImageAsLoopbackDevice(ctx, runner.Run, localOSImageStorePath)
if err != nil {
return errors.Annotate(err, "provision: failed to get and associate image").Err()
}
imgBootDev := fmt.Sprintf("%sp1", device)
imgRootDev := fmt.Sprintf("%sp2", device)
// Change partition labels on image before flashing.
// Do this here so if something goes wrong during the flashing the partition
// The labels will still exist so we can find the correct partition next time.
if err := btpeer.SetEXTLabel(ctx, runner.Run, imgRootDev, rootBLabel); err != nil {
return errors.Annotate(err, "provision: failed to label ROOT_B").Err()
}
if err := btpeer.SetFAT32Label(ctx, runner.Run, imgBootDev, bootBLabel); err != nil {
return errors.Annotate(err, "provision: failed to label BOOT_B").Err()
}
// Flash boot partition.
if err := btpeer.FlashImage(ctx, runner.Run, flashBootTimeout, imgBootDev, bootB); err != nil {
return errors.Annotate(err, "provision: failed to flash BOOT_B parition").Err()
}
// Flash root partition.
if err := btpeer.FlashImage(ctx, runner.Run, flashRootTimeout, imgRootDev, rootB); err != nil {
return errors.Annotate(err, "provision: failed to flash ROOT_B parition").Err()
}
// Finally, update the partition IDs to match the new device.
// Without this, the raspberry PI will search for partitions that match the UUID in the image since it's expecting
// that we dd the entire .img to the device rather than individual partitions.
// This is similar to what is already done in the default raspberry pi OS during first boot:
// https://github.com/RPi-Distro/raspi-config/blob/bookworm/usr/lib/raspi-config/init_resize.sh#L90-L91
// Mount the newly flashed partitions so we can update the files.
imgBootMount, err := btpeer.MountDevice(ctx, runner.Run, bootB)
if err != nil {
return errors.Annotate(err, "update partition id: failed to mount image boot partition").Err()
}
imgRootMount, err := btpeer.MountDevice(ctx, runner.Run, rootB)
if err != nil {
return errors.Annotate(err, "update partition id: failed to mount image boot partition").Err()
}
// Replace old boot and root partition IDs in fstab.
if err := btpeer.ReplacePartID(ctx, runner.Run, imgRootDev, rootB, imgRootMount+"/etc/fstab"); err != nil {
return errors.Annotate(err, "update partition id: failed to replace old image root PART ID").Err()
}
if err := btpeer.ReplacePartID(ctx, runner.Run, imgBootDev, bootB, imgRootMount+"/etc/fstab"); err != nil {
return errors.Annotate(err, "update partition id: failed to replace old image root PART ID").Err()
}
// Replace old root partition ID in cmdline.txt.
if err := btpeer.ReplacePartID(ctx, runner.Run, imgRootDev, rootB, imgBootMount+"/cmdline.txt"); err != nil {
return errors.Annotate(err, "update partition id: failed to replace old image root PART ID").Err()
}
return nil
}
// setTempBootPartitionExec temporarily boots into the specified partition.
//
// This is done prior to changing the permanent boot partition to verify that the
// partition is valid and bootable before making any permanent changes.
func setTempBootPartitionExec(ctx context.Context, info *execs.ExecInfo) error {
argsMap := info.GetActionArgs(ctx)
partition := argsMap.AsString(ctx, "boot_partition_label", "")
if partition == "" {
return errors.Reason("set temp boot partition: required arg boot_partition_label not provided.").Err()
}
rebootTime := argsMap.AsDuration(ctx, "wait_reboot", 300, time.Second)
runner := btpeer.NewSshRunner(info.GetAccess(), info.GetActiveResource())
if err := btpeer.TempBootIntoPartition(ctx, runner.Run, partition, rebootTime); err != nil {
return errors.Annotate(err, "set temp boot partition: failed to boot into requested partition").Err()
}
return nil
}
// setPermanentBootPartitionExec permanently boots into the requested partition.
//
// See: https://github.com/raspberrypi/documentation/blob/develop/documentation/asciidoc/computers/config_txt/autoboot.adoc
func setPermanentBootPartitionExec(ctx context.Context, info *execs.ExecInfo) error {
argsMap := info.GetActionArgs(ctx)
partition := argsMap.AsString(ctx, "boot_partition_label", "")
if partition == "" {
return errors.Reason("set temp boot partition: required arg boot_partition_label not provided.").Err()
}
rebootTime := argsMap.AsDuration(ctx, "wait_reboot", 300, time.Second)
runner := btpeer.NewSshRunner(info.GetAccess(), info.GetActiveResource())
// Raspberry PI bootloader will search the first FAT32 partition for
// an autoboot.txt file which points to which partition contains the boot partition.
// This is sometimes a separate RECOVERY partition, but for us it is just BOOT_A partition
if err := btpeer.PermBootIntoPartition(ctx, runner.Run, bootALabel, partition, rebootTime); err != nil {
return errors.Reason("set perm boot partition: failed to set perm boot partition.").Err()
}
return nil
}
func init() {
execs.Register("btpeer_enable_initrd", enableInitrdExec)
execs.Register("btpeer_disable_initrd", disableInitrdExec)
execs.Register("btpeer_has_partition_room", hasRoomToPartitionExec)
execs.Register("btpeer_shrink_rootfs", shrinkRootFSExec)
execs.Register("btpeer_partition_device", createPartitionsExec)
execs.Register("btpeer_has_partitions_with_labels", hasPartitionsWithLabelsExec)
execs.Register("btpeer_device_has_standard_partitions", hasStandardPartitioningExec)
execs.Register("btpeer_download_image", downloadImageExec)
execs.Register("btpeer_set_permanent_boot_partition", setPermanentBootPartitionExec)
execs.Register("btpeer_temp_boot_into_partition", setTempBootPartitionExec)
execs.Register("btpeer_provision_device", provisionExec)
}