//go:build e2e // Package harness provides shared E2E test primitives for the Spinifex scenario // suites (cert, lb, multinode, baremetal, reboot, tofu). package harness import ( "bufio" "net" "os" "path/filepath" "regexp" "strings" "testing" "time" ) // Mode is the cluster topology a scenario runs against, from SPINIFEX_MODE. type Mode string const ( ModeSingle Mode = "single" ModeMultinode Mode = "multinode" ModeBaremetal Mode = "baremetal" ) // Env is the target cluster as seen by a scenario: its node and service IPs, // ports, config directory and default wait budgets. type Env struct { Mode Mode NodeIPs []string // WANHost is the externally-reachable host used by runner-resident // scenarios (e.g. the reboot suite, which executes on the GitHub Actions // runner because systemctl-reboot kills any in-VM process). Distinct from // ServiceIPs[0]: ServiceIPs[0] is the awsgw bind IP and may be loopback // when the test runs in-VM, whereas WANHost must always be the SSH/HTTP // target IP reachable from outside the cluster. WANHost string ServiceIPs []string ConfigDir string AWSGWPort int UIPort int ArtifactDir string DefaultTimeout time.Duration DefaultPoll time.Duration } // LoadEnv builds the Env from SPINIFEX_* variables, discovering IPs and the // config directory when unset. It skips the test unless SPINIFEX_E2E is set. func LoadEnv(t *testing.T) *Env { t.Helper() if os.Getenv("SPINIFEX_E2E") == "" { t.Skip("SPINIFEX_E2E not set; skipping E2E scenario") } mode := Mode(getenv("SPINIFEX_MODE", string(ModeSingle))) configDir := getenv("SPINIFEX_CONFIG_DIR", "") if configDir == "" { for _, c := range []string{"/etc/spinifex", os.ExpandEnv("$HOME/spinifex/config")} { if stat, err := os.Stat(c); err == nil && stat.IsDir() { configDir = c break } } } nodeIPs := splitCSV(os.Getenv("SPINIFEX_NODE_IPS")) if len(nodeIPs) == 0 { // Include loopback + every non-loopback global IPv4 so SAN checks see // all addresses the cert covers. if mode == ModeSingle { nodeIPs = discoverSingleNodeIPs() } else { nodeIPs = []string{"127.0.0.1"} } } serviceIPs := splitCSV(os.Getenv("SPINIFEX_SERVICE_IPS")) if len(serviceIPs) == 0 { // Use the awsgw bind IP so TLS checks hit the actual listener, not loopback. if bind := awsgwBindIP(configDir); bind != "" && bind != "0.0.0.0" { serviceIPs = []string{bind} } else { serviceIPs = nodeIPs } } wanHost := getenv("SPINIFEX_WAN_IP", "") if wanHost == "" && mode == ModeSingle { // Prefer the node's advertised address so multi-bridge shared hosts // resolve the advertised WAN bridge, not an arbitrary co-tenant bridge // picked by interface-enumeration order. wanHost = advertiseIP(configDir) } if wanHost == "" { for _, ip := range nodeIPs { if !strings.HasPrefix(ip, "127.") { wanHost = ip break } } } if wanHost == "" && len(nodeIPs) > 0 { wanHost = nodeIPs[0] } return &Env{ Mode: mode, NodeIPs: nodeIPs, WANHost: wanHost, ServiceIPs: serviceIPs, ConfigDir: configDir, AWSGWPort: atoiOr("SPINIFEX_AWSGW_PORT", 9999), UIPort: atoiOr("SPINIFEX_UI_PORT", 3000), ArtifactDir: getenv("ARTIFACT_DIR", "/tmp/spinifex-e2e-artifacts"), DefaultTimeout: durationOr("SPINIFEX_DEFAULT_TIMEOUT", 30*time.Second), DefaultPoll: durationOr("SPINIFEX_DEFAULT_POLL", 500*time.Millisecond), } } func getenv(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func splitCSV(s string) []string { if s == "" { return nil } parts := strings.Split(s, ",") out := make([]string, 0, len(parts)) for _, p := range parts { if v := strings.TrimSpace(p); v != "" { out = append(out, v) } } return out } func atoiOr(key string, def int) int { v := os.Getenv(key) if v == "" { return def } n := 0 for _, c := range v { if c < '0' || c > '9' { return def } n = n*10 + int(c-'0') } return n } func durationOr(key string, def time.Duration) time.Duration { v := os.Getenv(key) if v == "" { return def } d, err := time.ParseDuration(v) if err != nil { return def } return d } func discoverSingleNodeIPs() []string { ips := []string{"127.0.0.1"} addrs, err := net.InterfaceAddrs() if err != nil { return ips } for _, a := range addrs { ipNet, ok := a.(*net.IPNet) if !ok { continue } ip4 := ipNet.IP.To4() if ip4 == nil || ip4.IsLoopback() || ip4.IsLinkLocalUnicast() { continue } ips = append(ips, ip4.String()) } return ips } var awsgwHostLine = regexp.MustCompile(`(?i)^\s*host\s*=\s*"([^":]+)`) func awsgwBindIP(configDir string) string { if configDir == "" { return "" } f, err := os.Open(filepath.Join(configDir, "spinifex.toml")) if err != nil { return "" } defer f.Close() scanner := bufio.NewScanner(f) inAwsgw := false for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "[") { inAwsgw = strings.Contains(line, ".awsgw]") || line == "[awsgw]" continue } if !inAwsgw { continue } if m := awsgwHostLine.FindStringSubmatch(line); m != nil { return m[1] } } return "" } var ( tomlNodeLine = regexp.MustCompile(`(?i)^\s*node\s*=\s*"([^"]+)"`) tomlAdvertiseLine = regexp.MustCompile(`(?i)^\s*advertise\s*=\s*"([^":]+)`) tomlHostLine = regexp.MustCompile(`(?i)^\s*host\s*=\s*"([^":]+)`) ) // advertiseIP returns the externally-reachable address of the local node from // spinifex.toml (nodes..advertise, or that node's host when advertise is // unset). The local node is named by the top-level node key. Returns "" when // the file or node cannot be resolved so callers fall back to interface scan. func advertiseIP(configDir string) string { if configDir == "" { return "" } f, err := os.Open(filepath.Join(configDir, "spinifex.toml")) if err != nil { return "" } defer f.Close() var node, advertise, host string inNode := false scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if strings.HasPrefix(line, "[") { // Match only the [nodes.] top-level table, not sub-tables // like [nodes..awsgw]. inNode = node != "" && line == "[nodes."+node+"]" continue } if !inNode { if node == "" { if m := tomlNodeLine.FindStringSubmatch(line); m != nil { node = m[1] } } continue } if m := tomlAdvertiseLine.FindStringSubmatch(line); m != nil { advertise = m[1] } else if m := tomlHostLine.FindStringSubmatch(line); m != nil { host = m[1] } } if advertise != "" { return advertise } return host }