mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
feat: headless autoinstall mode via autoinstall.toml on boot media
Scans the USB EFI partition at boot for autoinstall.toml. When enabled = true, the installer runs fully unattended using values from the config file then ejects the USB and reboots — no user interaction needed. - New autoinstall package: TOML config struct, boot-media scanner, install.Config converter, EjectAndReboot - Scanner skips iso9660 mounts so the bundled reference copy of the template never accidentally triggers headless mode - "auto" disk target selects the largest non-removable disk - "auto" NIC target picks the first physical interface - Validates all required fields before touching any disk - main.go: check for autoinstall config before launching TUI
This commit is contained in:
@@ -0,0 +1,408 @@
|
||||
/*
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Package autoinstall scans boot media for an autoinstall.toml and, when
|
||||
// headless mode is enabled, converts it into an install.Config that the
|
||||
// installer can run without any user interaction.
|
||||
package autoinstall
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/installer/install"
|
||||
"github.com/pelletier/go-toml/v2"
|
||||
)
|
||||
|
||||
const configFileName = "autoinstall.toml"
|
||||
|
||||
// fileConfig mirrors the structure of autoinstall.toml.
|
||||
type fileConfig struct {
|
||||
Autoinstall struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
} `toml:"autoinstall"`
|
||||
|
||||
Node struct {
|
||||
Hostname string `toml:"hostname"`
|
||||
Password string `toml:"password"`
|
||||
} `toml:"node"`
|
||||
|
||||
Disk struct {
|
||||
Target string `toml:"target"`
|
||||
} `toml:"disk"`
|
||||
|
||||
Network struct {
|
||||
WAN ifaceConfig `toml:"wan"`
|
||||
LAN ifaceConfig `toml:"lan"`
|
||||
} `toml:"network"`
|
||||
|
||||
Cluster struct {
|
||||
Role string `toml:"role"`
|
||||
JoinAddr string `toml:"join_addr"`
|
||||
} `toml:"cluster"`
|
||||
}
|
||||
|
||||
type ifaceConfig struct {
|
||||
Interface string `toml:"interface"`
|
||||
Mode string `toml:"mode"` // "dhcp" or "static"
|
||||
Address string `toml:"address"` // static only
|
||||
Mask string `toml:"mask"` // static only
|
||||
Gateway string `toml:"gateway"` // static only, WAN only
|
||||
DNS []string `toml:"dns"` // static only
|
||||
}
|
||||
|
||||
// Load scans boot media for an autoinstall.toml. Returns nil if the file is
|
||||
// not found or enabled = false. Returns an error only if a config was found
|
||||
// but could not be parsed or is invalid.
|
||||
func Load() (*install.Config, error) {
|
||||
path, mountpoint, err := findConfigFile()
|
||||
if err != nil {
|
||||
slog.Debug("autoinstall scan failed", "err", err)
|
||||
return nil, nil
|
||||
}
|
||||
if path == "" {
|
||||
slog.Debug("autoinstall: no config found, using interactive mode")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read autoinstall config: %w", err)
|
||||
}
|
||||
|
||||
var fc fileConfig
|
||||
if err := toml.Unmarshal(data, &fc); err != nil {
|
||||
return nil, fmt.Errorf("parse autoinstall config: %w", err)
|
||||
}
|
||||
|
||||
if !fc.Autoinstall.Enabled {
|
||||
slog.Info("autoinstall: config found but enabled = false, using interactive mode")
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
slog.Info("autoinstall: headless mode enabled", "config", path)
|
||||
|
||||
cfg, err := toInstallConfig(fc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("autoinstall config invalid: %w", err)
|
||||
}
|
||||
|
||||
// Persist the mountpoint so EjectAndReboot knows which device to eject.
|
||||
if mountpoint != "" {
|
||||
_ = os.WriteFile("/run/spinifex-autoinstall-src", []byte(mountpoint), 0o600)
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// EjectAndReboot ejects the USB that provided the autoinstall config (if
|
||||
// identifiable) then reboots. Call this after a successful headless install to
|
||||
// prevent the node looping back into the installer on next boot.
|
||||
func EjectAndReboot() {
|
||||
mp, _ := os.ReadFile("/run/spinifex-autoinstall-src")
|
||||
mountpoint := strings.TrimSpace(string(mp))
|
||||
if mountpoint != "" {
|
||||
if dev := deviceForMountpoint(mountpoint); dev != "" {
|
||||
// Walk up from partition (/dev/sdb1) to the whole disk (/dev/sdb).
|
||||
disk := strings.TrimRight(dev, "0123456789")
|
||||
slog.Info("autoinstall: ejecting source device", "disk", disk)
|
||||
_ = exec.Command("eject", disk).Run()
|
||||
}
|
||||
_ = exec.Command("umount", mountpoint).Run()
|
||||
_ = os.Remove(mountpoint)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Installation complete.")
|
||||
fmt.Println("Remove the USB drive now if it was not ejected automatically.")
|
||||
fmt.Println("Rebooting in 10 seconds...")
|
||||
time.Sleep(10 * time.Second)
|
||||
_ = exec.Command("reboot").Run()
|
||||
}
|
||||
|
||||
// findConfigFile returns the path of autoinstall.toml and its mountpoint (if
|
||||
// the scanner had to mount a partition to find it). Skips iso9660 mounts so
|
||||
// the read-only reference copy bundled in the ISO never triggers headless mode.
|
||||
func findConfigFile() (path, mountpoint string, err error) {
|
||||
// Prefer a file on a writable (non-iso9660) already-mounted filesystem.
|
||||
mounts, _ := os.ReadFile("/proc/mounts")
|
||||
for line := range strings.SplitSeq(string(mounts), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
fstype := fields[2]
|
||||
mp := fields[1]
|
||||
// Skip virtual and read-only filesystems.
|
||||
switch fstype {
|
||||
case "iso9660", "squashfs", "tmpfs", "proc", "sysfs", "devtmpfs", "devpts":
|
||||
continue
|
||||
}
|
||||
candidate := filepath.Join(mp, configFileName)
|
||||
if _, statErr := os.Stat(candidate); statErr == nil {
|
||||
return candidate, "", nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to scanning the USB that the ISO was loaded from.
|
||||
srcDev, _ := os.ReadFile("/run/iso-dev")
|
||||
baseDev := strings.TrimSpace(string(srcDev))
|
||||
if baseDev == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
// Strip partition suffix to get the disk name (e.g. /dev/sdb1 → sdb).
|
||||
diskName := filepath.Base(strings.TrimRight(baseDev, "0123456789"))
|
||||
|
||||
partEntries, readErr := os.ReadDir("/sys/block/" + diskName)
|
||||
if readErr != nil {
|
||||
return "", "", nil
|
||||
}
|
||||
for _, pe := range partEntries {
|
||||
partName := pe.Name()
|
||||
if !strings.HasPrefix(partName, diskName) {
|
||||
continue
|
||||
}
|
||||
partDev := "/dev/" + partName
|
||||
|
||||
// Skip if already mounted as iso9660.
|
||||
if mountedAs(partDev) == "iso9660" {
|
||||
continue
|
||||
}
|
||||
|
||||
mp, err := mountReadOnly(partDev)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
candidate := filepath.Join(mp, configFileName)
|
||||
if _, statErr := os.Stat(candidate); statErr == nil {
|
||||
return candidate, mp, nil
|
||||
}
|
||||
|
||||
// Not here — unmount and try next partition.
|
||||
_ = exec.Command("umount", mp).Run()
|
||||
_ = os.Remove(mp)
|
||||
}
|
||||
|
||||
return "", "", nil
|
||||
}
|
||||
|
||||
// mountReadOnly mounts partDev read-only under a temporary directory, trying
|
||||
// vfat (EFI) then ext4 then a generic auto-detect. Returns the mountpoint.
|
||||
func mountReadOnly(partDev string) (string, error) {
|
||||
mp := "/tmp/spinifex-cfg-" + filepath.Base(partDev)
|
||||
if err := os.MkdirAll(mp, 0o700); err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, fstype := range []string{"vfat", "ext4", "auto"} {
|
||||
args := []string{"-t", fstype, "-o", "ro", partDev, mp}
|
||||
if fstype == "auto" {
|
||||
args = []string{"-o", "ro", partDev, mp}
|
||||
}
|
||||
if exec.Command("mount", args...).Run() == nil {
|
||||
return mp, nil
|
||||
}
|
||||
}
|
||||
_ = os.Remove(mp)
|
||||
return "", fmt.Errorf("could not mount %s", partDev)
|
||||
}
|
||||
|
||||
// mountedAs returns the filesystem type that partDev is currently mounted with,
|
||||
// or an empty string if it is not mounted.
|
||||
func mountedAs(partDev string) string {
|
||||
data, _ := os.ReadFile("/proc/mounts")
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 3 && fields[0] == partDev {
|
||||
return fields[2]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// deviceForMountpoint returns the block device mounted at mp, or empty string.
|
||||
func deviceForMountpoint(mp string) string {
|
||||
data, _ := os.ReadFile("/proc/mounts")
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 && fields[1] == mp {
|
||||
return fields[0]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// toInstallConfig converts a parsed fileConfig into an install.Config,
|
||||
// applying defaults and validating required fields.
|
||||
func toInstallConfig(fc fileConfig) (*install.Config, error) {
|
||||
if fc.Node.Password == "" {
|
||||
return nil, fmt.Errorf("node.password is required")
|
||||
}
|
||||
|
||||
hostname := fc.Node.Hostname
|
||||
if hostname == "" {
|
||||
hostname = "spinifex-node"
|
||||
}
|
||||
|
||||
disk, err := resolveDisk(fc.Disk.Target)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("disk: %w", err)
|
||||
}
|
||||
|
||||
wanNIC, err := resolveNIC(fc.Network.WAN.Interface, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("network.wan.interface: %w", err)
|
||||
}
|
||||
|
||||
cfg := &install.Config{
|
||||
Disk: disk,
|
||||
Hostname: hostname,
|
||||
RootPassword: fc.Node.Password,
|
||||
WANInterface: wanNIC,
|
||||
WANDHCPMode: strings.ToLower(fc.Network.WAN.Mode) != "static",
|
||||
}
|
||||
|
||||
if !cfg.WANDHCPMode {
|
||||
if fc.Network.WAN.Address == "" || fc.Network.WAN.Mask == "" || fc.Network.WAN.Gateway == "" {
|
||||
return nil, fmt.Errorf("network.wan: address, mask, and gateway are required for mode = \"static\"")
|
||||
}
|
||||
cfg.WANAddress = fc.Network.WAN.Address
|
||||
cfg.WANMask = fc.Network.WAN.Mask
|
||||
cfg.WANGateway = fc.Network.WAN.Gateway
|
||||
cfg.WANDNS = fc.Network.WAN.DNS
|
||||
}
|
||||
|
||||
// LAN is optional — only configured when interface is specified.
|
||||
if fc.Network.LAN.Interface != "" {
|
||||
lanNIC, err := resolveNIC(fc.Network.LAN.Interface, wanNIC)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("network.lan.interface: %w", err)
|
||||
}
|
||||
cfg.LANInterface = lanNIC
|
||||
cfg.LANDHCPMode = strings.ToLower(fc.Network.LAN.Mode) != "static"
|
||||
if !cfg.LANDHCPMode {
|
||||
if fc.Network.LAN.Address == "" || fc.Network.LAN.Mask == "" {
|
||||
return nil, fmt.Errorf("network.lan: address and mask are required for mode = \"static\"")
|
||||
}
|
||||
cfg.LANAddress = fc.Network.LAN.Address
|
||||
cfg.LANMask = fc.Network.LAN.Mask
|
||||
cfg.LANDNS = fc.Network.LAN.DNS
|
||||
}
|
||||
}
|
||||
|
||||
role := strings.ToLower(fc.Cluster.Role)
|
||||
if role == "" {
|
||||
role = "init"
|
||||
}
|
||||
cfg.ClusterRole = role
|
||||
if role == "join" {
|
||||
if fc.Cluster.JoinAddr == "" {
|
||||
return nil, fmt.Errorf("cluster.join_addr is required when role = \"join\"")
|
||||
}
|
||||
cfg.JoinAddr = fc.Cluster.JoinAddr
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// resolveDisk returns the block device path to install onto. "auto" or empty
|
||||
// selects the largest non-removable, non-optical disk.
|
||||
func resolveDisk(target string) (string, error) {
|
||||
if target != "" && target != "auto" {
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
return "", fmt.Errorf("%q not found", target)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
return largestNonRemovableDisk()
|
||||
}
|
||||
|
||||
func largestNonRemovableDisk() (string, error) {
|
||||
entries, err := os.ReadDir("/sys/block")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
dev string
|
||||
bytes int64
|
||||
}
|
||||
var candidates []candidate
|
||||
|
||||
for _, e := range entries {
|
||||
dev := e.Name()
|
||||
// Skip virtual/optical devices.
|
||||
switch {
|
||||
case strings.HasPrefix(dev, "loop"),
|
||||
strings.HasPrefix(dev, "ram"),
|
||||
strings.HasPrefix(dev, "dm-"),
|
||||
strings.HasPrefix(dev, "sr"):
|
||||
continue
|
||||
}
|
||||
removable, _ := os.ReadFile("/sys/block/" + dev + "/removable")
|
||||
if strings.TrimSpace(string(removable)) == "1" {
|
||||
continue
|
||||
}
|
||||
sizeRaw, _ := os.ReadFile("/sys/block/" + dev + "/size")
|
||||
sectors, err := strconv.ParseInt(strings.TrimSpace(string(sizeRaw)), 10, 64)
|
||||
if err != nil || sectors == 0 {
|
||||
continue
|
||||
}
|
||||
candidates = append(candidates, candidate{dev: dev, bytes: sectors * 512})
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return "", fmt.Errorf("no suitable disk found (all disks are removable or virtual)")
|
||||
}
|
||||
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].bytes > candidates[j].bytes
|
||||
})
|
||||
slog.Info("autoinstall: selected target disk", "disk", candidates[0].dev, "size_gb",
|
||||
candidates[0].bytes>>30)
|
||||
return "/dev/" + candidates[0].dev, nil
|
||||
}
|
||||
|
||||
// resolveNIC returns the interface name to use. "auto" or empty picks the
|
||||
// first physical (non-loopback) interface that is not exclude.
|
||||
func resolveNIC(name, exclude string) (string, error) {
|
||||
if name != "" && name != "auto" {
|
||||
return name, nil
|
||||
}
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
if iface.Name == exclude {
|
||||
continue
|
||||
}
|
||||
return iface.Name, nil
|
||||
}
|
||||
return "", fmt.Errorf("no suitable NIC found")
|
||||
}
|
||||
@@ -21,11 +21,28 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/installer/autoinstall"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/install"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/ui"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Check for a headless autoinstall config on the boot media before
|
||||
// launching the interactive TUI. If found and enabled, run without
|
||||
// any user input then eject the USB and reboot.
|
||||
if cfg, err := autoinstall.Load(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "autoinstall: %v\n", err)
|
||||
os.Exit(1)
|
||||
} else if cfg != nil {
|
||||
if err := install.Run(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
autoinstall.EjectAndReboot()
|
||||
return
|
||||
}
|
||||
|
||||
// Normal interactive path.
|
||||
ttyPath := detectTTY()
|
||||
|
||||
cfg, err := ui.Run(ttyPath)
|
||||
|
||||
Reference in New Issue
Block a user