mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-19 10:52:04 +00:00
dad5d702e1
* fix(lbagent): stop heartbeating a load balancer once it is confirmed deleted A LoadBalancerNotFound response is the gateway's positive confirmation that the store has no record for the LB, not a connectivity failure — unlike a dial error, timeout, or 5xx, which leave its existence unknown and must keep retrying. Past the startup registration grace window (or after any prior successful heartbeat), it can no longer be the record's own replication race, so the agent now stops rather than retrying forever and filling the log with heartbeat noise after teardown. * fix(nats): enable the JetStream ENOSPC-latch watchdog timer nats-server disables JetStream process-wide on ENOSPC (server/jetstream.go handleOutOfSpace -> ShutdownJetStream) and never re-enables it itself — that decision is nats-server's own internal state, not spinifex's, so a restart is the only way back. The spinifex-nats-watchdog.timer + service already probe /healthz?js-enabled-only=true and try-restart on a sustained failure, but the timer's WantedBy=timers.target never took effect: neither setup.sh nor firstboot enabled it, so the watchdog shipped inert and a full disk needed a manual restart forever. Enable --now it alongside the rest of the unit set.
264 lines
11 KiB
Go
264 lines
11 KiB
Go
// 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
|
|
// InstallCallback, when non-empty, is curled once at the end of firstboot
|
|
// after the success marker is written. Generic phone-home hook for
|
|
// provisioning controllers (PXE/MAAS-style flows). Best-effort: non-2xx
|
|
// and network failures are swallowed so the install is not gated on
|
|
// controller reachability.
|
|
InstallCallback string
|
|
// SkipFormation skips spx admin init/join. Used when a provisioning
|
|
// controller (e.g. bm-bootstrap.sh) owns cluster formation and will call
|
|
// spx admin init/join itself with the correct multi-node parameters.
|
|
SkipFormation 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)
|
|
}
|
|
|
|
// 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.
|
|
ovnPrestart := `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`
|
|
|
|
// When a provisioning controller owns formation (SkipFormation) it also owns
|
|
// the clustered OVN bring-up via setup-ovn.sh's RAFT flags. firstboot must
|
|
// not start a standalone ovn-central here: the single-node .db it would leave
|
|
// blocks ovn-ctl's create/join (which require a clean DB). Defer both.
|
|
if cfg.SkipFormation {
|
|
ovnPrestart = `echo "[firstboot] OVN bring-up deferred to provisioning controller"`
|
|
setupOVN = `echo "[firstboot] setup-ovn deferred to provisioning controller"`
|
|
}
|
|
|
|
callbackBlock := ""
|
|
if cfg.InstallCallback != "" {
|
|
callbackBlock = fmt.Sprintf(
|
|
"\ncurl -fsS --max-time 10 --retry 2 --retry-delay 2 -o /dev/null %s || true\n",
|
|
shellEscapeSingle(cfg.InstallCallback),
|
|
)
|
|
}
|
|
|
|
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
|
|
|
|
%s
|
|
|
|
# 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
|
|
# WantedBy=timers.target only self-activates once enabled — without this the
|
|
# JetStream ENOSPC-latch watchdog never runs and a full disk needs a manual restart.
|
|
systemctl enable --now spinifex-nats-watchdog.timer
|
|
|
|
# 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"
|
|
%s
|
|
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, ovnPrestart, setupOVN, clusterCmd, callbackBlock)
|
|
|
|
path := filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh")
|
|
return os.WriteFile(path, []byte(script), 0o755)
|
|
}
|
|
|
|
func buildClusterCmd(cfg Config) string {
|
|
if cfg.SkipFormation {
|
|
return `echo "[firstboot] cluster formation skipped — provisioning controller will handle it"`
|
|
}
|
|
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, "'", `'\''`) + "'"
|
|
}
|