Lorenz Brun | 57479bb | 2021-10-26 14:01:06 +0200 | [diff] [blame] | 1 | package core |
| 2 | |
| 3 | import ( |
| 4 | "errors" |
| 5 | "fmt" |
| 6 | "io" |
| 7 | "os" |
| 8 | |
| 9 | "github.com/diskfs/go-diskfs" |
| 10 | "github.com/diskfs/go-diskfs/disk" |
| 11 | "github.com/diskfs/go-diskfs/filesystem" |
| 12 | "github.com/diskfs/go-diskfs/partition/gpt" |
| 13 | "google.golang.org/protobuf/proto" |
Serge Bazanski | 44d2ad4 | 2021-11-10 17:05:34 +0100 | [diff] [blame] | 14 | |
Lorenz Brun | 57479bb | 2021-10-26 14:01:06 +0200 | [diff] [blame] | 15 | "source.monogon.dev/metropolis/proto/api" |
| 16 | ) |
| 17 | |
| 18 | func mibToSectors(size uint64, logicalBlockSize int64) uint64 { |
| 19 | return (size * 1024 * 1024) / uint64(logicalBlockSize) |
| 20 | } |
| 21 | |
| 22 | // Mask for aligning values to 1MiB boundaries. Go complains if you shift |
| 23 | // 1 bits out of the value in a constant so the construction is a bit |
| 24 | // convoluted. |
| 25 | const align1MiBMask = (1<<44 - 1) << 20 |
| 26 | |
| 27 | const MiB = 1024 * 1024 |
| 28 | |
| 29 | type MakeInstallerImageArgs struct { |
| 30 | // Path to either a file or a disk which will contain the installer data. |
| 31 | TargetPath string |
| 32 | |
| 33 | // Reader for the installer EFI executable. Mandatory. |
| 34 | Installer io.Reader |
| 35 | InstallerSize uint64 |
| 36 | |
| 37 | // Optional NodeParameters to be embedded for use by the installer. |
| 38 | NodeParams *api.NodeParameters |
| 39 | |
| 40 | // Optional Reader for a Metropolis bundle for use by the installer. |
| 41 | Bundle io.Reader |
| 42 | BundleSize uint64 |
| 43 | } |
| 44 | |
| 45 | // MakeInstallerImage generates an installer disk image containing a GPT |
| 46 | // partition table and a single FAT32 partition with an installer and optionally |
| 47 | // with a bundle and/or Node Parameters. |
| 48 | func MakeInstallerImage(args MakeInstallerImageArgs) error { |
| 49 | if args.Installer == nil { |
| 50 | return errors.New("Installer is mandatory") |
| 51 | } |
| 52 | if args.InstallerSize == 0 { |
| 53 | return errors.New("InstallerSize needs to be valid (>0)") |
| 54 | } |
| 55 | if args.Bundle != nil && args.BundleSize == 0 { |
| 56 | return errors.New("if a Bundle is passed BundleSize needs to be valid (>0)") |
| 57 | } |
| 58 | |
| 59 | var err error |
| 60 | var nodeParamsRaw []byte |
| 61 | if args.NodeParams != nil { |
| 62 | nodeParamsRaw, err = proto.Marshal(args.NodeParams) |
| 63 | if err != nil { |
| 64 | return fmt.Errorf("failed to marshal node params: %w", err) |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | var img *disk.Disk |
| 69 | // The following section is a bit ugly, it would technically be nicer to |
| 70 | // just pack all clusters of the FAT32 together, figure out how many were |
| 71 | // needed at the end and truncate the partition there. But that would |
| 72 | // require writing a new FAT32 writer, the effort to do that is in no way |
| 73 | // proportional to its advantages. So let's just do some conservative |
| 74 | // calculations on how much space we need and call it a day. |
| 75 | |
| 76 | // ~4MiB FAT32 headers, 1MiB alignment overhead (bitmask drops up to 1MiB), |
| 77 | // 5% filesystem overhead |
| 78 | partitionSizeBytes := (uint64(float32(5*MiB+args.BundleSize+args.InstallerSize+uint64(len(nodeParamsRaw))) * 1.05)) & align1MiBMask |
| 79 | // FAT32 has a minimum partition size of 32MiB, so clamp the lower partition |
Mateusz Zalega | 0b12170 | 2021-11-06 12:54:58 +0100 | [diff] [blame] | 80 | // size to a notch more than that. |
| 81 | minimumSize := uint64(33 * MiB) |
| 82 | if partitionSizeBytes < minimumSize { |
| 83 | partitionSizeBytes = minimumSize |
Lorenz Brun | 57479bb | 2021-10-26 14:01:06 +0200 | [diff] [blame] | 84 | } |
| 85 | // If creating an image, create it with minimal size, i.e. 1MiB at each |
| 86 | // end for partitioning metadata and alignment. |
| 87 | // 1MiB alignment is used as that will essentially guarantee that any |
| 88 | // partition is aligned to whatever internal block size is used by the |
| 89 | // storage device. Especially flash-based storage likes to use much bigger |
| 90 | // blocks than advertised as sectors which can degrade performance when |
| 91 | // partitions are misaligned. |
| 92 | calculatedImageBytes := 2*MiB + partitionSizeBytes |
| 93 | |
| 94 | if _, err = os.Stat(args.TargetPath); os.IsNotExist(err) { |
| 95 | img, err = diskfs.Create(args.TargetPath, int64(calculatedImageBytes), diskfs.Raw) |
| 96 | } else { |
| 97 | img, err = diskfs.Open(args.TargetPath) |
| 98 | } |
| 99 | if err != nil { |
| 100 | return fmt.Errorf("failed to create/open target: %w", err) |
| 101 | } |
| 102 | defer img.File.Close() |
| 103 | // This has an edge case where the data would technically fit but our 5% |
| 104 | // overhead are too conservative. But it is very rare and I don't really |
| 105 | // trust diskfs to generate good errors when it overflows so we'll just |
| 106 | // reject early. |
| 107 | if uint64(img.Size) < calculatedImageBytes { |
| 108 | return errors.New("target too small, data won't fit") |
| 109 | } |
| 110 | |
| 111 | gptTable := &gpt.Table{ |
| 112 | LogicalSectorSize: int(img.LogicalBlocksize), |
| 113 | PhysicalSectorSize: int(img.PhysicalBlocksize), |
| 114 | ProtectiveMBR: true, |
| 115 | Partitions: []*gpt.Partition{ |
| 116 | { |
| 117 | Type: gpt.EFISystemPartition, |
| 118 | Name: "MetropolisInstaller", |
| 119 | Start: mibToSectors(1, img.LogicalBlocksize), |
| 120 | Size: partitionSizeBytes, |
| 121 | }, |
| 122 | }, |
| 123 | } |
| 124 | if err := img.Partition(gptTable); err != nil { |
| 125 | return fmt.Errorf("failed to partition target: %w", err) |
| 126 | } |
| 127 | fs, err := img.CreateFilesystem(disk.FilesystemSpec{Partition: 1, FSType: filesystem.TypeFat32, VolumeLabel: "METRO_INST"}) |
| 128 | if err != nil { |
| 129 | return fmt.Errorf("failed to create target filesystem: %w", err) |
| 130 | } |
| 131 | |
| 132 | // Create EFI partition structure. |
| 133 | for _, dir := range []string{"/EFI", "/EFI/BOOT", "/EFI/metropolis-installer"} { |
| 134 | if err := fs.Mkdir(dir); err != nil { |
| 135 | panic(err) |
| 136 | } |
| 137 | } |
| 138 | // This needs to be a "Removable Media" according to the UEFI Specification |
| 139 | // V2.9 Section 3.5.1.1. This file is booted by any compliant UEFI firmware |
| 140 | // in absence of another bootable boot entry. |
| 141 | installerFile, err := fs.OpenFile("/EFI/BOOT/BOOTx64.EFI", os.O_CREATE|os.O_RDWR) |
| 142 | if err != nil { |
| 143 | panic(err) |
| 144 | } |
| 145 | if _, err := io.Copy(installerFile, args.Installer); err != nil { |
| 146 | return fmt.Errorf("failed to copy installer file: %w", err) |
| 147 | } |
| 148 | if args.NodeParams != nil { |
| 149 | nodeParamsFile, err := fs.OpenFile("/EFI/metropolis-installer/nodeparams.pb", os.O_CREATE|os.O_RDWR) |
| 150 | if err != nil { |
| 151 | panic(err) |
| 152 | } |
| 153 | _, err = nodeParamsFile.Write(nodeParamsRaw) |
| 154 | if err != nil { |
| 155 | return fmt.Errorf("failed to write node params: %w", err) |
| 156 | } |
| 157 | } |
| 158 | if args.Bundle != nil { |
| 159 | bundleFile, err := fs.OpenFile("/EFI/metropolis-installer/bundle.bin", os.O_CREATE|os.O_RDWR) |
| 160 | if err != nil { |
| 161 | panic(err) |
| 162 | } |
| 163 | if _, err := io.Copy(bundleFile, args.Bundle); err != nil { |
| 164 | return fmt.Errorf("failed to copy bundle: %w", err) |
| 165 | } |
| 166 | } |
| 167 | return nil |
| 168 | } |