diff --git a/cmd/installer/autoinstall/autoinstall_test.go b/cmd/installer/autoinstall/autoinstall_test.go new file mode 100644 index 000000000..0452ecf45 --- /dev/null +++ b/cmd/installer/autoinstall/autoinstall_test.go @@ -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") + } +} diff --git a/cmd/installer/firstboot/firstboot.go b/cmd/installer/firstboot/firstboot.go index 6b8f6a4cb..1654771f7 100644 --- a/cmd/installer/firstboot/firstboot.go +++ b/cmd/installer/firstboot/firstboot.go @@ -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) diff --git a/cmd/installer/firstboot/firstboot_test.go b/cmd/installer/firstboot/firstboot_test.go new file mode 100644 index 000000000..70058bdcd --- /dev/null +++ b/cmd/installer/firstboot/firstboot_test.go @@ -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") + } +} diff --git a/cmd/installer/install/install.go b/cmd/installer/install/install.go index 45f25f71b..eeaa5e982 100644 --- a/cmd/installer/install/install.go +++ b/cmd/installer/install/install.go @@ -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")), } } diff --git a/cmd/installer/install/install_test.go b/cmd/installer/install/install_test.go new file mode 100644 index 000000000..25c715d8a --- /dev/null +++ b/cmd/installer/install/install_test.go @@ -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) + } +}