Files
spinifex/cmd/installer/install/disk.go
T

159 lines
5.1 KiB
Go

package install
import (
"fmt"
"log/slog"
"os"
"time"
)
// Partition numbers, identical on every disk the installer touches so that a
// replacement drive can be cloned with `sgdisk -R` and nothing has to be
// recomputed at 3am.
const (
biosPartNum = 1
espPartNum = 2
rootPartNum = 3
)
// GPT partition type GUIDs, by their sgdisk short code.
const (
typeBIOSBoot = "EF02"
typeESP = "EF00"
typeLinuxFS = "8300"
typeZFS = "BF00"
)
// Partition geometry. The ESP is sized as Proxmox does: 512MiB is enough for
// two kernels, but a 1GiB ESP on a real disk costs nothing and keeps room for
// the extra kernels an operator pins during an upgrade.
const (
espSmallMiB = 512
espLargeMiB = 1024
espLargeDiskGiB = 100
// biosBootMiB is the BIOS Boot Partition GRUB's i386-pc core.img is embedded
// into. GPT leaves no post-MBR gap, so without it there is nowhere to put it.
biosBootMiB = 1
// tailReserveMiB is left unallocated at the end of every disk. A replacement
// drive of the "same" size is routinely a few thousand sectors smaller, and
// without slack `sgdisk -R` refuses to clone the table onto it — which is
// discovered while standing in front of a degraded pool.
tailReserveMiB = 1024
)
// espSizeMiB picks the ESP size for a disk.
func espSizeMiB(d Disk) int {
if d.Bytes >= espLargeDiskGiB<<30 {
return espLargeMiB
}
return espSmallMiB
}
// partitionDisks lays down an identical GPT on every selected disk. Each disk
// gets a BIOS boot partition and an ESP whether or not this machine booted in
// EFI mode: the cost is 1GiB, and it means a board swap or a firmware change
// from BIOS to UEFI does not leave an unbootable pool.
func partitionDisks(cfg DiskConfig) error {
ptype := typeLinuxFS
if cfg.FS.IsZFS() {
ptype = typeZFS
}
for _, d := range cfg.Disks {
if err := partitionOne(d, ptype); err != nil {
return fmt.Errorf("partition %s: %w", d.Path, err)
}
}
return waitForPartitions(cfg)
}
// partitionOne writes the GPT for a single disk.
func partitionOne(d Disk, rootType string) error {
// Stale ZFS labels and filesystem signatures survive a new partition table
// and make a retried install fail with errors that read like hardware faults.
if err := clearDisk(d); err != nil {
return fmt.Errorf("clear: %w", err)
}
esp := espSizeMiB(d)
if err := run("sgdisk",
"-n", fmt.Sprintf("%d:1M:+%dM", biosPartNum, biosBootMiB),
"-t", fmt.Sprintf("%d:%s", biosPartNum, typeBIOSBoot),
"-c", fmt.Sprintf("%d:bios_boot", biosPartNum),
"-n", fmt.Sprintf("%d:0:+%dM", espPartNum, esp),
"-t", fmt.Sprintf("%d:%s", espPartNum, typeESP),
"-c", fmt.Sprintf("%d:ESP", espPartNum),
"-n", fmt.Sprintf("%d:0:-%dM", rootPartNum, tailReserveMiB),
"-t", fmt.Sprintf("%d:%s", rootPartNum, rootType),
"-c", fmt.Sprintf("%d:root", rootPartNum),
d.Path,
); err != nil {
return err
}
// A fresh disk-level GUID: cloning a table with `sgdisk -R` copies it, and
// two disks sharing one leaves the pool ambiguous to udev.
if err := run("sgdisk", "-G", d.Path); err != nil {
slog.Warn("could not randomise GPT GUIDs", "disk", d.Path, "err", err)
}
return nil
}
// waitForPartitions ensures every partition device node the installer is about
// to use exists. Trixie's udev is slow enough after a table rewrite that mkfs
// races it and fails with ENOENT on a device the kernel has already accepted.
func waitForPartitions(cfg DiskConfig) error {
for _, d := range cfg.Disks {
// Best-effort: partprobe failing is not fatal, since udev may still act
// on the BLKRRPART ioctl sgdisk issued itself.
if err := run("partprobe", d.Path); err != nil {
slog.Warn("partprobe failed, continuing", "disk", d.Path, "err", err)
}
}
if err := run("udevadm", "settle", "--timeout=10"); err != nil {
slog.Warn("udevadm settle failed, continuing", "err", err)
}
deadline := time.Now().Add(15 * time.Second)
for _, d := range cfg.Disks {
wanted := []string{d.PartitionPath(espPartNum), d.PartitionPath(rootPartNum)}
// The pool is built from by-id paths, so their symlinks have to exist
// too — udev publishes them later than the kernel device node.
if d.ByID != "" {
wanted = append(wanted, d.StablePartitionPath(rootPartNum))
}
for _, part := range wanted {
if err := waitForPath(part, deadline); err != nil {
return err
}
}
}
return nil
}
func waitForPath(path string, deadline time.Time) error {
for {
if _, err := os.Stat(path); err == nil {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("device %s did not appear within timeout — the kernel or udev did not pick up the new partition table", path)
}
time.Sleep(200 * time.Millisecond)
}
}
// formatESPs makes a FAT32 filesystem on every disk's ESP. Each one is
// independent — there is no RAID here, they are kept in step by content.
func formatESPs(cfg DiskConfig) error {
for _, d := range cfg.Disks {
esp := d.PartitionPath(espPartNum)
// A distinct label per member so `lsblk -f` on a degraded machine tells
// the operator which disk each ESP belongs to.
if err := run("mkfs.fat", "-F", "32", "-n", "EFI", esp); err != nil {
return fmt.Errorf("format ESP %s: %w", esp, err)
}
}
return nil
}