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/build/platforms/BUILD.bazel b/build/platforms/BUILD.bazel
index 20cf3cd..4025c0a 100644
--- a/build/platforms/BUILD.bazel
+++ b/build/platforms/BUILD.bazel
@@ -14,6 +14,7 @@
         "@platforms//os:uefi",
         "@platforms//cpu:x86_64",
     ],
+    visibility = ["//visibility:public"],
 )
 
 # Linux x86_64 platform with static linking
diff --git a/cloud/agent/BUILD.bazel b/cloud/agent/BUILD.bazel
index 793f15b..96e3a38 100644
--- a/cloud/agent/BUILD.bazel
+++ b/cloud/agent/BUILD.bazel
@@ -10,6 +10,9 @@
         "install.go",
         "main.go",
     ],
+    embedsrcs = [
+        "//metropolis/node/core/abloader",  #keep
+    ],
     importpath = "source.monogon.dev/cloud/agent",
     visibility = ["//visibility:private"],
     deps = [
diff --git a/cloud/agent/install.go b/cloud/agent/install.go
index c8583ac..f209ce3 100644
--- a/cloud/agent/install.go
+++ b/cloud/agent/install.go
@@ -3,6 +3,7 @@
 import (
 	"archive/zip"
 	"bytes"
+	_ "embed"
 	"errors"
 	"fmt"
 	"io/fs"
@@ -20,6 +21,9 @@
 	npb "source.monogon.dev/net/proto"
 )
 
+//go:embed metropolis/node/core/abloader/abloader_bin.efi
+var abloader []byte
+
 // FileSizedReader is a small adapter from fs.File to fs.SizedReader
 // Panics on Stat() failure, so should only be used with sources where Stat()
 // cannot fail.
@@ -134,6 +138,7 @@
 		},
 		SystemImage:    systemImage,
 		EFIPayload:     FileSizedReader{efiPayload},
+		ABLoader:       bytes.NewReader(abloader),
 		NodeParameters: bytes.NewReader(nodeParamsRaw),
 		Output:         rootDev,
 	}
diff --git a/metropolis/installer/BUILD.bazel b/metropolis/installer/BUILD.bazel
index 17fd8c7..400b98e 100644
--- a/metropolis/installer/BUILD.bazel
+++ b/metropolis/installer/BUILD.bazel
@@ -9,6 +9,9 @@
         "log.go",
         "main.go",
     ],
+    embedsrcs = [
+        "//metropolis/node/core/abloader",  #keep
+    ],
     importpath = "source.monogon.dev/metropolis/installer",
     visibility = ["//visibility:private"],
     deps = [
diff --git a/metropolis/installer/main.go b/metropolis/installer/main.go
index 5481c3f..8aa8836 100644
--- a/metropolis/installer/main.go
+++ b/metropolis/installer/main.go
@@ -21,6 +21,8 @@
 
 import (
 	"archive/zip"
+	"bytes"
+	_ "embed"
 	"errors"
 	"fmt"
 	"io/fs"
@@ -38,6 +40,9 @@
 	"source.monogon.dev/metropolis/pkg/sysfs"
 )
 
+//go:embed metropolis/node/core/abloader/abloader_bin.efi
+var abloader []byte
+
 const mib = 1024 * 1024
 
 // mountPseudoFS mounts efivarfs, devtmpfs and sysfs, used by the installer in
@@ -228,6 +233,7 @@
 		},
 		SystemImage:    systemImage,
 		EFIPayload:     FileSizedReader{efiPayload},
+		ABLoader:       bytes.NewReader(abloader),
 		NodeParameters: FileSizedReader{nodeParameters},
 	}
 	// Calculate the minimum target size based on the installation parameters.
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)
-			}
-		})
-	}
-}