metropolis: implement and use A/B preloader
This switches over from using the EFI built-in bootloader for A/B
updates to using our own EFI preloader due to significant issues with
in-the-wild EFI implementations. It is a very minimal design relying
on a single Protobuf state file instead of EFI variables.
Change-Id: Ieebd0a8172ebe3f44c69b3e8c278c53d3fe2eeb4
Reviewed-on: https://review.monogon.dev/c/monogon/+/2203
Tested-by: Jenkins CI
Reviewed-by: Serge Bazanski <serge@monogon.tech>
diff --git a/metropolis/node/build/def.bzl b/metropolis/node/build/def.bzl
index 7baa16b..d71279c 100644
--- a/metropolis/node/build/def.bzl
+++ b/metropolis/node/build/def.bzl
@@ -13,6 +13,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
+load("@bazel_skylib//lib:paths.bzl", "paths")
def _build_pure_transition_impl(settings, attr):
"""
@@ -338,3 +339,75 @@
),
},
)
+
+# From Aspect's bazel-lib under Apache 2.0
+def _transition_platform_impl(_, attr):
+ return {"//command_line_option:platforms": str(attr.target_platform)}
+
+# Transition from any input configuration to one that includes the
+# --platforms command-line flag.
+_transition_platform = transition(
+ implementation = _transition_platform_impl,
+ inputs = [],
+ outputs = ["//command_line_option:platforms"],
+)
+
+
+def _platform_transition_binary_impl(ctx):
+ # We need to forward the DefaultInfo provider from the underlying rule.
+ # Unfortunately, we can't do this directly, because Bazel requires that the executable to run
+ # is actually generated by this rule, so we need to symlink to it, and generate a synthetic
+ # forwarding DefaultInfo.
+
+ result = []
+ binary = ctx.attr.binary[0]
+
+ default_info = binary[DefaultInfo]
+ files = default_info.files
+ new_executable = None
+ original_executable = default_info.files_to_run.executable
+ runfiles = default_info.default_runfiles
+
+ if not original_executable:
+ fail("Cannot transition a 'binary' that is not executable")
+
+ new_executable_name = ctx.attr.basename if ctx.attr.basename else original_executable.basename
+
+ # In order for the symlink to have the same basename as the original
+ # executable (important in the case of proto plugins), put it in a
+ # subdirectory named after the label to prevent collisions.
+ new_executable = ctx.actions.declare_file(paths.join(ctx.label.name, new_executable_name))
+ ctx.actions.symlink(
+ output = new_executable,
+ target_file = original_executable,
+ is_executable = True,
+ )
+ files = depset(direct = [new_executable], transitive = [files])
+ runfiles = runfiles.merge(ctx.runfiles([new_executable]))
+
+ result.append(
+ DefaultInfo(
+ files = files,
+ runfiles = runfiles,
+ executable = new_executable,
+ ),
+ )
+
+ return result
+
+platform_transition_binary = rule(
+ implementation = _platform_transition_binary_impl,
+ attrs = {
+ "basename": attr.string(),
+ "binary": attr.label(allow_files = True, cfg = _transition_platform),
+ "target_platform": attr.label(
+ doc = "The target platform to transition the binary.",
+ mandatory = True,
+ ),
+ "_allowlist_function_transition": attr.label(
+ default = "@bazel_tools//tools/allowlists/function_transition_allowlist",
+ ),
+ },
+ executable = True,
+ doc = "Transitions the binary to use the provided platform.",
+)
\ No newline at end of file
diff --git a/metropolis/node/build/mkimage/BUILD.bazel b/metropolis/node/build/mkimage/BUILD.bazel
index 2c7d699..ad88acb 100644
--- a/metropolis/node/build/mkimage/BUILD.bazel
+++ b/metropolis/node/build/mkimage/BUILD.bazel
@@ -3,6 +3,9 @@
go_library(
name = "mkimage_lib",
srcs = ["main.go"],
+ embedsrcs = [
+ "//metropolis/node/core/abloader", #keep
+ ],
importpath = "source.monogon.dev/metropolis/node/build/mkimage",
visibility = ["//visibility:private"],
deps = [
diff --git a/metropolis/node/build/mkimage/main.go b/metropolis/node/build/mkimage/main.go
index 077348e..7de951e 100644
--- a/metropolis/node/build/mkimage/main.go
+++ b/metropolis/node/build/mkimage/main.go
@@ -27,6 +27,8 @@
package main
import (
+ "bytes"
+ _ "embed"
"flag"
"log"
"os"
@@ -36,6 +38,9 @@
"source.monogon.dev/metropolis/pkg/blockdev"
)
+//go:embed metropolis/node/core/abloader/abloader_bin.efi
+var abloader []byte
+
func main() {
// Fill in the image parameters based on flags.
var (
@@ -92,6 +97,8 @@
panic(err)
}
+ cfg.ABLoader = bytes.NewReader(abloader)
+
// Write the parametrized OS image.
if _, err := osimage.Create(&cfg); err != nil {
log.Fatalf("while creating a Metropolis OS image: %v", err)
diff --git a/metropolis/node/build/mkimage/osimage/osimage.go b/metropolis/node/build/mkimage/osimage/osimage.go
index 01c13ac..a09f5d1 100644
--- a/metropolis/node/build/mkimage/osimage/osimage.go
+++ b/metropolis/node/build/mkimage/osimage/osimage.go
@@ -19,7 +19,6 @@
package osimage
import (
- "bytes"
"fmt"
"io"
"strings"
@@ -72,8 +71,11 @@
type Params struct {
// Output is the block device to which the OS image is written.
Output blockdev.BlockDev
+ // ABLoader provides the A/B loader which then loads the EFI loader for the
+ // correct slot.
+ ABLoader fat32.SizedReader
// EFIPayload provides contents of the EFI payload file. It must not be
- // nil.
+ // nil. This gets put into boot slot A.
EFIPayload fat32.SizedReader
// SystemImage provides contents of the Metropolis system partition.
// If nil, no contents will be copied into the partition.
@@ -116,16 +118,11 @@
rootInode := fat32.Inode{
Attrs: fat32.AttrDirectory,
}
- efiPayload, err := io.ReadAll(params.EFIPayload)
- if err != nil {
- return nil, fmt.Errorf("while reading EFIPayload: %w", err)
- }
- if err := rootInode.PlaceFile(strings.TrimPrefix(EFIBootAPath, "/"), bytes.NewReader(efiPayload)); err != nil {
+ if err := rootInode.PlaceFile(strings.TrimPrefix(EFIBootAPath, "/"), params.EFIPayload); err != nil {
return nil, err
}
- // Also place a copy of the boot file at the autodiscovery path. This will
- // always boot slot A.
- if err := rootInode.PlaceFile(strings.TrimPrefix(EFIPayloadPath, "/"), bytes.NewReader(efiPayload)); err != nil {
+ // Place the A/B loader at the EFI bootloader autodiscovery path.
+ if err := rootInode.PlaceFile(strings.TrimPrefix(EFIPayloadPath, "/"), params.ABLoader); err != nil {
return nil, err
}
if params.NodeParameters != nil {
@@ -181,7 +178,7 @@
// Build an EFI boot entry pointing to the image's ESP.
return &efivarfs.LoadOption{
- Description: "Metropolis Slot A",
+ Description: "Metropolis",
FilePath: efivarfs.DevicePath{
&efivarfs.HardDrivePath{
PartitionNumber: 1,
@@ -191,7 +188,7 @@
PartitionUUID: esp.ID,
},
},
- efivarfs.FilePath(EFIBootAPath),
+ efivarfs.FilePath(EFIPayloadPath),
},
}, nil
}
diff --git a/metropolis/node/core/abloader/BUILD.bazel b/metropolis/node/core/abloader/BUILD.bazel
new file mode 100644
index 0000000..2e382fa
--- /dev/null
+++ b/metropolis/node/core/abloader/BUILD.bazel
@@ -0,0 +1,24 @@
+load("@rules_rust//rust:defs.bzl", "rust_binary")
+load("//metropolis/node/build:def.bzl", "platform_transition_binary")
+
+rust_binary(
+ name = "abloader_bin",
+ srcs = ["main.rs"],
+ edition = "2021",
+ target_compatible_with = [
+ "@platforms//os:uefi",
+ ],
+ deps = [
+ "//metropolis/node/core/abloader/spec:abloader_proto_rs",
+ "@rsefi__prost__0_12_1//:prost",
+ "@rsefi__uefi__0_24_0//:uefi",
+ "@rsefi__uefi_services__0_21_0//:uefi_services",
+ ],
+)
+
+platform_transition_binary(
+ name = "abloader",
+ binary = ":abloader_bin",
+ target_platform = "//build/platforms:efi_amd64",
+ visibility = ["//visibility:public"],
+)
diff --git a/metropolis/node/core/abloader/main.rs b/metropolis/node/core/abloader/main.rs
new file mode 100644
index 0000000..0068f95
--- /dev/null
+++ b/metropolis/node/core/abloader/main.rs
@@ -0,0 +1,170 @@
+#![no_main]
+#![no_std]
+
+extern crate alloc;
+
+use alloc::vec::Vec;
+use core::result::Result;
+use core::fmt;
+use prost::Message;
+use uefi::fs::FileSystem;
+use uefi::proto::device_path::build::media::FilePath;
+use uefi::proto::device_path::build::DevicePathBuilder;
+use uefi::proto::device_path::{DeviceSubType, DeviceType, LoadedImageDevicePath};
+use uefi::table::boot;
+use uefi::{prelude::*, CStr16};
+use uefi_services::println;
+
+use abloader_proto::monogon::metropolis::node::core::abloader;
+
+const A_LOADER_PATH: &CStr16 = cstr16!("\\EFI\\metropolis\\boot-a.efi");
+const B_LOADER_PATH: &CStr16 = cstr16!("\\EFI\\metropolis\\boot-b.efi");
+
+const LOADER_STATE_PATH: &CStr16 = cstr16!("\\EFI\\metropolis\\loader_state.pb");
+
+enum ValidSlot {
+ A,
+ B,
+}
+
+impl ValidSlot {
+ // other returns B if the value is A and A if the value is B.
+ fn other(&self) -> Self {
+ match self {
+ ValidSlot::A => ValidSlot::B,
+ ValidSlot::B => ValidSlot::A,
+ }
+ }
+ // path returns the path to the slot's EFI payload.
+ fn path(&self) -> &'static CStr16 {
+ match self {
+ ValidSlot::A => A_LOADER_PATH,
+ ValidSlot::B => B_LOADER_PATH,
+ }
+ }
+}
+
+enum ReadLoaderStateError {
+ FSReadError(uefi::fs::Error),
+ DecodeError(prost::DecodeError),
+}
+
+impl fmt::Display for ReadLoaderStateError {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
+ match self {
+ ReadLoaderStateError::FSReadError(e) => write!(f, "while reading state file: {}", e),
+ ReadLoaderStateError::DecodeError(e) => write!(f, "while decoding state file contents: {}", e),
+ }
+ }
+}
+
+fn read_loader_state(fs: &mut FileSystem) -> Result<abloader::AbLoaderData, ReadLoaderStateError> {
+ let state_raw = fs.read(&LOADER_STATE_PATH).map_err(|e| ReadLoaderStateError::FSReadError(e))?;
+ abloader::AbLoaderData::decode(state_raw.as_slice()).map_err(|e| ReadLoaderStateError::DecodeError(e))
+}
+
+fn load_slot_image(slot: &ValidSlot, boot_services: &BootServices) -> uefi::Result<Handle> {
+ let mut storage = Vec::new();
+
+ // Build the path to the slot payload. This takes the path to the loader
+ // itself, strips off the file path and following element(s) and appends
+ // the path to the correct slot payload.
+ let new_image_path = {
+ let loaded_image_device_path = boot_services
+ .open_protocol_exclusive::<LoadedImageDevicePath>(boot_services.image_handle())?;
+
+ let mut builder = DevicePathBuilder::with_vec(&mut storage);
+
+ for node in loaded_image_device_path.node_iter() {
+ if node.full_type() == (DeviceType::MEDIA, DeviceSubType::MEDIA_FILE_PATH) {
+ break;
+ }
+
+ builder = builder.push(&node).unwrap();
+ }
+
+ builder = builder
+ .push(&FilePath {
+ path_name: slot.path(),
+ })
+ .unwrap();
+
+ builder.finalize().unwrap()
+ };
+
+ boot_services
+ .load_image(
+ boot_services.image_handle(),
+ boot::LoadImageSource::FromDevicePath {
+ device_path: new_image_path,
+ from_boot_manager: false,
+ },
+ )
+}
+
+#[entry]
+fn main(_handle: Handle, mut system_table: SystemTable<Boot>) -> Status {
+ uefi_services::init(&mut system_table).unwrap();
+
+ let boot_services = system_table.boot_services();
+
+ let boot_slot_raw = {
+ let mut esp_fs = boot_services
+ .get_image_file_system(boot_services.image_handle())
+ .expect("image filesystem not available");
+
+ let mut loader_data = match read_loader_state(&mut esp_fs) {
+ Ok(d) => d,
+ Err(e) => {
+ println!("Unable to load A/B loader state, using default slot A: {}", e);
+ abloader::AbLoaderData {
+ active_slot: abloader::Slot::A.into(),
+ next_slot: abloader::Slot::None.into(),
+ }
+ }
+ };
+
+ // If next_slot is set, use it as slot to boot but clear it in the
+ // state file as the next boot should not use it again. If it should
+ // be permanently activated, it is the OS's job to put it into
+ if loader_data.next_slot != abloader::Slot::None.into() {
+ let next_slot = loader_data.next_slot;
+ loader_data.next_slot = abloader::Slot::None.into();
+ let new_loader_data = loader_data.encode_to_vec();
+ esp_fs
+ .write(&LOADER_STATE_PATH, new_loader_data)
+ .expect("failed to write back abdata");
+ next_slot
+ } else {
+ loader_data.active_slot
+ }
+ };
+
+ let boot_slot = match abloader::Slot::try_from(boot_slot_raw) {
+ Ok(abloader::Slot::A) => ValidSlot::A,
+ Ok(abloader::Slot::B) => ValidSlot::B,
+ _ => {
+ println!("Invalid slot ({}) active, falling back to A", boot_slot_raw);
+ ValidSlot::A
+ }
+ };
+
+ let payload_image = match load_slot_image(&boot_slot, boot_services) {
+ Ok(img) => img,
+ Err(e) => {
+ println!("Error loading intended slot, falling back to other slot: {}", e);
+ match load_slot_image(&boot_slot.other(), boot_services) {
+ Ok(img) => img,
+ Err(e) => {
+ panic!("Loading from both slots failed, second slot error: {}", e);
+ },
+ }
+ }
+ };
+
+ // Boot the payload.
+ boot_services
+ .start_image(payload_image)
+ .expect("failed to start payload");
+ Status::SUCCESS
+}
diff --git a/metropolis/node/core/abloader/spec/BUILD.bazel b/metropolis/node/core/abloader/spec/BUILD.bazel
new file mode 100644
index 0000000..54d96cc
--- /dev/null
+++ b/metropolis/node/core/abloader/spec/BUILD.bazel
@@ -0,0 +1,30 @@
+load("@rules_proto//proto:defs.bzl", "proto_library")
+load("@rules_rust//proto/prost:defs.bzl", "rust_prost_library")
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+load("@io_bazel_rules_go//proto:def.bzl", "go_proto_library")
+
+proto_library(
+ name = "abloader_proto",
+ srcs = ["abloader.proto"],
+ visibility = ["//visibility:public"],
+)
+
+rust_prost_library(
+ name = "abloader_proto_rs",
+ proto = ":abloader_proto",
+ visibility = ["//visibility:public"],
+)
+
+go_proto_library(
+ name = "abloader_go_proto",
+ importpath = "source.monogon.dev/metropolis/node/core/abloader/spec",
+ proto = ":abloader_proto",
+ visibility = ["//visibility:public"],
+)
+
+go_library(
+ name = "spec",
+ embed = [":abloader_go_proto"],
+ importpath = "source.monogon.dev/metropolis/node/core/abloader/spec",
+ visibility = ["//visibility:public"],
+)
diff --git a/metropolis/node/core/abloader/spec/abloader.proto b/metropolis/node/core/abloader/spec/abloader.proto
new file mode 100644
index 0000000..aeaacd5
--- /dev/null
+++ b/metropolis/node/core/abloader/spec/abloader.proto
@@ -0,0 +1,22 @@
+syntax = "proto3";
+
+package monogon.metropolis.node.core.abloader;
+
+enum Slot {
+ NONE = 0;
+ A = 1;
+ B = 2;
+}
+
+// ABLoaderData contains data the A/B loader needs to chose which slot to boot
+// as well as for auto-rollback functionality.
+message ABLoaderData {
+ // The currently-active slot. This slot will be booted unless next_slot is
+ // set to a non-default value.
+ Slot active_slot = 1;
+ // The slot to boot next, but only once. Once the the slot has been selected
+ // to be booted by the loader, this value is reset before booting into that
+ // slot. If the OS boots successfully, it will update the active_slot to
+ // permanently boot from the new slot.
+ Slot next_slot = 2;
+}
\ No newline at end of file
diff --git a/metropolis/node/core/abloader/spec/gomod-generated-placeholder.go b/metropolis/node/core/abloader/spec/gomod-generated-placeholder.go
new file mode 100644
index 0000000..f09cd57
--- /dev/null
+++ b/metropolis/node/core/abloader/spec/gomod-generated-placeholder.go
@@ -0,0 +1 @@
+package spec
diff --git a/metropolis/node/core/update/BUILD.bazel b/metropolis/node/core/update/BUILD.bazel
index e506984..3be122e 100644
--- a/metropolis/node/core/update/BUILD.bazel
+++ b/metropolis/node/core/update/BUILD.bazel
@@ -1,4 +1,4 @@
-load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
go_library(
name = "update",
@@ -7,6 +7,7 @@
visibility = ["//visibility:public"],
deps = [
"//metropolis/node/build/mkimage/osimage",
+ "//metropolis/node/core/abloader/spec",
"//metropolis/pkg/blockdev",
"//metropolis/pkg/efivarfs",
"//metropolis/pkg/gpt",
@@ -15,17 +16,7 @@
"@com_github_cenkalti_backoff_v4//:backoff",
"@org_golang_google_grpc//codes",
"@org_golang_google_grpc//status",
+ "@org_golang_google_protobuf//proto",
"@org_golang_x_sys//unix",
],
)
-
-go_test(
- name = "update_test",
- srcs = ["update_test.go"],
- embed = [":update"],
- deps = [
- "//metropolis/pkg/efivarfs",
- "//metropolis/pkg/gpt",
- "@com_github_google_uuid//:uuid",
- ],
-)
diff --git a/metropolis/node/core/update/e2e/BUILD.bazel b/metropolis/node/core/update/e2e/BUILD.bazel
index 3905036..5f2b15f 100644
--- a/metropolis/node/core/update/e2e/BUILD.bazel
+++ b/metropolis/node/core/update/e2e/BUILD.bazel
@@ -9,6 +9,7 @@
# For the initial image creation
"//metropolis/node/core/update/e2e/testos:verity_rootfs_x",
"//metropolis/node/core/update/e2e/testos:kernel_efi_x",
+ "//metropolis/node/core/abloader",
# For the two update tests
"//metropolis/node/core/update/e2e/testos:testos_bundle_y",
"//metropolis/node/core/update/e2e/testos:testos_bundle_z",
diff --git a/metropolis/node/core/update/e2e/e2e_test.go b/metropolis/node/core/update/e2e/e2e_test.go
index 491259a..b5de3da 100644
--- a/metropolis/node/core/update/e2e/e2e_test.go
+++ b/metropolis/node/core/update/e2e/e2e_test.go
@@ -207,8 +207,18 @@
}
defer system.Close()
+ abloaderPath, err := datafile.ResolveRunfile("metropolis/node/core/abloader/abloader_bin.efi")
+ if err != nil {
+ t.Fatal(err)
+ }
+ loader, err := blkio.NewFileReader(abloaderPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
if _, err := osimage.Create(&osimage.Params{
Output: rootDisk,
+ ABLoader: loader,
EFIPayload: boot,
SystemImage: system,
PartitionSize: osimage.PartitionSizeInfo{
diff --git a/metropolis/node/core/update/update.go b/metropolis/node/core/update/update.go
index 75fe752..6486768 100644
--- a/metropolis/node/core/update/update.go
+++ b/metropolis/node/core/update/update.go
@@ -19,8 +19,10 @@
"golang.org/x/sys/unix"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
+ "google.golang.org/protobuf/proto"
"source.monogon.dev/metropolis/node/build/mkimage/osimage"
+ abloaderpb "source.monogon.dev/metropolis/node/core/abloader/spec"
"source.monogon.dev/metropolis/pkg/blockdev"
"source.monogon.dev/metropolis/pkg/efivarfs"
"source.monogon.dev/metropolis/pkg/gpt"
@@ -143,61 +145,6 @@
return res, nil
}
-func (s *Service) getOrMakeBootEntry(existing map[int]*efivarfs.LoadOption, slot Slot) (int, error) {
- idx, ok := s.findBootEntry(existing, slot)
- if ok {
- return idx, nil
- }
- newEntry := &efivarfs.LoadOption{
- Description: fmt.Sprintf("Metropolis Slot %s", slot),
- FilePath: efivarfs.DevicePath{
- &efivarfs.HardDrivePath{
- PartitionNumber: s.ESPPartNumber,
- PartitionStartBlock: s.ESPPart.FirstBlock,
- PartitionSizeBlocks: s.ESPPart.SizeBlocks(),
- PartitionMatch: efivarfs.PartitionGPT{
- PartitionUUID: s.ESPPart.ID,
- },
- },
- efivarfs.FilePath(slot.EFIBootPath()),
- },
- }
- s.Logger.Infof("Recreated boot entry %s", newEntry.Description)
- newIdx, err := efivarfs.AddBootEntry(newEntry)
- if err == nil {
- existing[newIdx] = newEntry
- }
- return newIdx, err
-}
-
-func (s *Service) findBootEntry(existing map[int]*efivarfs.LoadOption, slot Slot) (int, bool) {
- for idx, e := range existing {
- if len(e.FilePath) != 2 {
- // Not our entry
- continue
- }
- switch p := e.FilePath[0].(type) {
- case *efivarfs.HardDrivePath:
- gptMatch, ok := p.PartitionMatch.(efivarfs.PartitionGPT)
- if !(ok && gptMatch.PartitionUUID == s.ESPPart.ID) {
- // Not related to our ESP
- continue
- }
- default:
- continue
- }
- switch p := e.FilePath[1].(type) {
- case efivarfs.FilePath:
- if string(p) == slot.EFIBootPath() {
- return idx, true
- }
- default:
- continue
- }
- }
- return 0, false
-}
-
// MarkBootSuccessful must be called after each boot if some implementation-
// defined criteria for a successful boot are met. If an update has been
// installed and booted and this function is called, the updated version is
@@ -207,64 +154,24 @@
if s.ESPPath == "" {
return errors.New("no ESP information provided to update service, cannot continue")
}
- bootEntries, err := s.getAllBootEntries()
- if err != nil {
- return fmt.Errorf("while getting boot entries: %w", err)
- }
- aIdx, err := s.getOrMakeBootEntry(bootEntries, SlotA)
- if err != nil {
- return fmt.Errorf("while ensuring slot A boot entry: %w", err)
- }
- bIdx, err := s.getOrMakeBootEntry(bootEntries, SlotB)
- if err != nil {
- return fmt.Errorf("while ensuring slot B boot entry: %w", err)
- }
-
activeSlot := s.CurrentlyRunningSlot()
- firstSlot := SlotInvalid
-
- ord, err := efivarfs.GetBootOrder()
+ abState, err := s.getABState()
if err != nil {
- return fmt.Errorf("failed to get boot order: %w", err)
- }
-
- for _, e := range ord {
- if int(e) == aIdx {
- firstSlot = SlotA
- break
+ s.Logger.Warningf("Error while getting A/B loader state, recreating: %v", err)
+ abState = &abloaderpb.ABLoaderData{
+ ActiveSlot: abloaderpb.Slot(activeSlot),
}
- if int(e) == bIdx {
- firstSlot = SlotB
- break
+ err := s.setABState(abState)
+ if err != nil {
+ return fmt.Errorf("while recreating A/B loader state: %w", err)
}
}
-
- if firstSlot == SlotInvalid {
- bootOrder := make(efivarfs.BootOrder, 2)
- switch activeSlot {
- case SlotA:
- bootOrder[0], bootOrder[1] = uint16(aIdx), uint16(bIdx)
- case SlotB:
- bootOrder[0], bootOrder[1] = uint16(bIdx), uint16(aIdx)
- default:
- return fmt.Errorf("invalid active slot")
- }
- efivarfs.SetBootOrder(bootOrder)
- s.Logger.Infof("Metropolis missing from boot order, recreated it")
- } else if activeSlot != firstSlot {
- var aPos, bPos int
- for i, e := range ord {
- if int(e) == aIdx {
- aPos = i
- }
- if int(e) == bIdx {
- bPos = i
- }
- }
- // swap A and B slots in boot order
- ord[aPos], ord[bPos] = ord[bPos], ord[aPos]
- if err := efivarfs.SetBootOrder(ord); err != nil {
- return fmt.Errorf("failed to set boot order to permanently switch slot: %w", err)
+ if Slot(abState.ActiveSlot) != activeSlot {
+ err := s.setABState(&abloaderpb.ABLoaderData{
+ ActiveSlot: abloaderpb.Slot(activeSlot),
+ })
+ if err != nil {
+ return fmt.Errorf("while setting next A/B slot: %w", err)
}
s.Logger.Infof("Permanently activated slot %v", activeSlot)
} else {
@@ -285,6 +192,29 @@
}
}
+func (s *Service) getABState() (*abloaderpb.ABLoaderData, error) {
+ abDataRaw, err := os.ReadFile(filepath.Join(s.ESPPath, "EFI/metropolis/loader_state.pb"))
+ if err != nil {
+ return nil, err
+ }
+ var abData abloaderpb.ABLoaderData
+ if err := proto.Unmarshal(abDataRaw, &abData); err != nil {
+ return nil, err
+ }
+ return &abData, nil
+}
+
+func (s *Service) setABState(d *abloaderpb.ABLoaderData) error {
+ abDataRaw, err := proto.Marshal(d)
+ if err != nil {
+ return fmt.Errorf("while marshaling: %w", err)
+ }
+ if err := os.WriteFile(filepath.Join(s.ESPPath, "EFI/metropolis/loader_state.pb"), abDataRaw, 0666); err != nil {
+ return err
+ }
+ return nil
+}
+
// InstallBundle installs the bundle at the given HTTP(S) URL into the currently
// inactive slot and sets that slot to boot next. If it doesn't return an error,
// a reboot boots into the new slot.
@@ -326,22 +256,6 @@
}
targetSlot := activeSlot.Other()
- bootEntries, err := s.getAllBootEntries()
- if err != nil {
- return fmt.Errorf("while getting boot entries: %w", err)
- }
- targetSlotBootEntryIdx, err := s.getOrMakeBootEntry(bootEntries, targetSlot)
- if err != nil {
- return fmt.Errorf("while ensuring target slot boot entry: %w", err)
- }
- targetSlotBootEntry := bootEntries[targetSlotBootEntryIdx]
-
- // Disable boot entry while the corresponding slot is being modified.
- targetSlotBootEntry.Inactive = true
- if err := efivarfs.SetBootEntry(targetSlotBootEntryIdx, targetSlotBootEntry); err != nil {
- return fmt.Errorf("failed setting boot entry %d inactive: %w", targetSlotBootEntryIdx, err)
- }
-
systemPart, err := openSystemSlot(targetSlot)
if err != nil {
return status.Errorf(codes.Internal, "Inactive system slot unavailable: %v", err)
@@ -360,20 +274,17 @@
return fmt.Errorf("failed to write boot file: %w", err)
}
- // Reenable target slot boot entry after boot and system have been written
- // fully. The slot should now be bootable again.
- targetSlotBootEntry.Inactive = false
- if err := efivarfs.SetBootEntry(targetSlotBootEntryIdx, targetSlotBootEntry); err != nil {
- return fmt.Errorf("failed setting boot entry %d active: %w", targetSlotBootEntryIdx, err)
- }
-
if withKexec {
if err := s.stageKexec(bootFile, targetSlot); err != nil {
return fmt.Errorf("while kexec staging: %w", err)
}
} else {
- if err := efivarfs.SetBootNext(uint16(targetSlotBootEntryIdx)); err != nil {
- return fmt.Errorf("failed to set BootNext variable: %w", err)
+ err := s.setABState(&abloaderpb.ABLoaderData{
+ ActiveSlot: abloaderpb.Slot(activeSlot),
+ NextSlot: abloaderpb.Slot(targetSlot),
+ })
+ if err != nil {
+ return fmt.Errorf("while setting next A/B slot: %w", err)
}
}
diff --git a/metropolis/node/core/update/update_test.go b/metropolis/node/core/update/update_test.go
deleted file mode 100644
index 8206a22..0000000
--- a/metropolis/node/core/update/update_test.go
+++ /dev/null
@@ -1,127 +0,0 @@
-package update
-
-import (
- "testing"
-
- "github.com/google/uuid"
-
- "source.monogon.dev/metropolis/pkg/efivarfs"
- "source.monogon.dev/metropolis/pkg/gpt"
-)
-
-func TestFindBootEntry(t *testing.T) {
- testUUID1 := uuid.MustParse("85cb7a0c-d31d-4b65-1111-919b069915f1")
- testUUID2 := uuid.MustParse("d3086aa2-0327-4634-2222-5c6c8488cae3")
- cases := []struct {
- name string
- slot Slot
- espid uuid.UUID
- entries map[int]*efivarfs.LoadOption
- expectedOk bool
- expectedIdx int
- }{
- {
- name: "NoEntries",
- slot: SlotA,
- espid: testUUID1,
- entries: make(map[int]*efivarfs.LoadOption),
- expectedOk: false,
- },
- {
- name: "FindSimple",
- slot: SlotB,
- espid: testUUID1,
- entries: map[int]*efivarfs.LoadOption{
- 5: &efivarfs.LoadOption{
- Description: "Other Entry",
- FilePath: efivarfs.DevicePath{
- &efivarfs.HardDrivePath{
- PartitionNumber: 1,
- PartitionMatch: efivarfs.PartitionMBR{
- DiskSignature: [4]byte{1, 2, 3, 4},
- },
- },
- efivarfs.FilePath("EFI/something/else.efi"),
- },
- },
- 6: &efivarfs.LoadOption{
- Description: "Completely different entry",
- FilePath: efivarfs.DevicePath{
- &efivarfs.UnknownPath{
- // Vendor-specific subtype
- TypeVal: 1,
- SubTypeVal: 4,
- DataVal: []byte{1, 2, 3, 4},
- },
- efivarfs.FilePath("EFI/something"),
- efivarfs.FilePath("else.efi"),
- },
- },
- 16: &efivarfs.LoadOption{
- Description: "Target Entry",
- FilePath: efivarfs.DevicePath{
- &efivarfs.HardDrivePath{
- PartitionNumber: 2,
- PartitionMatch: efivarfs.PartitionGPT{
- PartitionUUID: testUUID1,
- },
- },
- efivarfs.FilePath("/EFI/metropolis/boot-b.efi"),
- },
- },
- },
- expectedOk: true,
- expectedIdx: 16,
- },
- {
- name: "FindViaESPUUID",
- slot: SlotA,
- espid: testUUID1,
- entries: map[int]*efivarfs.LoadOption{
- 6: &efivarfs.LoadOption{
- Description: "Other ESP UUID",
- FilePath: efivarfs.DevicePath{
- &efivarfs.HardDrivePath{
- PartitionNumber: 2,
- PartitionMatch: efivarfs.PartitionGPT{
- PartitionUUID: testUUID2,
- },
- },
- efivarfs.FilePath("/EFI/metropolis/boot-a.efi"),
- },
- },
- 7: &efivarfs.LoadOption{
- Description: "Target Entry",
- FilePath: efivarfs.DevicePath{
- &efivarfs.HardDrivePath{
- PartitionNumber: 2,
- PartitionMatch: efivarfs.PartitionGPT{
- PartitionUUID: testUUID1,
- },
- },
- efivarfs.FilePath("/EFI/metropolis/boot-a.efi"),
- },
- },
- },
- expectedOk: true,
- expectedIdx: 7,
- },
- }
-
- for _, c := range cases {
- t.Run(c.name, func(t *testing.T) {
- s := Service{
- ESPPart: &gpt.Partition{
- ID: c.espid,
- },
- }
- idx, ok := s.findBootEntry(c.entries, c.slot)
- if ok != c.expectedOk {
- t.Fatalf("expected ok %t, got %t", c.expectedOk, ok)
- }
- if idx != c.expectedIdx {
- t.Fatalf("expected idx %d, got %d", c.expectedIdx, idx)
- }
- })
- }
-}