treewide: port everything to blockdev

This gets rid of most ad-hoc block device code, using blockdev for
everything. It also gets rid of diskfs for everything but tests. This
enables Metropolis to be installed on non-512-byte block sizes.

Change-Id: I644b5b68bb7bed8106585df3179674789031687a
Reviewed-on: https://review.monogon.dev/c/monogon/+/1873
Tested-by: Jenkins CI
Reviewed-by: Serge Bazanski <serge@monogon.tech>
diff --git a/metropolis/cli/metroctl/core/install.go b/metropolis/cli/metroctl/core/install.go
index c20e38d..31b7328 100644
--- a/metropolis/cli/metroctl/core/install.go
+++ b/metropolis/cli/metroctl/core/install.go
@@ -1,168 +1,100 @@
 package core
 
 import (
+	"bytes"
 	"errors"
 	"fmt"
-	"io"
+	"math"
 	"os"
 
-	"github.com/diskfs/go-diskfs"
-	"github.com/diskfs/go-diskfs/disk"
-	"github.com/diskfs/go-diskfs/filesystem"
-	"github.com/diskfs/go-diskfs/partition/gpt"
 	"google.golang.org/protobuf/proto"
 
+	"source.monogon.dev/metropolis/pkg/blockdev"
+	"source.monogon.dev/metropolis/pkg/fat32"
+	"source.monogon.dev/metropolis/pkg/gpt"
 	"source.monogon.dev/metropolis/proto/api"
 )
 
-func mibToSectors(size uint64, logicalBlockSize int64) uint64 {
-	return (size * 1024 * 1024) / uint64(logicalBlockSize)
-}
-
-// Mask for aligning values to 1MiB boundaries. Go complains if you shift
-// 1 bits out of the value in a constant so the construction is a bit
-// convoluted.
-const align1MiBMask = (1<<44 - 1) << 20
-
-const MiB = 1024 * 1024
-
 type MakeInstallerImageArgs struct {
 	// Path to either a file or a disk which will contain the installer data.
 	TargetPath string
 
 	// Reader for the installer EFI executable. Mandatory.
-	Installer     io.Reader
-	InstallerSize uint64
+	Installer fat32.SizedReader
 
 	// Optional NodeParameters to be embedded for use by the installer.
 	NodeParams *api.NodeParameters
 
 	// Optional Reader for a Metropolis bundle for use by the installer.
-	Bundle     io.Reader
-	BundleSize uint64
+	Bundle fat32.SizedReader
 }
 
