mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 10:52:04 +00:00
203 lines
7.3 KiB
Go
203 lines
7.3 KiB
Go
package install
|
|
|
|
import (
|
|
_ "embed"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
//go:embed assets/spinifex-boot-tool
|
|
var bootToolScript []byte
|
|
|
|
//go:embed assets/grub-install-wrapper
|
|
var grubInstallWrapper []byte
|
|
|
|
const (
|
|
bootToolPath = "/usr/sbin/spinifex-boot-tool"
|
|
grubInstallPath = "/usr/sbin/grub-install"
|
|
grubInstallReal = "/usr/sbin/grub-install.real"
|
|
bootToolUUIDList = "/etc/kernel/spinifex-boot-uuids"
|
|
)
|
|
|
|
// installBootToolZFS sets up per-disk bootloaders on a ZFS root system.
|
|
//
|
|
// Nothing in the boot path is allowed to read the pool: GRUB's ZFS support
|
|
// trails OpenZFS, so a pool feature flag enabled years from now could make the
|
|
// machine unbootable. Each disk instead carries a self-contained ESP with its
|
|
// own GRUB, kernels and initramfs.
|
|
func installBootToolZFS(cfg DiskConfig) error {
|
|
if err := writeTargetFile(bootToolPath, bootToolScript, 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := writeGrubDefaults(cfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := bindChrootMounts(); err != nil {
|
|
return err
|
|
}
|
|
defer unbindChrootMounts()
|
|
|
|
// Diverted before the first init: init runs a refresh, and refresh calls
|
|
// grub-install. With the wrapper already in place that call has to reach
|
|
// the real binary, which is what the diversion arranges.
|
|
if err := divertGrubInstall(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// A partially-updated set of ESPs is an install that boots today and fails
|
|
// after the next kernel upgrade, so every disk must succeed here.
|
|
env := []string{"SPINIFEX_BOOT_TOOL_STRICT=1"}
|
|
for _, d := range cfg.Disks {
|
|
esp := d.PartitionPath(espPartNum)
|
|
if err := runEnv(env, "chroot", mountRoot, bootToolPath, "init", esp); err != nil {
|
|
return fmt.Errorf("spinifex-boot-tool init %s: %w", esp, err)
|
|
}
|
|
}
|
|
|
|
// The one moment firmware boot variables are written. Refreshes run on
|
|
// every kernel upgrade and deliberately leave NVRAM alone: repeatedly
|
|
// rewriting boot entries has bricked more than one vendor's boards. Losing
|
|
// the entry is survivable anyway, because each ESP also carries the
|
|
// removable-path loader that firmware falls back to.
|
|
if err := runEnv(append(env, "SPINIFEX_BOOT_TOOL_NVRAM=1"),
|
|
"chroot", mountRoot, bootToolPath, "refresh"); err != nil {
|
|
return fmt.Errorf("spinifex-boot-tool refresh: %w", err)
|
|
}
|
|
return verifyBootConfig(cfg)
|
|
}
|
|
|
|
// divertGrubInstall moves the real grub-install aside and puts the wrapper in
|
|
// its place. dpkg-divert makes the move survive package upgrades, which is the
|
|
// point: grub-pc's postinst runs grub-install and would otherwise quietly
|
|
// replace a working boot layout with one that reads the pool.
|
|
func divertGrubInstall() error {
|
|
if err := run("chroot", mountRoot, "dpkg-divert", "--divert", grubInstallReal,
|
|
"--rename", "--add", grubInstallPath); err != nil {
|
|
return fmt.Errorf("divert grub-install: %w", err)
|
|
}
|
|
return writeTargetFile(grubInstallPath, grubInstallWrapper, 0o755)
|
|
}
|
|
|
|
// verifyBootConfig asserts that the config the machine will actually boot from
|
|
// names the ZFS root. grub-mkconfig degrades quietly when grub-probe cannot
|
|
// read the pool, emitting a config that gets as far as the GRUB menu and then
|
|
// panics with "unable to mount root fs" — which looks like a hardware fault.
|
|
func verifyBootConfig(cfg DiskConfig) error {
|
|
uuids, err := os.ReadFile(filepath.Join(mountRoot, strings.TrimPrefix(bootToolUUIDList, "/")))
|
|
if err != nil {
|
|
return fmt.Errorf("read ESP list: %w", err)
|
|
}
|
|
if len(strings.Fields(string(uuids))) != len(cfg.Disks) {
|
|
return fmt.Errorf("expected %d registered ESPs, found %d — some disks have no bootloader",
|
|
len(cfg.Disks), len(strings.Fields(string(uuids))))
|
|
}
|
|
|
|
// One ESP is enough to prove grub-mkconfig produced the right thing; they
|
|
// are all generated by the same code path in the same run.
|
|
mp := filepath.Join(mountRoot, "var/tmp/verify-esp")
|
|
if err := os.MkdirAll(mp, 0o755); err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = os.Remove(mp) }()
|
|
|
|
esp := cfg.Disks[0].PartitionPath(espPartNum)
|
|
if err := run("mount", esp, mp); err != nil {
|
|
return fmt.Errorf("mount %s for verification: %w", esp, err)
|
|
}
|
|
defer func() { _ = run("umount", mp) }()
|
|
|
|
grubCfg, err := os.ReadFile(filepath.Join(mp, "grub/grub.cfg"))
|
|
if err != nil {
|
|
return fmt.Errorf("read grub.cfg from %s: %w", esp, err)
|
|
}
|
|
want := "root=ZFS=" + ZFSRootDataset
|
|
if !strings.Contains(string(grubCfg), want) {
|
|
return fmt.Errorf("grub.cfg on %s does not contain %q — the installed system would not find its root filesystem", esp, want)
|
|
}
|
|
if !strings.Contains(string(grubCfg), "vmlinuz-") {
|
|
return fmt.Errorf("grub.cfg on %s references no kernel", esp)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// writeTargetFile writes a file into the install target.
|
|
func writeTargetFile(path string, content []byte, mode os.FileMode) error {
|
|
full := filepath.Join(mountRoot, strings.TrimPrefix(path, "/"))
|
|
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(full, content, mode); err != nil {
|
|
return fmt.Errorf("write %s: %w", path, err)
|
|
}
|
|
// WriteFile does not apply the mode to a file that already exists, and this
|
|
// path is re-run on a retried install.
|
|
return os.Chmod(full, mode)
|
|
}
|
|
|
|
// writeGrubDefaults writes /etc/default/grub and the branding snippet.
|
|
//
|
|
// Both consoles are listed: tty0 stays primary for a local install while ttyS0
|
|
// mirrors kernel output for headless racks and CI. The last console= on the
|
|
// cmdline becomes the system console, so serial-only hardware still works.
|
|
func writeGrubDefaults(cfg DiskConfig) error {
|
|
cmdline := "console=tty0 console=ttyS0,115200 systemd.show_status=1"
|
|
if cfg.FS.IsZFS() {
|
|
// Stated explicitly rather than relying on grub-mkconfig's probe. It
|
|
// appears after the root= that 10_linux computes, so it wins if the
|
|
// probe came back with something wrong.
|
|
cmdline = fmt.Sprintf("root=ZFS=%s boot=zfs %s", ZFSRootDataset, cmdline)
|
|
}
|
|
grubDefault := fmt.Sprintf(`GRUB_DEFAULT=0
|
|
GRUB_TIMEOUT=5
|
|
GRUB_DISTRIBUTOR=Spinifex
|
|
GRUB_CMDLINE_LINUX_DEFAULT=""
|
|
GRUB_CMDLINE_LINUX="%s"
|
|
`, cmdline)
|
|
if err := os.WriteFile(filepath.Join(mountRoot, "etc/default/grub"), []byte(grubDefault), 0o644); err != nil {
|
|
return fmt.Errorf("write /etc/default/grub: %w", err)
|
|
}
|
|
|
|
// gfxterm must be activated before serial is appended — background_image
|
|
// silently does nothing in text mode. $prefix is used rather than a literal
|
|
// /boot/grub so the same snippet is correct whether GRUB's root is the ext4
|
|
// filesystem or a per-disk ESP.
|
|
grubTheme := `#!/bin/sh
|
|
exec tail -n +3 $0
|
|
insmod all_video
|
|
insmod font
|
|
if loadfont $prefix/fonts/unicode.pf2; then
|
|
set gfxmode=auto
|
|
insmod gfxterm
|
|
terminal_output gfxterm
|
|
fi
|
|
insmod serial
|
|
if serial --unit=0 --speed=115200 --timeout=1; then
|
|
terminal_input --append serial
|
|
terminal_output --append serial
|
|
fi
|
|
|
|
# --- Branding ---
|
|
insmod png
|
|
set theme=$prefix/theme.txt
|
|
export theme
|
|
`
|
|
if err := os.WriteFile(filepath.Join(mountRoot, "etc/grub.d/06_spinifex"), []byte(grubTheme), 0o755); err != nil {
|
|
return fmt.Errorf("write /etc/grub.d/06_spinifex: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// refreshBootToolNote logs the command an operator needs after replacing a
|
|
// disk. Nothing in the installer can do this for them later.
|
|
func refreshBootToolNote(cfg DiskConfig) {
|
|
if cfg.FS.IsZFS() {
|
|
slog.Info("bootloader installed on every pool member",
|
|
"disks", len(cfg.Disks),
|
|
"after_disk_replacement", "spinifex-boot-tool format <esp> && spinifex-boot-tool init <esp>")
|
|
}
|
|
}
|