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.
370 lines
14 KiB
370 lines
14 KiB
|
2 months ago
|
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)
|
||
|
|
}
|
||
|
|
}
|