-// MakeInstallerImage generates an installer disk image containing a GPT
+// MakeInstallerImage generates an installer disk image containing a Table
 // partition table and a single FAT32 partition with an installer and optionally
 // with a bundle and/or Node Parameters.
 func MakeInstallerImage(args MakeInstallerImageArgs) error {
 	if args.Installer == nil {
 		return errors.New("Installer is mandatory")
 	}
-	if args.InstallerSize == 0 {
-		return errors.New("InstallerSize needs to be valid (>0)")
-	}
-	if args.Bundle != nil && args.BundleSize == 0 {
-		return errors.New("if a Bundle is passed BundleSize needs to be valid (>0)")
-	}
 
-	var err error
-	var nodeParamsRaw []byte
-	if args.NodeParams != nil {
-		nodeParamsRaw, err = proto.Marshal(args.NodeParams)
-		if err != nil {
-			return fmt.Errorf("failed to marshal node params: %w", err)
-		}
-	}
+	espRoot := fat32.Inode{Attrs: fat32.AttrDirectory}
 
-	var img *disk.Disk
-	// The following section is a bit ugly, it would technically be nicer to
-	// just pack all clusters of the FAT32 together, figure out how many were
-	// needed at the end and truncate the partition there. But that would
-	// require writing a new FAT32 writer, the effort to do that is in no way
-	// proportional to its advantages. So let's just do some conservative
-	// calculations on how much space we need and call it a day.
-
-	// ~4MiB FAT32 headers, 1MiB alignment overhead (bitmask drops up to 1MiB),
-	// 5% filesystem overhead
-	partitionSizeBytes := (uint64(float32(5*MiB+args.BundleSize+args.InstallerSize+uint64(len(nodeParamsRaw))) * 1.05)) & align1MiBMask
-	// FAT32 has a minimum partition size of 32MiB, so clamp the lower partition
-	// size to a notch more than that.
-	minimumSize := uint64(33 * MiB)
-	if partitionSizeBytes < minimumSize {
-		partitionSizeBytes = minimumSize
-	}
-	// If creating an image, create it with minimal size, i.e. 1MiB at each
-	// end for partitioning metadata and alignment.
-	// 1MiB alignment is used as that will essentially guarantee that any
-	// partition is aligned to whatever internal block size is used by the
-	// storage device. Especially flash-based storage likes to use much bigger
-	// blocks than advertised as sectors which can degrade performance when
-	// partitions are misaligned.
-	calculatedImageBytes := 2*MiB + partitionSizeBytes
-
-	if _, err = os.Stat(args.TargetPath); os.IsNotExist(err) {
-		img, err = diskfs.Create(args.TargetPath, int64(calculatedImageBytes), diskfs.Raw)
-	} else {
-		img, err = diskfs.Open(args.TargetPath)
-	}
-	if err != nil {
-		return fmt.Errorf("failed to create/open target: %w", err)
-	}
-	defer img.File.Close()
-	// This has an edge case where the data would technically fit but our 5%
-	// overhead are too conservative. But it is very rare and I don't really
-	// trust diskfs to generate good errors when it overflows so we'll just
-	// reject early.
-	if uint64(img.Size) < calculatedImageBytes {
-		return errors.New("target too small, data won't fit")
-	}
-
-	gptTable := &gpt.Table{
-		LogicalSectorSize:  int(img.LogicalBlocksize),
-		PhysicalSectorSize: int(img.PhysicalBlocksize),
-		ProtectiveMBR:      true,
-		Partitions: []*gpt.Partition{
-			{
-				Type:  gpt.EFISystemPartition,
-				Name:  "MetropolisInstaller",
-				Start: mibToSectors(1, img.LogicalBlocksize),
-				Size:  partitionSizeBytes,
-			},
-		},
-	}
-	if err := img.Partition(gptTable); err != nil {
-		return fmt.Errorf("failed to partition target: %w", err)
-	}
-	fs, err := img.CreateFilesystem(disk.FilesystemSpec{Partition: 1, FSType: filesystem.TypeFat32, VolumeLabel: "METRO_INST"})
-	if err != nil {
-		return fmt.Errorf("failed to create target filesystem: %w", err)
-	}
-
-	// Create EFI partition structure.
-	for _, dir := range []string{"/EFI", "/EFI/BOOT", "/metropolis-installer"} {
-		if err := fs.Mkdir(dir); err != nil {
-			panic(err)
-		}
-	}
 	// This needs to be a "Removable Media" according to the UEFI Specification
 	// V2.9 Section 3.5.1.1. This file is booted by any compliant UEFI firmware
 	// in absence of another bootable boot entry.
-	installerFile, err := fs.OpenFile("/EFI/BOOT/BOOTx64.EFI", os.O_CREATE|os.O_RDWR)
-	if err != nil {
-		panic(err)
+	if err := espRoot.PlaceFile("EFI/BOOT/BOOTx64.EFI", args.Installer); err != nil {
+		return err
 	}
-	if _, err := io.Copy(installerFile, args.Installer); err != nil {
-		return fmt.Errorf("failed to copy installer file: %w", err)
-	}
+
 	if args.NodeParams != nil {
-		nodeParamsFile, err := fs.OpenFile("/metropolis-installer/nodeparams.pb", os.O_CREATE|os.O_RDWR)
+		nodeParamsRaw, err := proto.Marshal(args.NodeParams)
 		if err != nil {
-			panic(err)
+			return fmt.Errorf("failed to marshal node params: %w", err)
 		}
-		_, err = nodeParamsFile.Write(nodeParamsRaw)
-		if err != nil {
-			return fmt.Errorf("failed to write node params: %w", err)
+		if err := espRoot.PlaceFile("metropolis-installer/nodeparams.pb", bytes.NewReader(nodeParamsRaw)); err != nil {
+			return err
 		}
 	}
 	if args.Bundle != nil {
-		bundleFile, err := fs.OpenFile("/metropolis-installer/bundle.bin", os.O_CREATE|os.O_RDWR)
+		if err := espRoot.PlaceFile("metropolis-installer/bundle.bin", args.Bundle); err != nil {
+			return err
+		}
+	}
+	var targetDev blockdev.BlockDev
+	var err error
+	targetDev, err = blockdev.Open(args.TargetPath)
+	if err != nil {
+		if errors.Is(err, os.ErrNotExist) {
+			targetDev, err = blockdev.CreateFile(args.TargetPath, 512, 1024*1024+4096)
+		}
 		if err != nil {
-			panic(err)
+			return fmt.Errorf("unable to open target device: %w", err)
 		}
-		if _, err := io.Copy(bundleFile, args.Bundle); err != nil {
-			return fmt.Errorf("failed to copy bundle: %w", err)
-		}
+	}
+	partTable, err := gpt.New(targetDev)
+	if err != nil {
+		return fmt.Errorf("target device has invalid geometry: %w", err)
+	}
+	esp := gpt.Partition{
+		Type: gpt.PartitionTypeEFISystem,
+		Name: "MetropolisInstaller",
+	}
+	fatOpts := fat32.Options{Label: "METRO_INST"}
+	// TODO(#254): Build and use dynamically-grown block devices
+	var espSize int64 = 512 * 1024 * 1024
+	if err := partTable.AddPartition(&esp, espSize); err != nil {
+		return fmt.Errorf("unable to create partition layout: %w", err)
+	}
+	if esp.BlockSize() > math.MaxUint16 {
+		return fmt.Errorf("block size (%d) too large for FAT32", esp.BlockSize())
+	}
+	fatOpts.BlockSize = uint16(esp.BlockSize())
+	fatOpts.BlockCount = uint32(esp.BlockCount())
+	if err := fat32.WriteFS(blockdev.NewRWS(esp), espRoot, fatOpts); err != nil {
+		return fmt.Errorf("failed to write FAT32: %w", err)
+	}
+	if err := partTable.Write(); err != nil {
+		return fmt.Errorf("unable to write partition table: %w", err)
 	}
 	return nil
 }