diff --git a/cmd/installer/firstboot/firstboot.go b/cmd/installer/firstboot/firstboot.go index 365eb3567..d1c4a6634 100644 --- a/cmd/installer/firstboot/firstboot.go +++ b/cmd/installer/firstboot/firstboot.go @@ -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 -} diff --git a/cmd/installer/install/install.go b/cmd/installer/install/install.go index 6057da0c4..43b694efc 100644 --- a/cmd/installer/install/install.go +++ b/cmd/installer/install/install.go @@ -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 diff --git a/cmd/installer/systemd/systemd.go b/cmd/installer/systemd/systemd.go new file mode 100644 index 000000000..90f0cb0d1 --- /dev/null +++ b/cmd/installer/systemd/systemd.go @@ -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 . +*/ + +// 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) +}