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) } }