Files
spinifex/cmd/installer/firstboot/firstboot.go
T
tomnewton-mulga 03601ca89b fix: banner service ordering, getty wait, motd append (mulga-866)
- banner runs After=spinifex.target so services are settled before banner writes
- switch systemctl restart → try-restart (safe if target not yet active)
- append banner to /etc/motd with sentinel instead of overwriting, preserving
  the Debian disclaimer; sentinel ensures re-runs replace cleanly
- remove /etc/issue write — getty shows it pre-login which is wrong
- add getty@.service.d drop-in so all getty instances wait for banner before
  showing login prompt; prevents service messages scrolling during login
- fix Makefile to prefer locally built spx/installer binaries over system PATH
2026-04-09 15:40:26 +10:00

194 lines
6.2 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"
)
// Config holds the values the firstboot service needs to configure the node.
type Config struct {
Hostname string
OVNInterface string
ManagementIP string
// ClusterRole is "init" or "join".
ClusterRole string
// JoinAddr is host:port of the primary node, only used when ClusterRole is "join".
JoinAddr string
}
// 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 := writeUnit(root); err != nil {
return fmt.Errorf("firstboot unit: %w", err)
}
if err := enableUnit(root); err != nil {
return err
}
if err := writeBannerUnit(root); err != nil {
return fmt.Errorf("banner unit: %w", err)
}
if err := enableBannerUnit(root); err != nil {
return err
}
return writeGettyDropIn(root)
}
func writeScript(root string, cfg Config) error {
clusterCmd := buildClusterCmd(cfg)
script := fmt.Sprintf(`#!/bin/bash
# Spinifex firstboot — runs once after ISO installation, then disables itself.
set -euo pipefail
# Set hostname
hostnamectl set-hostname %s
# Configure OVN networking.
# --macvlan creates a virtual sub-interface (spx-ext-<NIC>) off the management
# NIC so OVN gets its own L2 path without stealing the host IP (SSH-safe).
/usr/local/bin/spinifex-setup-ovn.sh \
--management \
--macvlan \
--wan-iface=%s \
--encap-ip=%s
# Cluster formation — capture credentials to file for display on console.
%s 2>&1 | tee /root/spinifex-credentials.txt
chmod 600 /root/spinifex-credentials.txt
# Fix ownership: spx admin init runs as root (no SUDO_USER in systemd context)
# so config and data files are created as root:root. Hand them to the service
# user so systemd units running as spinifex can read them.
chown -R spinifex:spinifex /etc/spinifex /var/lib/spinifex
# Start services
systemctl start spinifex.target
# Disable this service so it never runs again
systemctl disable spinifex-firstboot.service
`, cfg.Hostname, cfg.OVNInterface, cfg.ManagementIP, clusterCmd)
path := filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh")
return os.WriteFile(path, []byte(script), 0o755)
}
func writeUnit(root string) error {
unit := `[Unit]
Description=Spinifex first-boot provisioning
After=network-online.target
Wants=network-online.target
ConditionPathExists=/usr/local/bin/spinifex-firstboot.sh
[Service]
Type=oneshot
Environment=HOME=/root
ExecStart=/usr/local/bin/spinifex-firstboot.sh
RemainAfterExit=yes
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
`
path := filepath.Join(root, "etc/systemd/system/spinifex-firstboot.service")
return os.WriteFile(path, []byte(unit), 0o644)
}
func enableUnit(root string) error {
wantsDir := filepath.Join(root, "etc/systemd/system/multi-user.target.wants")
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
return err
}
link := filepath.Join(wantsDir, "spinifex-firstboot.service")
target := "/etc/systemd/system/spinifex-firstboot.service"
// Remove stale symlink if present.
_ = os.Remove(link)
return os.Symlink(target, link)
}
func buildClusterCmd(cfg Config) string {
switch cfg.ClusterRole {
case "join":
return fmt.Sprintf("spx admin join --node %s --host %s", cfg.Hostname, cfg.JoinAddr)
default:
return fmt.Sprintf("spx admin init --node %s --nodes 1", cfg.Hostname)
}
}
// writeBannerUnit writes the spinifex-banner.service unit that runs
// `spx admin banner --boot-check` on every boot after spinifex.target is up.
// Running After=spinifex.target ensures the banner reflects a settled system
// state and that the IP check/restart happens once services are already running.
func writeBannerUnit(root string) error {
unit := `[Unit]
Description=Spinifex console banner and boot health check
After=spinifex.target
Wants=spinifex.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/spx admin banner --boot-check
RemainAfterExit=yes
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
`
path := filepath.Join(root, "etc/systemd/system/spinifex-banner.service")
return os.WriteFile(path, []byte(unit), 0o644)
}
func enableBannerUnit(root string) error {
wantsDir := filepath.Join(root, "etc/systemd/system/multi-user.target.wants")
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
return err
}
link := filepath.Join(wantsDir, "spinifex-banner.service")
target := "/etc/systemd/system/spinifex-banner.service"
_ = os.Remove(link)
return os.Symlink(target, link)
}
// writeGettyDropIn makes all getty instances (tty1, ttyS0, etc.) wait for
// spinifex-banner.service before showing the login prompt. This ensures:
// - All spinifex services have settled (banner runs After=spinifex.target)
// - The /etc/motd banner is written before the user sees the login prompt
// - Service startup messages finish scrolling before the prompt appears
func writeGettyDropIn(root string) error {
dropIn := `[Unit]
After=spinifex-banner.service
Wants=spinifex-banner.service
`
// Drop-in on getty@.service applies to all instances: tty1, ttyS0, etc.
dropInDir := filepath.Join(root, "etc/systemd/system/getty@.service.d")
if err := os.MkdirAll(dropInDir, 0o755); err != nil {
return err
}
return os.WriteFile(filepath.Join(dropInDir, "spinifex-wait.conf"), []byte(dropIn), 0o644)
}