mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
4b877c6a93
discoverSingleNodeIPs seeds 127.0.0.1 then appends net.InterfaceAddrs() in undefined order, so WANHost=nodeIPs[0] was loopback and the guest gateway URL helpers fell through to the first non-loopback interface addr — an arbitrary co-tenant bridge on multi-bridge shared hosts (wattle: br-lan-env1..23, br-wan), leaving guest VMs unable to reach the advertised gateway to register. Resolve WANHost from spinifex.toml nodes.<node>.advertise (falling back to that node's host), mirroring awsgwBindIP's toml scan; fall back to the first non-loopback discovered addr only when config is unreadable. This removes the need for the SPINIFEX_WAN_IP override on shared hosts.
264 lines
6.2 KiB
Go
264 lines
6.2 KiB
Go
//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"
|
|
)
|
|
|
|
type Mode string
|
|
|
|
const (
|
|
ModeSingle Mode = "single"
|
|
ModeMultinode Mode = "multinode"
|
|
ModeBaremetal Mode = "baremetal"
|
|
)
|
|
|
|
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
|
|
}
|
|
|
|
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.<node>.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.<node>] top-level table, not sub-tables
|
|
// like [nodes.<node>.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
|
|
}
|