Files
spinifex/cmd/installer/firstboot/firstboot.go
T
tomnewton-mulga 28ca689130 feat: VFIO GPU passthrough (#188)
* feat: add GPU discovery package (mulga-992)

Adds spinifex/gpu package with sysfs-based GPU enumeration, IOMMU group
member lookup, and a PCI device ID model table. Vendor-specific enrichment
via nvidia-smi is attempted opportunistically and falls back to the built-in
table when unavailable. All discovery and IOMMU logic is covered by unit tests
using mock sysfs trees.

* feat: add VFIO bind/unbind and GPU pool manager (mulga-993)

Adds gpu.BindVFIO/UnbindVFIO using sysfs driver_override for per-device
driver switching, and gpu.Manager which owns the claim/release lifecycle
for the GPU pool including full IOMMU group binding, rollback on partial
failure, and marking entries unavailable when rebind fails after VM
termination.

* feat: add G5 instance type generator with GPU model support (mulga-994)

- Add GPUModel type, NVIDIAa10g constant, and GPUModelForVendorDevice lookup
- Add generateGPUTypes to emit g5.* types with GpuInfo populated
- Add IsGPUType helper for consumers filtering GPU-capable types
- Update DetectAndGenerate to accept gpuModels []GPUModel parameter

* feat: integrate GPU passthrough into daemon lifecycle (mulga-995)

- Add gpu_passthrough config flag; discover GPUs and build manager at startup
- Gate g5 instance types on GPU pool availability via canAllocateCount
- Claim GPU (VFIO bind) in LaunchInstance; inject vfio-pci QEMU device
- Release GPU (VFIO unbind, rebind to host driver) in stopInstance
- Re-claim GPU on daemon restart when QEMU process is still alive
- Add UseUEFI / OVMF support for x86_64 VMs (optional, falls back to SeaBIOS)
- Fix Claim to use stored OriginalDriver when device is already vfio-pci bound

* feat: add GPU fields to NodeStatus and spx top nodes (mulga-996)

- Add TotalGPUs/AllocGPUs to NodeStatusResponse (zero on non-GPU nodes)
- Populate from gpuManager in handleNodeStatus
- Show GPU (used/total) column in spx top nodes; "-" when no GPUs present

* feat: Ubuntu 24.04 GPU AMI build pipeline (mulga-997)

Extends build-system-image.sh to support Ubuntu alongside Alpine.
Key differences handled: partition table (parted resizepart p1),
bind mounts for DKMS (/proc /sys /dev), apt vs apk, systemctl vs
rc-update. DISTRO defaults to "alpine" for backward compatibility.

Adds ubuntu-gpu.conf manifest and ubuntu-gpu-setup.sh chroot script
that installs nvidia-headless-550-server with DKMS pre-built against
a pinned linux-image-generic kernel, blacklists nouveau, and prevents
unattended-upgrades from invalidating the pre-built module.

* Fixed GPU passthrough and added test script

* feat: always-probe GPU at startup, surface capability in NodeStatusResponse

- Add gpuProbeResult struct and probeGPU() which runs at daemon startup
  regardless of gpu_passthrough config. Checks sysfs for GPUs, IOMMU
  groups, and vfio_pci module presence.
- Store gpuProbe on Daemon struct; split NewDaemon into probe (always)
  and activate (opt-in) phases.
- Populate GPUCapable, GPUPassthrough, GPUModels on NodeStatusResponse
  so operators can see hardware state before enabling passthrough.
- Update 'spx top' GPU column: '-' = no hardware, '0/N*' = capable but
  passthrough not active, 'used/total' = passthrough active.

* feat: SIGHUP hot-reload for GPU passthrough config (mulga-1001)

- Export GenerateGPUTypes for use by the reload path
- Add ResourceManager.reloadGPUTypes: mutates instanceTypes map in-place
  (preserves references held by instanceService) and updates NATS
  subscriptions without full daemon restart
- Separate SIGHUP from SIGTERM/SIGINT: setupReload() listens for SIGHUP
  in a context-aware loop; setupShutdown no longer intercepts SIGHUP
- Add reloadConfig() + applyGPUConfig(): re-reads spinifex.toml on signal,
  handles false→true (re-probe + activate) and true→false (refuse if
  instances running, otherwise deactivate) transitions

* feat: spx admin gpu status/enable/disable commands (mulga-1000)

- admin.SetGPUPassthrough: Go implementation of the idempotent TOML write
  (replaces the inline Python heredoc in spx-test-gpu.sh)
- spx admin gpu status: shows GPU hardware, IOMMU/vfio state, passthrough
  activation, and advertised g5 instance types via NATS NodeStatusResponse
- spx admin gpu enable: checks prerequisites, writes TOML, sends SIGHUP,
  polls until daemon confirms GPUPassthrough=true
- spx admin gpu disable: refuses while GPU instances are running, otherwise
  writes TOML, sends SIGHUP, polls until confirmed
- spx-test-gpu.sh: replace Python TOML heredoc with spx admin gpu enable

* fixed admin

* fix: filter Intel iGPU from passthrough pool, use lspci for consumer GPU names

Discover() now skips non-NVIDIA/AMD vendors so Intel iGPUs never enter
the passthrough pool. enrichFromLspci() uses the system PCI ID database
via lspci -vmm to populate the model name for any card not in the
hardcoded table, without needing nvidia-smi or driver tools.

* fix: grant CAP_SYS_ADMIN and vfio device access to spinifex-daemon

GPU passthrough requires writing to /sys/bus/pci/drivers/vfio-pci/unbind
on instance stop. The kernel gates this on CAP_SYS_ADMIN regardless of
file permissions. QEMU subprocesses also need char-vfio rw access to
open /dev/vfio/N for the passed-through device.

* fixing gpu bining permissions

* working state of gpu passthrough on phyiscal box

* installer grub image fixes

* made gpu-passthrough more user facing

* fixed banner

* fixed banner

* banner fix

* gpu passthrough docs

* preflight tweak

* vpcd fixes to set br-wan to promisc for public IP DHCP processes

* fixed vpcd br-wan promisc and veth pair

* ssh keygen fix

* fixes after running /review-branch

* feat: re-wire GPU claim and device injection after vm.Manager refactor

GPU passthrough claim (VFIO bind) and QEMU device injection were lost
when dev's vm.Manager refactor (#185/#186) moved StartInstance/
CreateQMPClient into vm/lifecycle.go. Re-add both in their correct
new homes:

- daemon_handlers_instance.go: claim GPU before vmMgr.Run() in both
  RunInstances and StartStoppedInstance handlers; release on failure
- vm/lifecycle.go startQEMU(): inject vfio-pci device with x-vga flag
  derived from instance.GPUXVGAEnabled when GPUPCIAddress is set

* fix: Create spinifex user ~/.ssh and keys in firstboot

---------

Co-authored-by: Ben Duncan <5178243+benduncan@users.noreply.github.com>
Co-authored-by: Julian Sommer <115516918+juliansommer@users.noreply.github.com>
2026-05-06 09:52:23 +10:00

246 lines
10 KiB
Go

/*
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 firstboot writes the oneshot systemd service and configuration that
// completes Spinifex provisioning on the first real boot after installation.
package firstboot
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/mulgadc/spinifex/cmd/installer/systemd"
)
// Config holds the values the firstboot service needs to configure the node.
type Config struct {
Hostname string
// EncapIP is the Geneve tunnel IP for OVN. Set to the LAN bridge IP when a
// dedicated LAN NIC is present, otherwise the WAN bridge IP. Empty when DHCP
// is used — setup-ovn.sh auto-detects the IP from the default route in that case.
EncapIP string
// ClusterRole is "init" or "join".
ClusterRole string
// JoinAddr is host:port of the primary node, only used when ClusterRole is "join".
JoinAddr string
// Email is the operator email collected by the TUI or SPINIFEX_EMAIL on
// the headless path. Passed to `spx admin init --email=<value>` when set;
// omitted entirely when empty.
Email string
// GPUPassthrough enables VFIO GPU passthrough by passing --gpu-passthrough
// to `spx admin init`, which writes gpu_passthrough = true in the daemon config.
GPUPassthrough bool
}
// Write drops the firstboot script and systemd unit into root, which should be
// the path of the installed system's root filesystem (e.g. /mnt/spinifex-install).
func Write(root string, cfg Config) error {
if err := writeScript(root, cfg); err != nil {
return fmt.Errorf("firstboot script: %w", err)
}
if err := systemd.WriteFirstbootUnit(root); err != nil {
return fmt.Errorf("firstboot unit: %w", err)
}
if err := systemd.EnableUnit(root, "spinifex-firstboot.service"); err != nil {
return err
}
// Banner unit is written but NOT enabled here. firstboot enables it
// (along with spinifex.target) at the end of its run, so on the first
// boot the only spinifex unit running is firstboot itself — no implicit
// pull of spinifex.target via the banner's Wants= relationship, which
// was the root cause of the boot-time race against firstboot.
if err := systemd.WriteBannerUnit(root); err != nil {
return fmt.Errorf("banner unit: %w", err)
}
return systemd.WriteGettyDropIn(root)
}
func writeScript(root string, cfg Config) error {
clusterCmd := buildClusterCmd(cfg)
// --encap-ip is optional: when DHCP is used the IP is unknown at install time
// and setup-ovn.sh auto-detects it from the default route at boot.
setupOVN := "/usr/local/bin/setup-ovn.sh --management"
if cfg.EncapIP != "" {
setupOVN += fmt.Sprintf(" --encap-ip=%s", cfg.EncapIP)
}
script := fmt.Sprintf(`#!/bin/bash
# Spinifex firstboot — runs once after ISO installation. On success, writes
# /var/lib/spinifex/.firstboot-done and the systemd unit's
# ConditionPathExists=! prevents re-execution on subsequent boots. A partial
# run leaves no marker, so the next reboot retries from the top — safe
# because every step below is idempotent (hostnamectl, setup-ovn.sh, spx
# admin init are all "set if not set"; systemctl enable on an already-enabled
# unit is a no-op).
set -euo pipefail
DONE_MARKER=/var/lib/spinifex/.firstboot-done
# Idempotency: bail early if a previous run completed successfully. The unit
# also has ConditionPathExists=!$DONE_MARKER so we shouldn't get here on
# subsequent boots, but defend in depth in case the unit is re-triggered
# manually before the operator notices the marker exists.
if [ -f "$DONE_MARKER" ]; then
echo "[firstboot] already complete — skipping"
exit 0
fi
# Set hostname
hostnamectl set-hostname %s
# Pre-start OVS and OVN central so their databases are initialised before
# setup-ovn.sh runs. On physical hardware, first-boot DB initialisation takes
# longer than setup-ovn.sh's internal 15-second timeout allows. Starting them
# here and waiting until the NB DB is ready means setup-ovn.sh sees a live DB
# the moment it starts — no races, no timeout failures.
systemctl start openvswitch-switch
systemctl start ovn-central
echo "Waiting for OVN NB DB to initialise..."
for _i in $(seq 1 120); do
if ovn-nbctl --timeout=2 get-connection >/dev/null 2>&1; then
echo "OVN NB DB ready (${_i}s)"
break
fi
sleep 1
done
# Create default nameservers if DHCP server returns blank
printf "nameserver 1.1.1.1\nnameserver 8.8.8.8\n" > /etc/resolvconf/resolv.conf.d/base
resolvconf -u
# Configure OVN networking.
# br-wan (and br-lan if present) are Linux bridges managed by systemd-networkd
# (declared in /etc/systemd/network/ by the installer). setup-ovn.sh
# auto-detects br-wan as the default route device and wires it to OVS via a
# veth pair — non-destructive, SSH-safe.
%s
# Write the banner (first time, spinifex-banner service will do this on reboot)
/usr/local/bin/spx admin banner --boot-check
# Cluster formation — capture credentials to file for display on console.
%s 2>&1
# Fix ownership of files spx admin init wrote. spx runs as root under
# systemd, so /etc/spinifex/{spinifex.toml,master.key,ca.key,*.pem} and any
# per-service files written under /var/lib/spinifex/* land as root:root.
# Delegate to setup.sh's fix_file_ownership (single source of truth) — it
# knows the per-file modes (ca.key 0600, *.pem 0644, toml/key 0640) that a
# blunt recursive chmod would clobber.
SETUP_STAGES=fixown /usr/local/share/spinifex/setup.sh
# The /var/lib/spinifex/awsgw/config symlink was created at build time by
# setup.sh's create_directories — it resolves to /etc/spinifex, so
# {BaseDir}/config/master.key automatically points at /etc/spinifex/master.key.
# Bootstrap the spinifex user's SSH directory with a generated key pair so
# ~/.ssh exists and the operator can immediately use ssh-keygen / ssh-copy-id
# without hitting "No such file or directory" errors documented in the setup guide.
if [ ! -f /home/spinifex/.ssh/id_ed25519 ]; then
mkdir -p /home/spinifex/.ssh
chmod 700 /home/spinifex/.ssh
ssh-keygen -t ed25519 -f /home/spinifex/.ssh/id_ed25519 -N "" -C "spinifex@$(hostname)"
chown -R spinifex:spinifex /home/spinifex/.ssh
chmod 600 /home/spinifex/.ssh/id_ed25519
chmod 644 /home/spinifex/.ssh/id_ed25519.pub
fi
# Copy AWS credentials to the spinifex user's home directory.
# spx admin init runs with HOME=/root (set by the systemd unit), so credentials
# land in /root/.aws/. Copy them to the spinifex user's home so the operator
# can use the AWS CLI without sudo.
if [ -f /root/.aws/credentials ]; then
mkdir -p /home/spinifex/.aws
cp /root/.aws/credentials /home/spinifex/.aws/credentials
cp /root/.aws/config /home/spinifex/.aws/config 2>/dev/null || true
chown -R spinifex:spinifex /home/spinifex/.aws
chmod 700 /home/spinifex/.aws
chmod 600 /home/spinifex/.aws/credentials
[ -f /home/spinifex/.aws/config ] && chmod 600 /home/spinifex/.aws/config
fi
# Enable + activate spinifex.target and the banner now that all configs are
# in place. "enable --now" creates the multi-user.target.wants/ symlinks (so
# they start directly on every subsequent boot — no longer dependent on
# firstboot running each time, since firstboot is condition-skipped after
# this run via the marker) and activates them in this boot.
# NOTE: Moved to firstinstall to enable
#systemctl enable --now spinifex.target spinifex-banner.service
# Enable services to start, on reboot
systemctl enable spinifex.target spinifex-banner.service
# Mark complete only after every step above has succeeded. Until this point,
# any failure (set -e) leaves the marker absent and the next reboot retries
# firstboot from the top.
mkdir -p "$(dirname "$DONE_MARKER")"
touch "$DONE_MARKER"
systemctl start spinifex.target
# Wait for the daemon to bring up external networking. When external_mode=pool
# is configured the daemon creates br-ext (the OVN external bridge) during
# startup; launching instances before it is ready results in no public IP being
# assigned. ip link requires no root and avoids parsing daemon logs.
if grep -q 'external_mode.*pool' /etc/spinifex/spinifex.toml 2>/dev/null; then
echo "[firstboot] waiting for external networking (br-ext)..."
for _i in $(seq 1 30); do
if ip link show br-ext >/dev/null 2>&1; then
echo "[firstboot] br-ext ready (${_i}s)"
break
fi
sleep 1
done
if ! ip link show br-ext >/dev/null 2>&1; then
echo "[firstboot] warning: br-ext not up after 30s — external networking may be delayed"
fi
fi
`, cfg.Hostname, setupOVN, clusterCmd)
path := filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh")
return os.WriteFile(path, []byte(script), 0o755)
}
func buildClusterCmd(cfg Config) string {
emailFlag := ""
if cfg.Email != "" {
// shellEscapeSingle keeps the email safe if it ever contains a
// character shell treats specially — belt-and-braces; the regex
// validator already rejects whitespace and @-chains.
emailFlag = " --email=" + shellEscapeSingle(cfg.Email)
}
gpuFlag := ""
if cfg.GPUPassthrough {
gpuFlag = " --gpu-passthrough"
}
switch cfg.ClusterRole {
case "join":
return fmt.Sprintf("spx admin join --node %s --host %s%s", cfg.Hostname, cfg.JoinAddr, emailFlag)
default:
return fmt.Sprintf("spx admin init --node %s --nodes 1%s%s", cfg.Hostname, emailFlag, gpuFlag)
}
}
// shellEscapeSingle wraps s in single quotes with any embedded single
// quotes escaped. Minimal — we only need this because the email value is
// interpolated into a shell script written by Write().
func shellEscapeSingle(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}