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
 }