fix(e2e): resolve WANHost from spinifex.toml advertise on single-node (#527)

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.
This commit is contained in:
Josh Siviour
2026-07-13 23:49:39 +10:00
committed by GitHub
parent 4f31b336c5
commit 4b877c6a93
2 changed files with 141 additions and 0 deletions
+65
View File
@@ -81,6 +81,20 @@ func LoadEnv(t *testing.T) *Env {
}
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]
}
@@ -196,3 +210,54 @@ func awsgwBindIP(configDir string) string {
}
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
}
+76
View File
@@ -0,0 +1,76 @@
//go:build e2e
package harness
import (
"os"
"path/filepath"
"testing"
)
func TestAdvertiseIP(t *testing.T) {
cases := []struct {
name string
toml string
want string
}{
{
name: "advertise preferred over host and ignores sub-tables",
toml: `node = "wattle"
[nodes.wattle]
host = "10.105.1.3"
advertise = "192.168.1.13"
[nodes.wattle.awsgw]
host = "10.105.1.3:9999"
`,
want: "192.168.1.13",
},
{
name: "falls back to host when advertise unset",
toml: `node = "dev1"
[nodes.dev1]
host = "192.168.1.20"
`,
want: "192.168.1.20",
},
{
name: "resolves only the local node, not co-tenant nodes",
toml: `node = "n2"
[nodes.n1]
host = "10.0.0.1"
advertise = "1.1.1.1"
[nodes.n2]
host = "10.0.0.2"
advertise = "2.2.2.2"
`,
want: "2.2.2.2",
},
{
name: "empty when top-level node key is absent",
toml: `[nodes.x]
advertise = "9.9.9.9"
`,
want: "",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "spinifex.toml"), []byte(tc.toml), 0o600); err != nil {
t.Fatalf("write toml: %v", err)
}
if got := advertiseIP(dir); got != tc.want {
t.Fatalf("advertiseIP = %q, want %q", got, tc.want)
}
})
}
}
func TestAdvertiseIPMissing(t *testing.T) {
if got := advertiseIP(""); got != "" {
t.Fatalf("advertiseIP(\"\") = %q, want empty", got)
}
if got := advertiseIP(t.TempDir()); got != "" {
t.Fatalf("advertiseIP(missing file) = %q, want empty", got)
}
}