mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
162 lines
6.6 KiB
Go
162 lines
6.6 KiB
Go
// 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"
|
|
"strings"
|
|
)
|
|
|
|
// WriteBridgeUnit installs a non-critical oneshot service that activates an
|
|
// east-west bridge (br-lan, br-vpc) via networkctl *after* network-online.target.
|
|
// Those bridges carry ActivationPolicy=manual in their networkd .network file,
|
|
// so networkd creates them at boot but does not bring them up automatically.
|
|
// This service triggers the activation once the WAN path is confirmed online,
|
|
// ensuring a missing cable or DHCP timeout on a secondary bridge never stalls
|
|
// the management interface or firstboot.
|
|
func WriteBridgeUnit(root, bridge string) error {
|
|
name := "spinifex-" + strings.TrimPrefix(bridge, "br-") + "-bridge.service"
|
|
unit := fmt.Sprintf(`[Unit]
|
|
Description=Spinifex %s bridge (non-critical)
|
|
After=network-online.target
|
|
Wants=network-online.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=/usr/bin/networkctl up %s
|
|
RemainAfterExit=yes
|
|
# Failure is non-critical — cable unplugged or switch not ready at boot.
|
|
SuccessExitStatus=0 1
|
|
|
|
[Install]
|
|
WantedBy=multi-user.target
|
|
`, bridge, bridge)
|
|
unitPath := filepath.Join(root, "etc/systemd/system", name)
|
|
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
|
|
return err
|
|
}
|
|
return EnableUnit(root, name)
|
|
}
|
|
|
|
// WriteFirstbootUnit writes the spinifex-firstboot.service oneshot unit that
|
|
// runs the firstboot provisioning script on the first real boot after
|
|
// installation. The script writes /var/lib/spinifex/.firstboot-done on
|
|
// success; ConditionPathExists=! on that marker condition-skips the unit on
|
|
// every subsequent boot so it never re-runs (replacing the previous
|
|
// "disable on EXIT" trap which couldn't distinguish success from failure).
|
|
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
|
|
ConditionPathExists=!/var/lib/spinifex/.firstboot-done
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
Environment=HOME=/root
|
|
ExecStart=/usr/local/bin/spinifex-firstboot.sh
|
|
RemainAfterExit=yes
|
|
# On non-zero exit, overwrite /etc/issue with a hint so the operator sees
|
|
# the failure on the console — the banner unit is never enabled in that
|
|
# case (firstboot didn't reach its enable --now line), so /etc/issue would
|
|
# otherwise stay at the Debian default with no signal that anything is wrong.
|
|
ExecStopPost=/bin/sh -c 'if [ "$EXIT_STATUS" != "0" ]; then printf "\n*** Spinifex firstboot FAILED ***\nLogin as spinifex, then run:\n sudo journalctl -u spinifex-firstboot.service\n\n" > /etc/issue; fi'
|
|
# Cap total firstboot runtime so a hang in setup-ovn.sh / spx admin init /
|
|
# ovn-central startup cannot wedge multi-user.target and keep getty from
|
|
# ever reaching the login prompt.
|
|
TimeoutStartSec=300s
|
|
# Install diagnostics land in the journal, which persists for the life of the
|
|
# node — nothing this script runs may print secrets to stdout or stderr.
|
|
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.
|
|
//
|
|
// The unit file is written by the installer but NOT enabled at install time.
|
|
// firstboot enables it (along with spinifex.target) at the end of its first
|
|
// run via `systemctl enable --now`. On every subsequent boot both units are
|
|
// driven by the multi-user.target.wants/ symlinks firstboot installed.
|
|
//
|
|
// Note: only After=spinifex.target — no Wants=. A Wants= would implicitly
|
|
// pull spinifex.target up at boot, which is exactly the race this whole
|
|
// change is fixing. After= alone is a no-op when the target isn't started,
|
|
// which is correct on first boot before firstboot has provisioned configs.
|
|
func WriteBannerUnit(root string) error {
|
|
unit := `[Unit]
|
|
Description=Spinifex console banner and boot health check
|
|
After=network-online.target
|
|
|
|
[Service]
|
|
Type=oneshot
|
|
ExecStart=/usr/local/bin/spx admin banner --boot-check
|
|
RemainAfterExit=yes
|
|
# Banner is oneshot; cap it so a stuck boot-check (IP detection, try-restart)
|
|
# cannot block getty via the spinifex-wait.conf drop-in.
|
|
TimeoutStartSec=30s
|
|
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 (when banner is enabled), 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.
|
|
//
|
|
// Only After=, not Wants=. A Wants= would implicitly start the (un-enabled)
|
|
// banner on the very first boot, partially undoing the "only firstboot is
|
|
// enabled on first boot" guarantee. On first boot, banner is not enabled,
|
|
// so After= is a no-op and login appears immediately with the default
|
|
// /etc/issue. Once firstboot completes and enables the banner, subsequent
|
|
// boots have banner enabled at multi-user.target and After= correctly
|
|
// orders getty after the banner has rendered /etc/issue.
|
|
func WriteGettyDropIn(root string) error {
|
|
dropIn := `[Unit]
|
|
After=spinifex-firstboot.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)
|
|
}
|