mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user