You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

395 lines
15 KiB

package networkconfig
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
type successfulRunner struct {
calls []string
failContains string
}
func (r *successfulRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
call := name + " " + strings.Join(args, " ")
r.calls = append(r.calls, call)
if r.failContains != "" && strings.Contains(call, r.failContains) {
return nil, errors.New("injected command failure")
}
return nil, nil
}
func testOSController(t *testing.T) (*OSController, *successfulRunner, map[string]string) {
t.Helper()
root := t.TempDir()
paths := map[string]string{
"interfaces": filepath.Join(root, "etc", "network", "interfaces"),
"routing": filepath.Join(root, "etc", "network", "if-up.d", "20-tera-active-active"),
"config": filepath.Join(root, "var", "lib", "tera-network", "config.json"),
"pending": filepath.Join(root, "var", "lib", "tera-network", "pending.json"),
"hostname": filepath.Join(root, "etc", "hostname"),
"sys": filepath.Join(root, "sys", "class", "net"),
}
for _, path := range paths {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(paths["interfaces"], []byte("old interfaces\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths["routing"], []byte("#!/bin/sh\n# old route\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(paths["hostname"], []byte("old-console\n"), 0o644); err != nil {
t.Fatal(err)
}
runner := &successfulRunner{}
controller, err := NewOSController(OSOptions{
InterfacesPath: paths["interfaces"], RoutingPath: paths["routing"], ConfigPath: paths["config"], PendingPath: paths["pending"], HostnamePath: paths["hostname"],
HostnameCommandPath: filepath.Join(root, "bin", "hostname"),
IfdownPath: filepath.Join(root, "sbin", "ifdown"), IfupPath: filepath.Join(root, "sbin", "ifup"), IPPath: filepath.Join(root, "sbin", "ip"), ShellPath: filepath.Join(root, "bin", "sh"), SysClassRoot: paths["sys"],
Runner: runner, Now: func() time.Time { return time.Date(2026, 7, 30, 9, 0, 0, 0, time.UTC) },
})
if err != nil {
t.Fatal(err)
}
return controller, runner, paths
}
func TestRenderRoutingScriptBuildsECMPAndSourceRules(t *testing.T) {
config := testConfig()
config.Interfaces[0].Role = RoleExternal
config.Interfaces[0].Gateway = "192.168.0.1"
script, err := RenderRoutingScript(config, "/sbin/ip")
if err != nil {
t.Fatal(err)
}
text := string(script)
for _, expected := range []string{
"if $IP -4 rule show >/dev/null 2>&1; then",
"network_ready()",
"if network_ready eth0 192.168.0.223; then",
"deferring IPv4 policy route for eth1 until 192.168.78.55 is configured",
"from 192.168.0.223/32 lookup 100",
"from 192.168.78.55/32 lookup 101",
"route flush table 100",
"route add 192.168.0.0/24 dev eth0 src 192.168.0.223 table 100",
"while $IP -4 route del default 2>/dev/null; do :; done",
"route add default nexthop via 192.168.0.1 dev eth0 weight 1 nexthop via 192.168.78.1 dev eth1 weight 1",
} {
if !strings.Contains(text, expected) {
t.Fatalf("routing script missing %q:\n%s", expected, text)
}
}
if !strings.Contains(text, "kernel lacks IPv4 policy routing required for dual-external active/active") {
t.Fatalf("dual-external kernel capability guard is missing:\n%s", text)
}
if strings.Contains(text, "route replace") {
t.Fatalf("routing script must remain compatible with the target route implementation:\n%s", text)
}
}
func TestRenderRoutingScriptFallsBackForSingleExternalWithoutPolicyRouting(t *testing.T) {
script, err := RenderRoutingScript(testConfig(), "/sbin/ip")
if err != nil {
t.Fatal(err)
}
text := string(script)
if !strings.Contains(text, "IPv4 policy routing unavailable; using connected routes plus one main default") {
t.Fatalf("single-external fallback is missing:\n%s", text)
}
if strings.Contains(text, "kernel lacks IPv4 policy routing required for dual-external active/active") {
t.Fatalf("single-external configuration must not use the dual-external rejection:\n%s", text)
}
if !strings.Contains(text, "route add default via 192.168.78.1 dev eth1") {
t.Fatalf("single-external main default is missing:\n%s", text)
}
if !strings.Contains(text, "if network_ready eth1 192.168.78.55; then") {
t.Fatalf("single-external main default is not guarded until eth1 has its address:\n%s", text)
}
}
func TestOOBHealthThresholdActivatesFallbackAndRestoresPrimary(t *testing.T) {
controller, runner, paths := testOSController(t)
for _, device := range []string{"eth0", "eth1"} {
directory := filepath.Join(paths["sys"], device)
if err := os.MkdirAll(directory, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(directory, "carrier"), []byte("1\n"), 0o644); err != nil {
t.Fatal(err)
}
}
config := testConfig()
config.OperatingMode = OperatingModeOOB
config.OOBPrimaryInterface = "Interface-1"
config.OOBFallbackInterface = "Interface-2"
config.Interfaces[0].Gateway = "192.168.0.1"
config.OOBHealthCheckTarget = "9.9.9.9"
config.OOBFailureThreshold = 2
if config.Interfaces[0].Name != Interface1Name || config.Interfaces[0].Device != Interface1Device ||
config.Interfaces[1].Name != Interface2Name || config.Interfaces[1].Device != Interface2Device {
t.Fatalf("test configuration does not use fixed interface mapping: %+v", config.Interfaces)
}
if _, err := controller.Apply(context.Background(), config); err != nil {
t.Fatal(err)
}
runner.failContains = "ping"
controller.checkOOBHealth(context.Background())
controller.checkOOBHealth(context.Background())
status, err := controller.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if !status.OOB.FallbackActive || status.OOB.FailoverReason != "primary_failure_threshold_reached" || status.OOB.LastFailoverAt == nil {
t.Fatalf("fallback status = %+v", status.OOB)
}
if !strings.Contains(strings.Join(runner.calls, "\n"), "route replace default via 192.168.78.1 dev eth1") {
t.Fatalf("fallback route command missing: %v", runner.calls)
}
runner.failContains = ""
controller.checkOOBHealth(context.Background())
status, _ = controller.Status(context.Background())
if status.OOB.FallbackActive || status.OOB.FailoverReason != "primary_restored" {
t.Fatalf("restored status = %+v", status.OOB)
}
if !strings.Contains(strings.Join(runner.calls, "\n"), "route replace default via 192.168.0.1 dev eth0") {
t.Fatalf("primary route restore command missing: %v", runner.calls)
}
}
func TestRenderInterfacesSupportsDHCPDNSAndIPv6(t *testing.T) {
config := testConfig()
config.Interfaces[0].DNSServers = []string{"192.168.0.1", "8.8.8.8"}
config.Interfaces[0].IPv6Mode = IPv6ModeStatic
config.Interfaces[0].IPv6Address = "2001:db8::223/64"
config.Interfaces[0].IPv6Gateway = "2001:db8::1"
config.Interfaces[0].IPv6DNSServers = []string{"2001:4860:4860::8888"}
config.Interfaces[1].AddressMode = AddressModeDHCP
config.Interfaces[1].Address = ""
config.Interfaces[1].Gateway = ""
payload, err := RenderInterfaces(config, "/etc/network/if-up.d/20-tera-network")
if err != nil {
t.Fatal(err)
}
text := string(payload)
if strings.Contains(text, "inet6 auto") {
t.Fatalf("BusyBox-incompatible IPv6 auto method emitted:\n%s", text)
}
for _, expected := range []string{
"iface eth0 inet static",
"dns-nameservers 192.168.0.1 8.8.8.8",
"iface eth0 inet6 static",
"address 2001:db8::223",
"netmask 64",
"gateway 2001:db8::1",
"dns-nameservers 2001:4860:4860::8888",
"iface eth1 inet dhcp",
} {
if !strings.Contains(text, expected) {
t.Fatalf("interfaces file missing %q:\n%s", expected, text)
}
}
routing, err := RenderRoutingScript(config, "/sbin/ip")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(routing), "DHCP owns the dynamic default route") {
t.Fatalf("routing script overwrites DHCP default route:\n%s", routing)
}
}
func TestRenderInterfacesBuildsBondingFromInterfaceOne(t *testing.T) {
config := testConfig()
config.Advanced = AdvancedConfig{Mode: AdvancedModeBonding, BondMode: "active-backup", BondMii: 50}
payload, err := RenderInterfaces(config, "/etc/network/if-up.d/20-tera-network")
if err != nil {
t.Fatal(err)
}
text := string(payload)
for _, expected := range []string{
"iface eth0 inet manual",
"iface eth1 inet manual",
"auto bond0",
"iface bond0 inet static",
"address 192.168.0.223",
"link add name bond0 type bond",
"type bond mode active-backup miimon 50",
"link set dev eth0 master bond0",
"link set dev eth1 master bond0",
} {
if !strings.Contains(text, expected) {
t.Fatalf("bonding interfaces file missing %q:\n%s", expected, text)
}
}
if strings.Contains(text, "address 192.168.78.55") {
t.Fatalf("Interface-2 layer-3 address leaked into bond configuration:\n%s", text)
}
routing, err := RenderRoutingScript(config, "/sbin/ip")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(routing), "dev bond0") || strings.Contains(string(routing), "dev eth1") {
t.Fatalf("bond routing does not use bond0 exclusively:\n%s", routing)
}
}
func TestRenderInterfacesBuildsBridgeAndSTP(t *testing.T) {
config := testConfig()
config.Advanced = AdvancedConfig{Mode: AdvancedModeBridge, BridgeSTP: true}
payload, err := RenderInterfaces(config, "/etc/network/if-up.d/20-tera-network")
if err != nil {
t.Fatal(err)
}
text := string(payload)
for _, expected := range []string{
"auto br0",
"iface br0 inet static",
"link add name br0 type bridge",
"type bridge stp_state 1",
"link set dev eth0 master br0",
"link set dev eth1 master br0",
} {
if !strings.Contains(text, expected) {
t.Fatalf("bridge interfaces file missing %q:\n%s", expected, text)
}
}
}
func TestApplyAdvancedModeBringsUpAggregateDevice(t *testing.T) {
controller, runner, _ := testOSController(t)
config := testConfig()
config.Advanced = AdvancedConfig{Mode: AdvancedModeBonding, BondMode: "balance-rr", BondMii: 100}
if _, err := controller.Apply(context.Background(), config); err != nil {
t.Fatal(err)
}
calls := strings.Join(runner.calls, "\n")
if !strings.Contains(calls, "ifup bond0") {
t.Fatalf("aggregate interface was not brought up: %s", calls)
}
}
func TestChangedNetworkDevicesIncludesDNSAndIPv6ButSkipsHostnameOnly(t *testing.T) {
current := testConfig()
next := current
next.Interfaces = append([]Interface(nil), current.Interfaces...)
next.Interfaces[0].DNSServers = []string{"8.8.8.8"}
next.Interfaces[0].IPv6Mode = IPv6ModeStatic
next.Interfaces[0].IPv6Address = "2001:db8::223/64"
changed := changedNetworkDevices(current, next)
if !changed["eth0"] || changed["eth1"] {
t.Fatalf("changed devices = %#v", changed)
}
hostOnly := current
hostOnly.HostName = "new-console"
changed = changedNetworkDevices(current, hostOnly)
if changed["eth0"] || changed["eth1"] {
t.Fatalf("hostname-only update bounced interfaces: %#v", changed)
}
}
func TestApplyConfirmPersistsConfigurationAndClearsRollback(t *testing.T) {
controller, runner, paths := testOSController(t)
result, err := controller.Apply(context.Background(), testConfig())
if err != nil {
t.Fatal(err)
}
if result.State != "pending_confirmation" || result.TransactionID == "" {
t.Fatalf("apply result = %+v", result)
}
interfaces, _ := os.ReadFile(paths["interfaces"])
if !strings.Contains(string(interfaces), "address 192.168.78.55") || len(runner.calls) < 5 {
t.Fatalf("configuration not applied: %s calls=%v", interfaces, runner.calls)
}
hostname, _ := os.ReadFile(paths["hostname"])
if string(hostname) != "iconsole-server\n" || !strings.Contains(strings.Join(runner.calls, "\n"), "hostname iconsole-server") {
t.Fatalf("hostname not applied: %q calls=%v", hostname, runner.calls)
}
status, err := controller.Confirm(context.Background(), result.TransactionID)
if err != nil || status.State != "confirmed" {
t.Fatalf("confirm = %+v, %v", status, err)
}
if _, err := os.Stat(paths["pending"]); !os.IsNotExist(err) {
t.Fatalf("pending transaction still exists: %v", err)
}
}
func TestRollbackRestoresExactPreviousFiles(t *testing.T) {
controller, _, paths := testOSController(t)
result, err := controller.Apply(context.Background(), testConfig())
if err != nil {
t.Fatal(err)
}
status, err := controller.Rollback(context.Background(), result.TransactionID)
if err != nil || !strings.HasPrefix(status.State, "rolled_back") {
t.Fatalf("rollback = %+v, %v", status, err)
}
content, _ := os.ReadFile(paths["interfaces"])
if string(content) != "old interfaces\n" {
t.Fatalf("interfaces after rollback = %q", content)
}
hostname, _ := os.ReadFile(paths["hostname"])
if string(hostname) != "old-console\n" {
t.Fatalf("hostname after rollback = %q", hostname)
}
if _, err := os.Stat(paths["config"]); !os.IsNotExist(err) {
t.Fatalf("new config was not removed: %v", err)
}
}
func TestRecoverRollsBackUnconfirmedTransactionAfterRestart(t *testing.T) {
controller, runner, paths := testOSController(t)
if _, err := controller.Apply(context.Background(), testConfig()); err != nil {
t.Fatal(err)
}
restarted, err := NewOSController(OSOptions{
InterfacesPath: paths["interfaces"], RoutingPath: paths["routing"], ConfigPath: paths["config"], PendingPath: paths["pending"], HostnamePath: paths["hostname"],
HostnameCommandPath: filepath.Join(filepath.Dir(paths["interfaces"]), "hostname"),
IfdownPath: filepath.Join(filepath.Dir(paths["interfaces"]), "ifdown"), IfupPath: filepath.Join(filepath.Dir(paths["interfaces"]), "ifup"), IPPath: filepath.Join(filepath.Dir(paths["interfaces"]), "ip"), ShellPath: filepath.Join(filepath.Dir(paths["interfaces"]), "sh"), SysClassRoot: paths["sys"], Runner: runner,
})
if err != nil {
t.Fatal(err)
}
if err := restarted.Recover(context.Background()); err != nil {
t.Fatal(err)
}
content, _ := os.ReadFile(paths["interfaces"])
if string(content) != "old interfaces\n" {
t.Fatalf("interfaces after recovery = %q", content)
}
}
func TestFactoryResetClearsPendingNetworkAndOnlyWritesBootConfiguration(t *testing.T) {
c, runner, paths := testOSController(t)
if _, err := c.Apply(context.Background(), testConfig()); err != nil {
t.Fatal(err)
}
before := len(runner.calls)
config := testConfig()
config.HostName = "factory-host"
if err := c.FactoryReset(context.Background(), config); err != nil {
t.Fatal(err)
}
if len(runner.calls) != before {
t.Fatal("factory reset disconnected network before response")
}
if c.pending != nil || c.timer != nil {
t.Fatal("old rollback transaction survived reset")
}
if _, err := os.Stat(paths["pending"]); !os.IsNotExist(err) {
t.Fatal("old network snapshot survived on disk")
}
content, err := os.ReadFile(paths["hostname"])
if err != nil || strings.TrimSpace(string(content)) != "factory-host" {
t.Fatal("factory network settings not persisted")
}
}