blob: c20e38d72ae8c16a5fb834612a1f08f038ebc44a [file] [log] [blame]
package core
import (
"errors"
"fmt"
"io"
"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/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
// 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
}
// MakeInstallerImage generates an installer disk image containing a GPT
// 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)
}
}
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 := 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)
if err != nil {
panic(err)
}
_, err = nodeParamsFile.Write(nodeParamsRaw)
if err != nil {
return fmt.Errorf("failed to write node params: %w", err)
}
}
if args.Bundle != nil {
bundleFile, err := fs.OpenFile("/metropolis-installer/bundle.bin", os.O_CREATE|os.O_RDWR)
if err != nil {
panic(err)
}
if _, err := io.Copy(bundleFile, args.Bundle); err != nil {
return fmt.Errorf("failed to copy bundle: %w", err)
}
}
return nil
}