refactor: move systemd unit files into installer/systemd package

Extract all inline systemd unit and drop-in content from install.go and
firstboot.go into a dedicated cmd/installer/systemd package for editability.
Bash scripts remain inline in firstboot.go.

Also copies the GRUB splash image from the live ISO into the installed
system's /boot/grub/ and sets GRUB_BACKGROUND so the branded background
appears in the post-install GRUB, not just the installer GRUB.
This commit is contained in:
tomnewton-mulga
2026-04-15 15:34:40 +10:00
parent 24429ee8b5
commit 054485e930
3 changed files with 202 additions and 146 deletions
+7 -100
View File
@@ -23,6 +23,8 @@ import (
"fmt"
"os"
"path/filepath"
"github.com/mulgadc/spinifex/cmd/installer/systemd"
)
// Config holds the values the firstboot service needs to configure the node.
@@ -44,19 +46,19 @@ 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 {
if err := systemd.WriteFirstbootUnit(root); err != nil {
return fmt.Errorf("firstboot unit: %w", err)
}
if err := enableUnit(root); err != nil {
if err := systemd.EnableUnit(root, "spinifex-firstboot.service"); err != nil {
return err
}
if err := writeBannerUnit(root); err != nil {
if err := systemd.WriteBannerUnit(root); err != nil {
return fmt.Errorf("banner unit: %w", err)
}
if err := enableBannerUnit(root); err != nil {
if err := systemd.EnableUnit(root, "spinifex-banner.service"); err != nil {
return err
}
return writeGettyDropIn(root)
return systemd.WriteGettyDropIn(root)
}
func writeScript(root string, cfg Config) error {
@@ -137,45 +139,6 @@ systemctl start spinifex.target
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 enableSystemdUnit(root, serviceName 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, serviceName)
target := "/etc/systemd/system/" + serviceName
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove stale symlink %s: %w", link, err)
}
return os.Symlink(target, link)
}
func enableUnit(root string) error {
return enableSystemdUnit(root, "spinifex-firstboot.service")
}
func buildClusterCmd(cfg Config) string {
switch cfg.ClusterRole {
case "join":
@@ -184,59 +147,3 @@ func buildClusterCmd(cfg Config) string {
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 {
return enableSystemdUnit(root, "spinifex-banner.service")
}
// writeGettyDropIn holds the primary consoles (tty1 and ttyS0) until
// spinifex-banner.service completes, so the MOTD banner is visible before the
// login prompt appears. The default getty ExecStart is left intact — the user
// is prompted for their password normally.
//
// Drop-ins are written to instance-specific directories (getty@tty1.service.d
// and serial-getty@ttyS0.service.d) rather than the template directories
// (getty@.service.d / serial-getty@.service.d). This is deliberate: scoping
// to named instances leaves tty2, tty3, etc. unaffected so they start
// immediately and are always available as a rescue terminal — even when
// spinifex.target or the banner service is stuck.
func writeGettyDropIn(root string) error {
dropIn := `[Unit]
After=spinifex-banner.service
Wants=spinifex-banner.service
`
for _, svc := range []string{"getty@tty1", "serial-getty@ttyS0"} {
dropInDir := filepath.Join(root, "etc/systemd/system/"+svc+".service.d")
if err := os.MkdirAll(dropInDir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dropInDir, "spinifex-wait.conf"), []byte(dropIn), 0o644); err != nil {
return err
}
}
return nil
}
+37 -46
View File
@@ -19,6 +19,7 @@ package install
import (
"fmt"
"io"
"log/slog"
"os"
"os/exec"
@@ -27,6 +28,7 @@ import (
"syscall"
"github.com/mulgadc/spinifex/cmd/installer/firstboot"
"github.com/mulgadc/spinifex/cmd/installer/systemd"
)
const (
@@ -245,16 +247,7 @@ func installSpinifex(cfg *Config) error {
return err
}
// networking.service drop-in: treat exit code 1 as success so that a
// secondary interface failure (e.g. br-lan DHCP timeout when no cable is
// plugged in) does not block network-online.target and therefore
// spinifex-firstboot.service.
netDropInDir := filepath.Join(mountRoot, "etc/systemd/system/networking.service.d")
if err := os.MkdirAll(netDropInDir, 0o755); err != nil {
return err
}
netDropIn := "[Service]\nSuccessExitStatus=0 1\n"
return os.WriteFile(filepath.Join(netDropInDir, "spinifex-optional-ifaces.conf"), []byte(netDropIn), 0o644)
return systemd.WriteNetworkingDropIn(mountRoot)
}
func writeNetworkConfig(cfg *Config) error {
@@ -319,7 +312,7 @@ func writeNetworkConfig(cfg *Config) error {
// Write a non-critical systemd unit for br-lan so it comes up after
// network-online.target without blocking the boot path.
if cfg.LANInterface != "" {
if err := writeLANBridgeUnit(mountRoot); err != nil {
if err := systemd.WriteLANBridgeUnit(mountRoot); err != nil {
return fmt.Errorf("lan bridge unit: %w", err)
}
}
@@ -369,41 +362,6 @@ func writeNetworkConfig(cfg *Config) error {
return nil
}
// writeLANBridgeUnit installs a non-critical oneshot service that brings up
// br-lan *after* network-online.target. This keeps br-lan out of
// networking.service entirely — a missing LAN cable or DHCP timeout on the
// secondary bridge can never stall the management interface or firstboot.
func writeLANBridgeUnit(root string) error {
unit := `[Unit]
Description=Spinifex LAN bridge (non-critical)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/sbin/ifup br-lan
RemainAfterExit=yes
# Failure is non-critical — cable unplugged or switch not ready at boot.
SuccessExitStatus=0 1
[Install]
WantedBy=multi-user.target
`
unitPath := filepath.Join(root, "etc/systemd/system/spinifex-lan-bridge.service")
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
return err
}
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-lan-bridge.service")
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove stale symlink %s: %w", link, err)
}
return os.Symlink("/etc/systemd/system/spinifex-lan-bridge.service", link)
}
func installBootloader(disk string) error {
// grub-install runs in the live environment (not chroot) using the
// grub-pc-bin and grub-efi-amd64-bin packages already present on the ISO.
@@ -434,6 +392,10 @@ func installBootloader(disk string) error {
}
return biosErr
}
// Copy splash image from the ISO (mounted at /cdrom) so the installed GRUB
// shows the same branded background as the installer GRUB.
copySplashImage(mountRoot)
// Configure serial console in the installed system's GRUB so the boot menu
// and kernel output are visible over serial (ttyS0) as well as VGA.
grubDefault := `GRUB_DEFAULT=0
@@ -443,6 +405,7 @@ GRUB_CMDLINE_LINUX_DEFAULT=""
GRUB_CMDLINE_LINUX="console=tty0 console=ttyS0,115200n8 systemd.show_status=1"
GRUB_TERMINAL="serial console"
GRUB_SERIAL_COMMAND="serial --unit=0 --speed=115200 --word=8 --parity=no --stop=1"
GRUB_BACKGROUND=/boot/grub/splash.png
`
if err := os.WriteFile(filepath.Join(mountRoot, "etc/default/grub"), []byte(grubDefault), 0o644); err != nil {
return fmt.Errorf("write /etc/default/grub: %w", err)
@@ -548,6 +511,34 @@ func partitionPaths(disk string) (efi, root string) {
return disk + "2", disk + "3"
}
// copySplashImage copies the GRUB splash from the live ISO (/cdrom/boot/grub/splash.png)
// into the installed system so the post-install GRUB shows the same branded background
// as the installer. Non-fatal — a missing or unreadable source is logged and skipped.
func copySplashImage(root string) {
const src = "/cdrom/boot/grub/splash.png"
in, err := os.Open(src)
if err != nil {
slog.Warn("copySplashImage: splash not found on ISO, skipping", "path", src)
return
}
defer in.Close()
dstDir := filepath.Join(root, "boot/grub")
if err := os.MkdirAll(dstDir, 0o755); err != nil {
slog.Warn("copySplashImage: cannot create boot/grub dir", "err", err)
return
}
out, err := os.OpenFile(filepath.Join(dstDir, "splash.png"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
slog.Warn("copySplashImage: cannot open destination", "err", err)
return
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
slog.Warn("copySplashImage: copy failed", "err", err)
}
}
func run(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
+158
View File
@@ -0,0 +1,158 @@
/*
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 systemd writes systemd unit files and drop-ins into an installed
// system root during the Spinifex ISO installation process.
package systemd
import (
"fmt"
"os"
"path/filepath"
)
// WriteLANBridgeUnit installs a non-critical oneshot service that brings up
// br-lan *after* network-online.target. This keeps br-lan out of
// networking.service entirely — a missing LAN cable or DHCP timeout on the
// secondary bridge can never stall the management interface or firstboot.
func WriteLANBridgeUnit(root string) error {
unit := `[Unit]
Description=Spinifex LAN bridge (non-critical)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/sbin/ifup br-lan
RemainAfterExit=yes
# Failure is non-critical — cable unplugged or switch not ready at boot.
SuccessExitStatus=0 1
[Install]
WantedBy=multi-user.target
`
unitPath := filepath.Join(root, "etc/systemd/system/spinifex-lan-bridge.service")
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
return err
}
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-lan-bridge.service")
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove stale symlink %s: %w", link, err)
}
return os.Symlink("/etc/systemd/system/spinifex-lan-bridge.service", link)
}
// WriteNetworkingDropIn writes a networking.service drop-in that treats exit
// code 1 as success. This prevents a secondary interface failure (e.g. br-lan
// DHCP timeout when no cable is plugged in) from blocking network-online.target
// and therefore spinifex-firstboot.service.
func WriteNetworkingDropIn(root string) error {
dir := filepath.Join(root, "etc/systemd/system/networking.service.d")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
dropIn := "[Service]\nSuccessExitStatus=0 1\n"
return os.WriteFile(filepath.Join(dir, "spinifex-optional-ifaces.conf"), []byte(dropIn), 0o644)
}
// WriteFirstbootUnit writes the spinifex-firstboot.service oneshot unit that
// runs the firstboot provisioning script on the first real boot after installation.
func WriteFirstbootUnit(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)
}
// 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)
}
// WriteGettyDropIn holds the primary consoles (tty1 and ttyS0) until
// spinifex-banner.service completes, so the MOTD banner is visible before the
// login prompt appears. Drop-ins are scoped to named instances (getty@tty1,
// serial-getty@ttyS0) so tty2, tty3, etc. remain available as rescue terminals.
func WriteGettyDropIn(root string) error {
dropIn := `[Unit]
After=spinifex-banner.service
Wants=spinifex-banner.service
`
for _, svc := range []string{"getty@tty1", "serial-getty@ttyS0"} {
dropInDir := filepath.Join(root, "etc/systemd/system/"+svc+".service.d")
if err := os.MkdirAll(dropInDir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dropInDir, "spinifex-wait.conf"), []byte(dropIn), 0o644); err != nil {
return err
}
}
return nil
}
// EnableUnit creates the multi-user.target.wants symlink for serviceName,
// equivalent to `systemctl enable` for a unit that targets multi-user.target.
func EnableUnit(root, serviceName 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, serviceName)
target := "/etc/systemd/system/" + serviceName
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove stale symlink %s: %w", link, err)
}
return os.Symlink(target, link)
}