mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
feat(installer): persist SPINIFEX_INSTALL_CALLBACK through firstboot reboot
In the PXE path SPINIFEX_INSTALL_CALLBACK arrives on the iPXE kernel cmdline but was never threaded into the firstboot script, so firstboot never curled the controller's /done endpoint. The flag stayed on disk, the node re-entered the install loop on the next PXE boot. Fix: read SPINIFEX_INSTALL_CALLBACK from the live-environment env in toFirstbootConfig and embed a best-effort curl into the generated firstboot script, fired once after the success marker is written. Interactive and USB-stick installs are unaffected (env var absent -> callbackBlock is empty, no curl injected).
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
package autoinstall
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadReturnsNilWhenNotAuto(t *testing.T) {
|
||||
os.Unsetenv("SPINIFEX_AUTO")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Fatal("expected nil config when SPINIFEX_AUTO is not set")
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,12 @@ type Config struct {
|
||||
// GPUPassthrough enables VFIO GPU passthrough by passing --gpu-passthrough
|
||||
// to `spx admin init`, which writes gpu_passthrough = true in the daemon config.
|
||||
GPUPassthrough bool
|
||||
// InstallCallback, when non-empty, is curled once at the end of firstboot
|
||||
// after the success marker is written. Generic phone-home hook for
|
||||
// provisioning controllers (PXE/MAAS-style flows). Best-effort: non-2xx
|
||||
// and network failures are swallowed so the install is not gated on
|
||||
// controller reachability.
|
||||
InstallCallback string
|
||||
}
|
||||
|
||||
// Write drops the firstboot script and systemd unit into root, which should be
|
||||
@@ -81,6 +87,14 @@ func writeScript(root string, cfg Config) error {
|
||||
setupOVN += fmt.Sprintf(" --encap-ip=%s", cfg.EncapIP)
|
||||
}
|
||||
|
||||
callbackBlock := ""
|
||||
if cfg.InstallCallback != "" {
|
||||
callbackBlock = fmt.Sprintf(
|
||||
"\ncurl -fsS --max-time 10 --retry 2 --retry-delay 2 -o /dev/null %s || true\n",
|
||||
shellEscapeSingle(cfg.InstallCallback),
|
||||
)
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/bash
|
||||
# Spinifex firstboot — runs once after ISO installation. On success, writes
|
||||
# /var/lib/spinifex/.firstboot-done and the systemd unit's
|
||||
@@ -191,7 +205,7 @@ systemctl enable spinifex.target spinifex-banner.service
|
||||
# firstboot from the top.
|
||||
mkdir -p "$(dirname "$DONE_MARKER")"
|
||||
touch "$DONE_MARKER"
|
||||
|
||||
%s
|
||||
systemctl start spinifex.target
|
||||
|
||||
# Wait for the daemon to bring up external networking. When external_mode=pool
|
||||
@@ -211,7 +225,7 @@ if grep -q 'external_mode.*pool' /etc/spinifex/spinifex.toml 2>/dev/null; then
|
||||
echo "[firstboot] warning: br-ext not up after 30s — external networking may be delayed"
|
||||
fi
|
||||
fi
|
||||
`, cfg.Hostname, setupOVN, clusterCmd)
|
||||
`, cfg.Hostname, setupOVN, clusterCmd, callbackBlock)
|
||||
|
||||
path := filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh")
|
||||
return os.WriteFile(path, []byte(script), 0o755)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package firstboot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func makeRootDirs(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
for _, d := range []string{
|
||||
"usr/local/bin",
|
||||
"etc/systemd/system",
|
||||
"etc/systemd/system/multi-user.target.wants",
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", d, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteScriptNoCallbackWhenEmpty(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makeRootDirs(t, root)
|
||||
|
||||
cfg := Config{Hostname: "test-node", ClusterRole: "init"}
|
||||
if err := Write(root, cfg); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
script, err := os.ReadFile(filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("read script: %v", err)
|
||||
}
|
||||
if strings.Contains(string(script), "curl") {
|
||||
t.Error("script should not contain curl when InstallCallback is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteScriptEmbedsCurlWhenCallbackSet(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makeRootDirs(t, root)
|
||||
|
||||
const callbackURL = "http://192.168.1.12/boot/done?mac=aa:bb:cc:dd:ee:ff"
|
||||
cfg := Config{
|
||||
Hostname: "test-node",
|
||||
ClusterRole: "init",
|
||||
InstallCallback: callbackURL,
|
||||
}
|
||||
if err := Write(root, cfg); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
script, err := os.ReadFile(filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("read script: %v", err)
|
||||
}
|
||||
content := string(script)
|
||||
|
||||
if !strings.Contains(content, "curl") {
|
||||
t.Error("script missing curl command")
|
||||
}
|
||||
if !strings.Contains(content, callbackURL) {
|
||||
t.Errorf("script missing callback URL %q", callbackURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteScriptCallbackAfterDoneMarker(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makeRootDirs(t, root)
|
||||
|
||||
const callbackURL = "http://192.168.1.12/boot/done?mac=aa:bb:cc:dd:ee:ff"
|
||||
cfg := Config{
|
||||
Hostname: "node1",
|
||||
ClusterRole: "init",
|
||||
InstallCallback: callbackURL,
|
||||
}
|
||||
if err := Write(root, cfg); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
script, err := os.ReadFile(filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("read script: %v", err)
|
||||
}
|
||||
content := string(script)
|
||||
|
||||
doneIdx := strings.Index(content, "touch \"$DONE_MARKER\"")
|
||||
curlIdx := strings.Index(content, "curl")
|
||||
if doneIdx < 0 {
|
||||
t.Fatal("done marker not found in script")
|
||||
}
|
||||
if curlIdx < 0 {
|
||||
t.Fatal("curl not found in script")
|
||||
}
|
||||
if curlIdx < doneIdx {
|
||||
t.Error("curl must appear after done marker write")
|
||||
}
|
||||
}
|
||||
@@ -662,11 +662,12 @@ func (c *Config) toFirstbootConfig() firstboot.Config {
|
||||
encapIP = c.LANAddress
|
||||
}
|
||||
return firstboot.Config{
|
||||
Hostname: c.Hostname,
|
||||
EncapIP: encapIP,
|
||||
ClusterRole: c.ClusterRole,
|
||||
JoinAddr: c.JoinAddr,
|
||||
Email: c.Email,
|
||||
Hostname: c.Hostname,
|
||||
EncapIP: encapIP,
|
||||
ClusterRole: c.ClusterRole,
|
||||
JoinAddr: c.JoinAddr,
|
||||
Email: c.Email,
|
||||
InstallCallback: strings.TrimSpace(os.Getenv("SPINIFEX_INSTALL_CALLBACK")),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToFirstbootConfigIncludesInstallCallback(t *testing.T) {
|
||||
const url = "http://192.168.1.12/boot/done?mac=aa:bb:cc:dd:ee:ff"
|
||||
t.Setenv("SPINIFEX_INSTALL_CALLBACK", url)
|
||||
|
||||
cfg := &Config{Hostname: "node1", ClusterRole: "init"}
|
||||
fb := cfg.toFirstbootConfig()
|
||||
if fb.InstallCallback != url {
|
||||
t.Errorf("InstallCallback = %q, want %q", fb.InstallCallback, url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToFirstbootConfigEmptyCallbackWhenEnvUnset(t *testing.T) {
|
||||
os.Unsetenv("SPINIFEX_INSTALL_CALLBACK")
|
||||
|
||||
cfg := &Config{Hostname: "node1", ClusterRole: "init"}
|
||||
fb := cfg.toFirstbootConfig()
|
||||
if fb.InstallCallback != "" {
|
||||
t.Errorf("InstallCallback = %q, want empty", fb.InstallCallback)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user