From 6962bd968f9e8118b4358701bacdf91a9b563e52 Mon Sep 17 00:00:00 2001 From: Adam Chen Date: Sat, 10 Jan 2026 05:20:33 +0000 Subject: [PATCH 1/4] cnkpt: add e2e harness --- .github/workflows/go-test.yml | 8 +- Dockerfile | 33 ++++ e2e/.gitignore | 1 + e2e/connectivity_test.go | 88 +++++++++ e2e/harness.go | 323 +++++++++++++++++++++++++++++++ e2e/utils.go | 65 +++++++ go.mod | 55 +++++- go.sum | 148 +++++++++++++- smoke/fixtures/testcentral1.yaml | 18 -- smoke/fixtures/testnode1.yaml | 5 - smoke/smoke-test.sh | 120 ------------ state/config_test.go | 33 +++- unit-test-coverage.sh | 8 - 13 files changed, 736 insertions(+), 169 deletions(-) create mode 100644 Dockerfile create mode 100644 e2e/.gitignore create mode 100644 e2e/connectivity_test.go create mode 100644 e2e/harness.go create mode 100644 e2e/utils.go delete mode 100755 smoke/fixtures/testcentral1.yaml delete mode 100644 smoke/fixtures/testnode1.yaml delete mode 100755 smoke/smoke-test.sh delete mode 100755 unit-test-coverage.sh diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index f5ea2082..87fdd92f 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -9,7 +9,6 @@ on: jobs: test: runs-on: ubuntu-latest - steps: - uses: actions/checkout@v3 @@ -24,9 +23,8 @@ jobs: - name: Build run: go build - - name: Run unit tests with coverage - run: bash unit-test-coverage.sh + - name: Run unit tests + run: go test -v -tags=router_test ./... - name: Run integration tests run: go test -v -tags=integration ./... - - name: Run smoke tests - run: bash smoke/smoke-test.sh + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..8a19402b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +FROM golang:1.25.4 AS builder +WORKDIR /src + +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -ldflags "-s -w" -o /nylon . + +FROM scratch + +# Copy binary from builder +COPY --from=builder /nylon /usr/local/bin/nylon + +WORKDIR /app/config + +ENTRYPOINT ["/usr/local/bin/nylon", "run", "-v"] + +FROM ubuntu:latest AS debug + +RUN apt-get update && apt-get install -y \ + iputils-ping \ + iperf3 \ + curl \ + iproute2 \ + net-tools \ + tcpdump \ + dnsutils \ + vim \ + && rm -rf /var/lib/apt/lists/* + +COPY --from=builder /nylon /usr/local/bin/nylon + +WORKDIR /app/config + +ENTRYPOINT ["/usr/local/bin/nylon", "run", "-v"] diff --git a/e2e/.gitignore b/e2e/.gitignore new file mode 100644 index 00000000..344f079e --- /dev/null +++ b/e2e/.gitignore @@ -0,0 +1 @@ +runs diff --git a/e2e/connectivity_test.go b/e2e/connectivity_test.go new file mode 100644 index 00000000..6aa1e3d7 --- /dev/null +++ b/e2e/connectivity_test.go @@ -0,0 +1,88 @@ +package e2e + +import ( + "testing" + "time" + + "github.com/encodeous/nylon/state" +) + +func TestConnectivity(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + + // Use a specific subnet for this test to avoid conflicts + subnet := "172.30.0.0/24" + gateway := "172.30.0.1" + + h := NewHarness(t, subnet, gateway) + + // Generate keys + node1Key := state.GenerateKey() + node2Key := state.GenerateKey() + node3Key := state.GenerateKey() + + // IPs in the docker network + node1IP := "172.30.0.10" + node2IP := "172.30.0.11" + node3IP := "172.30.0.12" + + // Internal Nylon IPs + node1NylonIP := "10.0.0.1" + node2NylonIP := "10.0.0.2" + node3NylonIP := "10.0.0.3" + + // Create config directory for this test run + configDir := h.SetupTestDir() + + // 1. Create Central Config + central := state.CentralCfg{ + Routers: []state.RouterCfg{ + SimpleRouter("node1", node1Key.Pubkey(), node1NylonIP, ""), + SimpleRouter("node2", node2Key.Pubkey(), node2NylonIP, node2IP), + SimpleRouter("node3", node3Key.Pubkey(), node3NylonIP, ""), + }, + Graph: []string{ + "node1, node2", + "node2, node3", + }, + Timestamp: time.Now().UnixNano(), + } + + centralPath := h.WriteConfig(configDir, "central.yaml", central) + + // 2. Create Node Configs + node1Cfg := SimpleLocal("node1", node1Key) + node1Path := h.WriteConfig(configDir, "node1.yaml", node1Cfg) + + node2Cfg := SimpleLocal("node2", node2Key) + node2Path := h.WriteConfig(configDir, "node2.yaml", node2Cfg) + + node3Cfg := SimpleLocal("node3", node3Key) + node3Path := h.WriteConfig(configDir, "node3.yaml", node3Cfg) + + // 4. Start Containers in Parallel + h.StartNodes( + NodeSpec{Name: "node1", IP: node1IP, CentralConfigPath: centralPath, NodeConfigPath: node1Path}, + NodeSpec{Name: "node2", IP: node2IP, CentralConfigPath: centralPath, NodeConfigPath: node2Path}, + NodeSpec{Name: "node3", IP: node3IP, CentralConfigPath: centralPath, NodeConfigPath: node3Path}, + ) + + // 5. Wait for convergence + t.Log("Waiting for convergence...") + h.WaitForLog("node3", "installing new route") + h.WaitForLog("node1", "installing new route") + + // 6. Test Connectivity + // Ping from node1 to node2's Nylon IP + t.Logf("Pinging %s from node1...", node2NylonIP) + stdout, stderr, err := h.Exec("node3", []string{"ping", "-c", "3", node1NylonIP}) + if err != nil { + h.PrintLogs("node1") + h.PrintLogs("node2") + h.PrintLogs("node3") + t.Fatalf("Ping failed: %v\nStdout: %s\nStderr: %s", err, stdout, stderr) + } + t.Logf("Ping output:\n%s", stdout) +} diff --git a/e2e/harness.go b/e2e/harness.go new file mode 100644 index 00000000..3a9d55e5 --- /dev/null +++ b/e2e/harness.go @@ -0,0 +1,323 @@ +package e2e + +import ( + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "testing" + "time" + + "github.com/docker/docker/api/types/build" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/pkg/stdcopy" + "github.com/testcontainers/testcontainers-go" + tcnetwork "github.com/testcontainers/testcontainers-go/network" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + ImageName = "nylon-debug:latest" + AppPort = "57175/udp" +) + +type Harness struct { + t *testing.T + mu sync.Mutex + ctx context.Context + Network *testcontainers.DockerNetwork + Nodes map[string]testcontainers.Container + LogBuffers map[string]*LogBuffer + ImageName string + RootDir string +} +type LogBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (l *LogBuffer) Write(p []byte) (n int, err error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.buf.Write(p) +} +func (l *LogBuffer) String() string { + l.mu.Lock() + defer l.mu.Unlock() + return l.buf.String() +} + +// NewHarness creates a test harness with a specific subnet to avoid collisions +func NewHarness(t *testing.T, subnet, gateway string) *Harness { + ctx := context.Background() + // Find root directory (assuming we are in e2e/) + wd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + // Traversing up to find go.mod + rootDir := wd + for { + if _, err := os.Stat(filepath.Join(rootDir, "go.mod")); err == nil { + break + } + parent := filepath.Dir(rootDir) + if parent == rootDir { + t.Fatal("could not find project root") + } + rootDir = parent + } + // Create network with specific subnet + newNetwork, err := tcnetwork.New(ctx, + tcnetwork.WithAttachable(), + tcnetwork.WithDriver("bridge"), + tcnetwork.WithIPAM(&network.IPAM{ + Driver: "default", + Config: []network.IPAMConfig{ + { + Subnet: subnet, + Gateway: gateway, + }, + }, + })) + if err != nil { + t.Fatal(err) + } + h := &Harness{ + t: t, + ctx: ctx, + Network: newNetwork, + Nodes: make(map[string]testcontainers.Container), + LogBuffers: make(map[string]*LogBuffer), + RootDir: rootDir, + } + h.buildImage() + t.Cleanup(func() { + h.Cleanup() + }) + return h +} +func (h *Harness) buildImage() { + h.t.Log("Pre-building nylon-debug:latest image...") + req := testcontainers.ContainerRequest{ + FromDockerfile: testcontainers.FromDockerfile{ + Context: h.RootDir, + Dockerfile: "Dockerfile", + KeepImage: true, + Repo: "nylon-debug", + Tag: "latest", + BuildOptionsModifier: func(buildOptions *build.ImageBuildOptions) { + buildOptions.Target = "debug" + }, + }, + } + + // Creating the container triggers the build + c, err := testcontainers.GenericContainer(h.ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: false, + }) + if err != nil { + h.t.Fatalf("Failed to build image: %v", err) + } + + // We don't need this container, just the image. + if err := c.Terminate(h.ctx); err != nil { + h.t.Logf("Warning: failed to terminate builder container: %v", err) + } +} + +var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) + +func StripAnsi(s string) string { + return ansiRegex.ReplaceAllString(s, "") +} + +type LogConsumer struct { + Name string + Buffer *LogBuffer +} + +func (g *LogConsumer) Accept(l testcontainers.Log) { + content := string(l.Content) + // Strip ANSI codes for easier processing and cleaner output + cleanContent := StripAnsi(content) + fmt.Printf("[%s] %s", g.Name, cleanContent) + if g.Buffer != nil { + g.Buffer.Write([]byte(cleanContent)) + } +} + +type NodeSpec struct { + Name string + IP string + CentralConfigPath string + NodeConfigPath string +} + +func (h *Harness) StartNodes(specs ...NodeSpec) { + var wg sync.WaitGroup + wg.Add(len(specs)) + for _, spec := range specs { + go func(s NodeSpec) { + defer wg.Done() + h.StartNode(s.Name, s.IP, s.CentralConfigPath, s.NodeConfigPath) + }(spec) + } + wg.Wait() +} +func (h *Harness) StartNode(name string, ip string, centralConfigPath, nodeConfigPath string) testcontainers.Container { + h.t.Logf("Starting node %s at %s", name, ip) + req := testcontainers.ContainerRequest{ + Image: ImageName, + Networks: []string{h.Network.Name}, + NetworkAliases: map[string][]string{ + h.Network.Name: {name}, + }, + Files: []testcontainers.ContainerFile{ + { + HostFilePath: centralConfigPath, + ContainerFilePath: "/app/config/central.yaml", + FileMode: 0644, + }, + { + HostFilePath: nodeConfigPath, + ContainerFilePath: "/app/config/node.yaml", + FileMode: 0644, + }, + }, + Cmd: nil, // Entrypoint already handles "run -v" + Env: map[string]string{ + "NYLON_LOG_LEVEL": "debug", + }, + WaitingFor: wait.ForLog("Nylon has been initialized").WithStartupTimeout(15 * time.Second), + HostConfigModifier: func(hostConfig *container.HostConfig) { + hostConfig.Privileged = true + hostConfig.CapAdd = []string{"NET_ADMIN"} + }, + EndpointSettingsModifier: func(m map[string]*network.EndpointSettings) { + if ip != "" { + if s, ok := m[h.Network.Name]; ok { + s.IPAMConfig = &network.EndpointIPAMConfig{ + IPv4Address: ip, + } + } + } + }, + } + container, err := testcontainers.GenericContainer(h.ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + h.t.Fatalf("failed to start container %s: %v", name, err) + } + buffer := &LogBuffer{} + h.mu.Lock() + h.LogBuffers[name] = buffer + h.Nodes[name] = container + h.mu.Unlock() + container.FollowOutput(&LogConsumer{Name: name, Buffer: buffer}) + container.StartLogProducer(h.ctx) + return container +} +func (h *Harness) WaitForLog(nodeName string, pattern string) { + h.mu.Lock() + buffer, ok := h.LogBuffers[nodeName] + h.mu.Unlock() + if !ok { + h.t.Fatalf("log buffer for node %s not found", nodeName) + } + // Poll the buffer + timeout := time.After(15 * time.Second) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-timeout: + h.t.Logf("buffer: %s", buffer.String()) + h.t.Fatalf("timed out waiting for log pattern %q in node %s", pattern, nodeName) + case <-ticker.C: + if strings.Contains(buffer.String(), pattern) { + return + } + case <-h.ctx.Done(): + h.t.Fatal("context canceled") + } + } +} +func (h *Harness) Cleanup() { + h.mu.Lock() + defer h.mu.Unlock() + for name, c := range h.Nodes { + if err := c.Terminate(h.ctx); err != nil { + h.t.Logf("failed to terminate container %s: %v", name, err) + } + } +} +func (h *Harness) Exec(nodeName string, cmd []string) (string, string, error) { + h.mu.Lock() + container, ok := h.Nodes[nodeName] + h.mu.Unlock() + + if !ok { + return "", "", fmt.Errorf("node %s not found", nodeName) + } + + code, r, err := container.Exec(h.ctx, cmd) + if err != nil { + return "", "", err + } + + stdoutBuf := new(bytes.Buffer) + stderrBuf := new(bytes.Buffer) + + // Demultiplex the stream using stdcopy + _, err = stdcopy.StdCopy(stdoutBuf, stderrBuf, r) + if err != nil { + return "", "", fmt.Errorf("failed to copy output: %w", err) + } + + stdout := StripAnsi(stdoutBuf.String()) + stderr := StripAnsi(stderrBuf.String()) + + if code != 0 { + return stdout, stderr, fmt.Errorf("command exited with code %d: %s\nStderr: %s", code, stdout, stderr) + } + + return stdout, stderr, nil +} + +// GetIP returns the IP address of the node in the test network +func (h *Harness) GetIP(nodeName string) (string, error) { + h.mu.Lock() + container, ok := h.Nodes[nodeName] + h.mu.Unlock() + if !ok { + return "", fmt.Errorf("node %s not found", nodeName) + } + return container.ContainerIP(h.ctx) +} +func (h *Harness) PrintLogs(nodeName string) { + h.mu.Lock() + container, ok := h.Nodes[nodeName] + h.mu.Unlock() + if !ok { + h.t.Logf("node %s not found for logging", nodeName) + return + } + r, err := container.Logs(h.ctx) + if err != nil { + h.t.Logf("failed to get logs for %s: %v", nodeName, err) + return + } + buf := new(bytes.Buffer) + io.Copy(buf, r) + h.t.Logf("Logs for %s:\n%s", nodeName, buf.String()) +} diff --git a/e2e/utils.go b/e2e/utils.go new file mode 100644 index 00000000..a94cc184 --- /dev/null +++ b/e2e/utils.go @@ -0,0 +1,65 @@ +package e2e + +import ( + "fmt" + "net/netip" + "os" + "path/filepath" + + "github.com/encodeous/nylon/state" + "github.com/goccy/go-yaml" +) + +// SetupTestDir creates a directory for the current test run +func (h *Harness) SetupTestDir() string { + dir := filepath.Join(h.RootDir, "e2e", "runs", h.t.Name()) + // Clean up previous run + os.RemoveAll(dir) + if err := os.MkdirAll(dir, 0755); err != nil { + h.t.Fatal(err) + } + return dir +} + +// WriteConfig marshals the config to YAML and writes it to the specified directory with the given filename +func (h *Harness) WriteConfig(dir, filename string, cfg any) string { + path := filepath.Join(dir, filename) + data, err := yaml.Marshal(cfg) + if err != nil { + h.t.Fatal(err) + } + if err := os.WriteFile(path, data, 0644); err != nil { + h.t.Fatal(err) + } + return path +} + +// SimpleRouter creates a basic RouterCfg with the given parameters +func SimpleRouter(id string, pubKey state.NyPublicKey, nylonIP string, endpointIP string) state.RouterCfg { + cfg := state.RouterCfg{ + NodeCfg: state.NodeCfg{ + Id: state.NodeId(id), + PubKey: pubKey, + Addresses: []netip.Addr{ + netip.MustParseAddr(nylonIP), + }, + }, + } + if endpointIP != "" { + cfg.Endpoints = []netip.AddrPort{ + netip.MustParseAddrPort(fmt.Sprintf("%s:57175", endpointIP)), + } + } + return cfg +} + +// SimpleLocal creates a basic LocalCfg with the given parameters and defaults +func SimpleLocal(id string, key state.NyPrivateKey) state.LocalCfg { + return state.LocalCfg{ + Id: state.NodeId(id), + Key: key, + Port: 57175, + NoNetConfigure: false, + InterfaceName: "nylon0", + } +} diff --git a/go.mod b/go.mod index 13a4a3f7..5b0b23da 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.4 require ( github.com/cilium/cilium v1.18.4 github.com/digineo/go-ping v1.2.0 + github.com/docker/docker v28.5.1+incompatible github.com/encodeous/metric v0.0.0-20251111175231-f339c2f7c4bd github.com/encodeous/tint v1.2.0 github.com/gaissmai/bart v0.25.0 @@ -15,34 +16,80 @@ require ( github.com/samber/slog-multi v1.5.0 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 + github.com/testcontainers/testcontainers-go v0.40.0 go.step.sm/crypto v0.70.0 go.uber.org/goleak v1.3.0 golang.org/x/crypto v0.45.0 golang.org/x/net v0.47.0 - golang.org/x/sys v0.38.0 + golang.org/x/sys v0.39.0 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 google.golang.org/protobuf v1.36.10 gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c ) require ( + dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/digineo/go-logwrap v0.0.0-20181106161722-a178c58ea3f0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/ebitengine/purego v0.8.4 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/google/btree v1.1.3 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/kr/pretty v0.3.1 // indirect + github.com/klauspost/compress v1.18.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/samber/lo v1.51.0 // indirect github.com/samber/slog-common v0.19.0 // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/sirupsen/logrus v1.9.3 // indirect github.com/spf13/pflag v1.0.10 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/sdk v1.39.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.12.0 // indirect - gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index ec57bb27..529e16f0 100644 --- a/go.sum +++ b/go.sum @@ -1,44 +1,121 @@ +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cilium/cilium v1.18.4 h1:/HrbeMmk46UDkJs4uJemIqxB7wuZ+QRMNVscNYua5C8= github.com/cilium/cilium v1.18.4/go.mod h1:PPAuhDhMHOLaAiraQKDxEHUTmE68RrDlZj3988R+Lco= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/digineo/go-logwrap v0.0.0-20181106161722-a178c58ea3f0 h1:OT/LKmj81wMymnWXaKaKBR9n1vPlu+GC0VVKaZP6kzs= github.com/digineo/go-logwrap v0.0.0-20181106161722-a178c58ea3f0/go.mod h1:DmqdumeAKGQNU5E8MN0ruT5ZGx8l/WbAsMbXCXcSEts= github.com/digineo/go-ping v1.2.0 h1:/9vEsoCRtQvol5vRMA2pE8guuhUVDSJB2ok2nKuJJbA= github.com/digineo/go-ping v1.2.0/go.mod h1:cXJTVTs7mthQ41c/nWykPYuhlDQwCN0ba5LyOnyYv8Y= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= +github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/encodeous/metric v0.0.0-20251111175231-f339c2f7c4bd h1:B32Ob80QTv5MomcVt709TsiWyD0QrpUYtnwW1jQFNlE= github.com/encodeous/metric v0.0.0-20251111175231-f339c2f7c4bd/go.mod h1:DiXCPJtfZYioejF9zv9wfs3TXqWWglKGQ20DsBNVWVw= github.com/encodeous/tint v1.2.0 h1:1Y+32Iu+C8MXBoNjsM4YDf6iAkcks7csAI9f7b4fr8k= github.com/encodeous/tint v1.2.0/go.mod h1:pyfyH+fKmtmIuWVWeMcSJjJKVJSb9sVlIfVsW3jkP+Q= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gaissmai/bart v0.25.0 h1:eqiokVPqM3F94vJ0bTHXHtH91S8zkKL+bKh+BsGOsJM= github.com/gaissmai/bart v0.25.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE= github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/kmahyyg/go-network-compo v0.2.10 h1:H5rZ59xxmaZsIucz5FGV56ww+nzR3k1RFaKEN2bYRog= github.com/kmahyyg/go-network-compo v0.2.10/go.mod h1:LN1qGQuWqk6v27GXWWiXGIaYmV8TUPZrPGgm0LFwmbU= -github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -48,17 +125,53 @@ github.com/samber/slog-common v0.19.0 h1:fNcZb8B2uOLooeYwFpAlKjkQTUafdjfqKcwcC89 github.com/samber/slog-common v0.19.0/go.mod h1:dTz+YOU76aH007YUU0DffsXNsGFQRQllPQh9XyNoA3M= github.com/samber/slog-multi v1.5.0 h1:UDRJdsdb0R5vFQFy3l26rpX3rL3FEPJTJ2yKVjoiT1I= github.com/samber/slog-multi v1.5.0/go.mod h1:im2Zi3mH/ivSY5XDj6LFcKToRIWPw1OcjSVSdXt+2d0= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0 h1:cMyu9O88joYEaI47CnQkxO1XZdpoTF9fEnW2duIddhw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.32.0/go.mod h1:6Am3rn7P9TVVeXYG+wtcGE7IE1tsQ+bP3AuWcKt/gOI= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.step.sm/crypto v0.70.0 h1:Q9Ft7N637mucyZcHZd1+0VVQJVwDCKqcb9CYcYi7cds= go.step.sm/crypto v0.70.0/go.mod h1:pzfUhS5/ue7ev64PLlEgXvhx1opwbhFCjkvlhsxVds0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -71,22 +184,41 @@ golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= +google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c h1:m/r7OM+Y2Ty1sgBQ7Qb27VgIMBW8ZZhT4gLnUyDIhzI= gvisor.dev/gvisor v0.0.0-20250503011706-39ed1f5ac29c/go.mod h1:3r5CMtNQMKIvBlrmM9xWUNamjKBYPOWyXOjmg5Kts3g= diff --git a/smoke/fixtures/testcentral1.yaml b/smoke/fixtures/testcentral1.yaml deleted file mode 100755 index 7fe4d13a..00000000 --- a/smoke/fixtures/testcentral1.yaml +++ /dev/null @@ -1,18 +0,0 @@ -dist: - key: YjlOIfTaJVGZ+j638l/j4KKHl26MPwyfmjIoKiIWhHs= - repos: - - file:~/.nylon/central.nybundle - - https://127.0.0.1:8000/example/central.nybundle -routers: - - id: sample_node1 - pubkey: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - addresses: [10.0.0.1] - endpoints: - - 8.8.8.8:57175 -clients: - - id: external-client - pubkey: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= - addresses: [10.2.0.1] -graph: - - Group1 = sample_node1, sample_node1 -timestamp: 1740879895918834978 diff --git a/smoke/fixtures/testnode1.yaml b/smoke/fixtures/testnode1.yaml deleted file mode 100644 index ab6597fb..00000000 --- a/smoke/fixtures/testnode1.yaml +++ /dev/null @@ -1,5 +0,0 @@ -key: IG5bLY5ar8+IXeqgI4pVUYqqQFbyCVhF2qUA4f/54Uo= -id: sample_node1 -port: 57175 -no_net_configure: false - diff --git a/smoke/smoke-test.sh b/smoke/smoke-test.sh deleted file mode 100755 index 1249f7ab..00000000 --- a/smoke/smoke-test.sh +++ /dev/null @@ -1,120 +0,0 @@ -#!/bin/bash -set -e - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# Detect if terminal supports colors -if [ -t 1 ] && command -v tput &> /dev/null && tput colors &> /dev/null && [ $(tput colors) -ge 8 ]; then - USE_COLOR=true -else - USE_COLOR=false -fi - -print_status() { - if [ "$USE_COLOR" = true ]; then - echo -e "${GREEN}✓${NC} $1" - else - echo "✓ $1" - fi -} - -print_error() { - if [ "$USE_COLOR" = true ]; then - echo -e "${RED}✗${NC} $1" - else - echo "✗ $1" - fi -} - -print_info() { - if [ "$USE_COLOR" = true ]; then - echo -e "${YELLOW}ℹ${NC} $1" - else - echo "ℹ $1" - fi -} - -# Change to script directory -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$SCRIPT_DIR" - -# Check if nylon binary exists -NYLON_BINARY="../nylon" -if [ ! -f "$NYLON_BINARY" ]; then - print_error "Nylon binary not found at $NYLON_BINARY" - print_info "Please build the binary first: go build -o nylon" - exit 1 -fi - -# Make nylon binary executable -chmod +x "$NYLON_BINARY" - -print_info "Running smoke tests for Nylon..." -echo "" - -# Test 1: Check that nylon executes and shows help -print_info "Test 1: Checking if nylon executes and shows help text..." - -CONTAINER_NAME="nylon-smoke-test-1-$$" -docker run --rm --name "$CONTAINER_NAME" \ - -v "$(cd .. && pwd)/nylon:/nylon:ro" \ - busybox:1.37-glibc \ - /nylon > /tmp/nylon-output-1.txt 2>&1 || true - -if grep -q "Nylon is a mesh networking system." /tmp/nylon-output-1.txt; then - print_status "Test 1 PASSED: Nylon executes and shows help text" -else - print_error "Test 1 FAILED: Expected help text not found" - cat /tmp/nylon-output-1.txt - rm -f /tmp/nylon-output-1.txt - exit 1 -fi -rm -f /tmp/nylon-output-1.txt -echo "" - -# Test 2: Check that nylon runs with config files -print_info "Test 2: Checking if nylon runs with config files..." - -CONTAINER_NAME="nylon-smoke-test-2-$$" -CONTAINER_ID=$(docker run -d --name "$CONTAINER_NAME" \ - --cap-add=NET_ADMIN \ - -v "$(cd .. && pwd)/nylon:/nylon:ro" \ - -v "$SCRIPT_DIR/fixtures/testcentral1.yaml:/central.yaml:ro" \ - -v "$SCRIPT_DIR/fixtures/testnode1.yaml:/node.yaml:ro" \ - busybox:1.37-glibc \ - sh -c "mkdir -p /dev/net && mknod /dev/net/tun c 10 200 && /nylon -c /central.yaml -n /node.yaml run") - -# Wait for the expected log message (with timeout) -TIMEOUT=10 -ELAPSED=0 -SUCCESS=false - -while [ $ELAPSED -lt $TIMEOUT ]; do - if docker logs "$CONTAINER_ID" 2>&1 | grep -q "Nylon has been initialized. To gracefully exit, send SIGINT or Ctrl+C."; then - SUCCESS=true - break - fi - sleep 1 - ELAPSED=$((ELAPSED + 1)) -done - -# Cleanup -docker stop "$CONTAINER_ID" > /dev/null 2>&1 || true -docker rm "$CONTAINER_ID" > /dev/null 2>&1 || true - -if [ "$SUCCESS" = true ]; then - print_status "Test 2 PASSED: Nylon runs successfully with config files" -else - print_error "Test 2 FAILED: Expected initialization message not found within ${TIMEOUT}s" - docker logs "$CONTAINER_ID" 2>&1 || true - exit 1 -fi -echo "" - -print_status "All smoke tests passed!" -exit 0 - diff --git a/state/config_test.go b/state/config_test.go index 87cedfb4..bdc4e3f8 100644 --- a/state/config_test.go +++ b/state/config_test.go @@ -1,9 +1,11 @@ package state import ( - "github.com/stretchr/testify/assert" + "net/netip" "strings" "testing" + + "github.com/stretchr/testify/assert" ) func TestParseGraph_SimpleGraph(t *testing.T) { @@ -148,3 +150,32 @@ func TestParseGraph_InvalidGraph(t *testing.T) { failGraph(t, `,,,,,,,,,,,,,,,,`) failGraph(t, `a=a`) } + +func TestSubtractPrefixDirect(t *testing.T) { + includes := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.1/32"), + netip.MustParsePrefix("10.0.0.2/32"), + netip.MustParsePrefix("10.0.0.3/32"), + } + excludes := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.3/32"), + } + result := SubtractPrefix(includes, excludes) + assert.ElementsMatch(t, result, []netip.Prefix{ + netip.MustParsePrefix("10.0.0.1/32"), + netip.MustParsePrefix("10.0.0.2/32"), + }) +} + +func TestSubtractPrefixLargerRange(t *testing.T) { + includes := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.1/32"), + netip.MustParsePrefix("10.0.0.2/32"), + netip.MustParsePrefix("10.0.0.3/32"), + } + excludes := []netip.Prefix{ + netip.MustParsePrefix("10.0.0.0/24"), + } + result := SubtractPrefix(includes, excludes) + assert.ElementsMatch(t, result, []netip.Prefix{}) +} diff --git a/unit-test-coverage.sh b/unit-test-coverage.sh deleted file mode 100755 index d1fd6836..00000000 --- a/unit-test-coverage.sh +++ /dev/null @@ -1,8 +0,0 @@ -#!/bin/bash -set -e - -echo "Running tests with coverage..." -go test -v -coverprofile=coverage.out -tags=router_test ./... -echo "Generating HTML coverage report..." -go tool cover -html=coverage.out -o coverage.html -echo "Coverage report generated: coverage.html" From 7123059470d113ffdea81c3f89cffd2f91c7939f Mon Sep 17 00:00:00 2001 From: Adam Chen Date: Sun, 18 Jan 2026 23:45:05 +0000 Subject: [PATCH 2/4] cnkpt: healthcheck --- .github/workflows/go-test.yml | 6 +- Dockerfile | 9 ++- core/nylon_wireguard.go | 2 +- e2e/connectivity_test.go | 7 +- e2e/harness.go | 7 +- e2e/healthcheck_test.go | 128 ++++++++++++++++++++++++++++++++++ e2e/utils.go | 2 + mise.toml | 2 + state/constants.go | 4 ++ state/prefix_health.go | 37 ++++++---- state/prefix_health_test.go | 9 ++- state/validation.go | 6 +- 12 files changed, 188 insertions(+), 31 deletions(-) create mode 100644 e2e/healthcheck_test.go create mode 100644 mise.toml diff --git a/.github/workflows/go-test.yml b/.github/workflows/go-test.yml index 87fdd92f..d73979ca 100644 --- a/.github/workflows/go-test.yml +++ b/.github/workflows/go-test.yml @@ -23,8 +23,6 @@ jobs: - name: Build run: go build - - name: Run unit tests - run: go test -v -tags=router_test ./... - - name: Run integration tests - run: go test -v -tags=integration ./... + - name: Run all tests + run: go test -v -tags=router_test,integration,e2e ./... diff --git a/Dockerfile b/Dockerfile index 8a19402b..668b445b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,10 @@ ENTRYPOINT ["/usr/local/bin/nylon", "run", "-v"] FROM ubuntu:latest AS debug -RUN apt-get update && apt-get install -y \ +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ iputils-ping \ iperf3 \ curl \ @@ -23,8 +26,8 @@ RUN apt-get update && apt-get install -y \ net-tools \ tcpdump \ dnsutils \ - vim \ - && rm -rf /var/lib/apt/lists/* + netcat-openbsd && \ + rm -rf /var/lib/apt/lists/* COPY --from=builder /nylon /usr/local/bin/nylon diff --git a/core/nylon_wireguard.go b/core/nylon_wireguard.go index b5c81837..676c3728 100644 --- a/core/nylon_wireguard.go +++ b/core/nylon_wireguard.go @@ -127,7 +127,7 @@ func (n *Nylon) cleanupWireGuard(s *state.State) error { } } // run pre-down commands - for _, cmd := range s.PreUp { + for _, cmd := range s.PreDown { err := ExecSplit(s.Log, cmd) if err != nil { s.Log.Error("failed to run pre-down command", "err", err) diff --git a/e2e/connectivity_test.go b/e2e/connectivity_test.go index 6aa1e3d7..6e3b5bf5 100644 --- a/e2e/connectivity_test.go +++ b/e2e/connectivity_test.go @@ -1,3 +1,5 @@ +//go:build e2e + package e2e import ( @@ -11,6 +13,7 @@ func TestConnectivity(t *testing.T) { if testing.Short() { t.Skip("skipping e2e test in short mode") } + t.Parallel() // Use a specific subnet for this test to avoid conflicts subnet := "172.30.0.0/24" @@ -71,8 +74,8 @@ func TestConnectivity(t *testing.T) { // 5. Wait for convergence t.Log("Waiting for convergence...") - h.WaitForLog("node3", "installing new route") - h.WaitForLog("node1", "installing new route") + h.WaitForLog("node3", "installing new route prefix=10.0.0.1/32") + h.WaitForLog("node1", "installing new route prefix=10.0.0.2/31") // 6. Test Connectivity // Ping from node1 to node2's Nylon IP diff --git a/e2e/harness.go b/e2e/harness.go index 3a9d55e5..5081625a 100644 --- a/e2e/harness.go +++ b/e2e/harness.go @@ -1,3 +1,5 @@ +//go:build e2e + package e2e import ( @@ -103,6 +105,7 @@ func NewHarness(t *testing.T, subnet, gateway string) *Harness { }) return h } + func (h *Harness) buildImage() { h.t.Log("Pre-building nylon-debug:latest image...") req := testcontainers.ContainerRequest{ @@ -241,7 +244,6 @@ func (h *Harness) WaitForLog(nodeName string, pattern string) { for { select { case <-timeout: - h.t.Logf("buffer: %s", buffer.String()) h.t.Fatalf("timed out waiting for log pattern %q in node %s", pattern, nodeName) case <-ticker.C: if strings.Contains(buffer.String(), pattern) { @@ -260,6 +262,9 @@ func (h *Harness) Cleanup() { h.t.Logf("failed to terminate container %s: %v", name, err) } } + if err := h.Network.Remove(context.Background()); err != nil { + h.t.Logf("failed to remove network: %v", err) + } } func (h *Harness) Exec(nodeName string, cmd []string) (string, string, error) { h.mu.Lock() diff --git a/e2e/healthcheck_test.go b/e2e/healthcheck_test.go new file mode 100644 index 00000000..5711ff42 --- /dev/null +++ b/e2e/healthcheck_test.go @@ -0,0 +1,128 @@ +//go:build e2e + +package e2e + +import ( + "net/netip" + "testing" + "time" + + "github.com/encodeous/nylon/state" +) + +func TestHealthcheckPing(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + t.Parallel() + + // Use a specific subnet for this test to avoid conflicts + subnet := "172.30.1.0/24" + gateway := "172.30.1.1" + + h := NewHarness(t, subnet, gateway) + + // Generate keys + node1Key := state.GenerateKey() + node2Key := state.GenerateKey() + node3Key := state.GenerateKey() + + // IPs in the docker network + node1IP := "172.30.1.10" + node2IP := "172.30.1.11" + node3IP := "172.30.1.12" + + // Internal Nylon IPs + node1NylonIP := "10.0.0.1" + node2NylonIP := "10.0.0.2" + node3NylonIP := "10.0.0.3" + + // Create config directory for this test run + configDir := h.SetupTestDir() + + // 1. Create Central Config + central := state.CentralCfg{ + Routers: []state.RouterCfg{ + SimpleRouter("node1", node1Key.Pubkey(), node1NylonIP, ""), + SimpleRouter("node2", node2Key.Pubkey(), node2NylonIP, node2IP), + SimpleRouter("node3", node3Key.Pubkey(), node3NylonIP, ""), + }, + Graph: []string{ + "node1, node2", + "node2, node3", + }, + Timestamp: time.Now().UnixNano(), + } + + // make node 1 and node 2 both advertise 10.0.0.4/32 + // 1 would be default + n1Metric := uint32(10) + central.Routers[0].Prefixes = []state.PrefixHealthWrapper{ + { + &state.PingPrefixHealth{ + Prefix: netip.MustParsePrefix("10.0.1.4/32"), + Addr: netip.MustParseAddr("10.0.1.4"), + Metric: &n1Metric, + }, + }, + } + // 2 would be fallback + n2Metric := uint32(1000) + central.Routers[1].Prefixes = []state.PrefixHealthWrapper{ + { + &state.PingPrefixHealth{ + Prefix: netip.MustParsePrefix("10.0.1.4/32"), + Addr: netip.MustParseAddr("10.0.1.4"), + Metric: &n2Metric, + }, + }, + } + + centralPath := h.WriteConfig(configDir, "central.yaml", central) + + // 2. Create Node Configs + node1Cfg := SimpleLocal("node1", node1Key) + node2Cfg := SimpleLocal("node2", node2Key) + node3Cfg := SimpleLocal("node3", node3Key) + + // add a dummy loopback interface on each node + node1Cfg.PreUp = append(node1Cfg.PreUp, "ip addr add 10.0.1.4/32 dev lo") + node1Cfg.PreUp = append(node1Cfg.PreUp, "ip route add 10.0.1.4/32 dev lo") + node2Cfg.PreUp = append(node2Cfg.PreUp, "ip addr add 10.0.1.4/32 dev lo") + node2Cfg.PreUp = append(node2Cfg.PreUp, "ip route add 10.0.1.4/32 dev lo") + + node1Path := h.WriteConfig(configDir, "node1.yaml", node1Cfg) + node2Path := h.WriteConfig(configDir, "node2.yaml", node2Cfg) + node3Path := h.WriteConfig(configDir, "node3.yaml", node3Cfg) + + // 4. Start Containers in Parallel + h.StartNodes( + NodeSpec{Name: "node1", IP: node1IP, CentralConfigPath: centralPath, NodeConfigPath: node1Path}, + NodeSpec{Name: "node2", IP: node2IP, CentralConfigPath: centralPath, NodeConfigPath: node2Path}, + NodeSpec{Name: "node3", IP: node3IP, CentralConfigPath: centralPath, NodeConfigPath: node3Path}, + ) + + // 5. Wait for convergence + t.Log("Waiting for convergence...") + h.WaitForLog("node3", "installing new route prefix=10.0.1.4/32") + h.WaitForLog("node1", "installing new route prefix=10.0.0") + + // ping from 3 to 10.0.0.4 + stdout, stderr, err := h.Exec("node3", []string{"ping", "-c", "3", "10.0.1.4"}) + if err != nil { + t.Fatalf("Ping failed: %v\nStdout: %s\nStderr: %s", err, stdout, stderr) + } + t.Logf("Ping output:\n%s", stdout) + + // listen on node 1 + stdout, stderr, err = h.Exec("node1", []string{"bash", "-c", "nc -l -p 8888 &"}) + if err != nil { + t.Fatalf("Failed: %v\nStdout: %s\nStderr: %s", err, stdout, stderr) + } + // send on node 3 + stdout, stderr, err = h.Exec("node3", []string{"bash", "-c", "echo 'hello from node 3'"}) + if err != nil { + t.Fatalf("Failed: %v\nStdout: %s\nStderr: %s", err, stdout, stderr) + } + h.WaitForLog("node1", "hello from node 3") +} diff --git a/e2e/utils.go b/e2e/utils.go index a94cc184..7109c66d 100644 --- a/e2e/utils.go +++ b/e2e/utils.go @@ -1,3 +1,5 @@ +//go:build e2e + package e2e import ( diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..89dbe7ba --- /dev/null +++ b/mise.toml @@ -0,0 +1,2 @@ +[tools] +go = "1.25.4" diff --git a/state/constants.go b/state/constants.go index 3c741d41..42fce72b 100644 --- a/state/constants.go +++ b/state/constants.go @@ -37,4 +37,8 @@ var ( // central updates CentralUpdateDelay = time.Second * 10 + + // healthcheck defaults + HealthCheckDelay = time.Second * 15 + HealthCheckMaxFailures = 3 ) diff --git a/state/prefix_health.go b/state/prefix_health.go index 62763109..27db535d 100644 --- a/state/prefix_health.go +++ b/state/prefix_health.go @@ -40,12 +40,12 @@ func (s *StaticPrefixHealth) Start(log *slog.Logger) { } type PingPrefixHealth struct { - Prefix netip.Prefix `yaml:"prefix"` - Addr netip.Addr `yaml:"addr"` // the address to ping - MaxFailures int `yaml:"max_failures,omitempty"` // number of failures before returning infinite metric - Delay time.Duration `yaml:"delay,omitempty"` // delay between pings - BindIf string `yaml:"bind_if,omitempty"` // local interface to bind to - Metric *uint32 `yaml:"metric,omitempty"` // metric override + Prefix netip.Prefix `yaml:"prefix"` + Addr netip.Addr `yaml:"addr"` // the address to ping + MaxFailures *int `yaml:"max_failures,omitempty"` // number of failures before returning infinite metric + Delay *time.Duration `yaml:"delay,omitempty"` // delay between pings + BindIf string `yaml:"bind_if,omitempty"` // local interface to bind to + Metric *uint32 `yaml:"metric,omitempty"` // metric override lastMetric uint32 running atomic.Bool } @@ -88,10 +88,16 @@ func (p *PingPrefixHealth) GetPrefix() netip.Prefix { } func (p *PingPrefixHealth) Start(log *slog.Logger) { p.running.Swap(true) + if p.Delay == nil { + p.Delay = &HealthCheckDelay + } + if p.MaxFailures == nil { + p.MaxFailures = &HealthCheckMaxFailures + } go func() { - ticker := time.NewTicker(p.Delay) + ticker := time.NewTicker(*p.Delay) for p.running.Load() { - time.Sleep(p.Delay) + time.Sleep(*p.Delay) p.lastMetric = INF bind4 := "" bind6 := "" @@ -122,7 +128,7 @@ func (p *PingPrefixHealth) Start(log *slog.Logger) { <-ticker.C // ICMP ping addr := &net.IPAddr{IP: net.IP(p.Addr.AsSlice())} - rtt, err := pinger.PingAttempts(addr, time.Duration(int64(p.Delay)/int64(p.MaxFailures)), p.MaxFailures) + rtt, err := pinger.PingAttempts(addr, time.Duration(int64(*p.Delay)/int64(*p.MaxFailures)), *p.MaxFailures) if err != nil { // failed p.lastMetric = INF @@ -139,10 +145,10 @@ func (p *PingPrefixHealth) Start(log *slog.Logger) { } type HTTPPrefixHealth struct { - Prefix netip.Prefix `yaml:"prefix"` - URL string `yaml:"url"` // the URL to check - Delay time.Duration `yaml:"delay,omitempty"` // delay between probes - Metric *uint32 `yaml:"metric,omitempty"` // metric override + Prefix netip.Prefix `yaml:"prefix"` + URL string `yaml:"url"` // the URL to check + Delay *time.Duration `yaml:"delay,omitempty"` // delay between probes + Metric *uint32 `yaml:"metric,omitempty"` // metric override lastMetric uint32 running atomic.Bool } @@ -163,8 +169,11 @@ func (h *HTTPPrefixHealth) GetPrefix() netip.Prefix { func (h *HTTPPrefixHealth) Start(log *slog.Logger) { h.lastMetric = INF h.running.Swap(true) + if h.Delay == nil { + h.Delay = &HealthCheckDelay + } go func() { - ticker := time.NewTicker(h.Delay) + ticker := time.NewTicker(*h.Delay) defer ticker.Stop() for h.running.Load() { // TODO: add a way to interrupt this sleep, if ticker has a high delay <-ticker.C diff --git a/state/prefix_health_test.go b/state/prefix_health_test.go index fd2deb3b..39543868 100644 --- a/state/prefix_health_test.go +++ b/state/prefix_health_test.go @@ -10,6 +10,9 @@ import ( ) func TestPrefixHealthSerialization(t *testing.T) { + threeFails := 3 + tenSecond := 10 * time.Second + fiveSecond := 5 * time.Second tests := []struct { name string wrapper PrefixHealthWrapper @@ -34,8 +37,8 @@ metric: 100 PrefixHealth: &PingPrefixHealth{ Prefix: netip.MustParsePrefix("192.168.1.0/24"), Addr: netip.MustParseAddr("8.8.8.8"), - MaxFailures: 3, - Delay: 10 * time.Second, + MaxFailures: &threeFails, + Delay: &tenSecond, }, }, yamlStr: `type: ping @@ -51,7 +54,7 @@ delay: 10s PrefixHealth: &HTTPPrefixHealth{ Prefix: netip.MustParsePrefix("172.16.0.0/16"), URL: "http://example.com/health", - Delay: 5 * time.Second, + Delay: &fiveSecond, }, }, yamlStr: `type: http diff --git a/state/validation.go b/state/validation.go index 1f36a59c..5032a315 100644 --- a/state/validation.go +++ b/state/validation.go @@ -171,10 +171,10 @@ func CentralConfigValidator(cfg *CentralCfg) error { if !v.Addr.IsValid() { return fmt.Errorf("invalid ping address %s for prefix %s", v.Addr, p.GetPrefix()) } - if v.Delay <= 0 { + if v.Delay != nil && *v.Delay <= 0 { return fmt.Errorf("ping delay must be greater than 0 for prefix %s", p.GetPrefix()) } - if v.MaxFailures <= 0 { + if v.MaxFailures != nil && *v.MaxFailures <= 0 { return fmt.Errorf("ping max_failures must be greater than 0 for prefix %s", p.GetPrefix()) } case *HTTPPrefixHealth: @@ -182,7 +182,7 @@ func CentralConfigValidator(cfg *CentralCfg) error { if err != nil { return fmt.Errorf("invalid HTTP URL %s for prefix %s: %v", v.URL, p.GetPrefix(), err) } - if v.Delay <= 0 { + if v.Delay != nil && *v.Delay <= 0 { return fmt.Errorf("HTTP delay must be greater than 0 for prefix %s", p.GetPrefix()) } default: From 81157b72438fc416744cb10170c8652cfcfc605d Mon Sep 17 00:00:00 2001 From: Adam Chen Date: Sat, 31 Jan 2026 21:49:21 +0000 Subject: [PATCH 3/4] fix routing instability, route not installing. add healthcheck test --- Dockerfile | 4 +-- cmd/run.go | 1 + core/ipc.go | 19 +++++++++++++ core/nylon.go | 2 +- core/nylon_tc.go | 33 +++++++++++++++++++++- core/router.go | 2 +- core/router_algo.go | 16 +++++------ core/router_harness.go | 2 +- e2e/harness.go | 62 +++++++++++++++++++++++++++++++++++++++++ e2e/healthcheck_test.go | 20 ++++++++----- state/debug.go | 1 + state/routing.go | 29 ++++++++++++++++++- 12 files changed, 168 insertions(+), 23 deletions(-) diff --git a/Dockerfile b/Dockerfile index 668b445b..1c9d86f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,7 @@ COPY --from=builder /nylon /usr/local/bin/nylon WORKDIR /app/config -ENTRYPOINT ["/usr/local/bin/nylon", "run", "-v"] +ENTRYPOINT ["/usr/local/bin/nylon", "run"] FROM ubuntu:latest AS debug @@ -33,4 +33,4 @@ COPY --from=builder /nylon /usr/local/bin/nylon WORKDIR /app/config -ENTRYPOINT ["/usr/local/bin/nylon", "run", "-v"] +ENTRYPOINT ["/usr/local/bin/nylon", "run", "-v", "-w", "--dbg-trace-tc"] diff --git a/cmd/run.go b/cmd/run.go index dae1dadc..afbdd89f 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -37,6 +37,7 @@ func init() { runCmd.Flags().BoolVarP(&state.DBG_log_repo_updates, "dbg-repo", "", false, "Outputs repo updates to the console") runCmd.Flags().BoolVarP(&state.DBG_debug, "dbg-perf", "", false, "Enables performance debugging server on port 6060") runCmd.Flags().BoolVarP(&state.DBG_trace, "dbg-trace", "", false, "Enables trace to trace.out") + runCmd.Flags().BoolVarP(&state.DBG_trace_tc, "dbg-trace-tc", "", false, "Enables logging of packet routing") runCmd.Flags().StringP("config", "c", DefaultConfigPath, "Path to the config file") runCmd.Flags().StringP("node", "n", DefaultNodeConfigPath, "Path to the node config file") runCmd.Flags().StringP("log", "l", "", "Path to the log file (overrides config)") diff --git a/core/ipc.go b/core/ipc.go index 2791cf76..092bda13 100644 --- a/core/ipc.go +++ b/core/ipc.go @@ -100,6 +100,25 @@ func HandleNylonIPCGet(s *state.State, rw *bufio.ReadWriter) error { } slices.Sort(rt) sb.WriteString(strings.Join(rt, "\n") + "\n") + + // print forward table + sb.WriteString("\n\nForward Table:\n") + rt = make([]string, 0) + for prefix, route := range Get[*NylonRouter](s).ForwardTable.All() { + rt = append(rt, fmt.Sprintf(" - %s via %s", prefix, route.Nh)) + } + slices.Sort(rt) + sb.WriteString(strings.Join(rt, "\n") + "\n") + + // print exit table + sb.WriteString("\n\nExit Table:\n") + rt = make([]string, 0) + for prefix, route := range Get[*NylonRouter](s).ExitTable.All() { + rt = append(rt, fmt.Sprintf(" - %s via %s", prefix, route.Nh)) + } + slices.Sort(rt) + sb.WriteString(strings.Join(rt, "\n") + "\n") + sb.WriteRune(0) _, err = rw.WriteString(sb.String()) if err != nil { diff --git a/core/nylon.go b/core/nylon.go index 11d218fd..be4866f0 100644 --- a/core/nylon.go +++ b/core/nylon.go @@ -56,7 +56,7 @@ func (n *Nylon) Init(s *state.State) error { } stNeigh := &state.Neighbour{ Id: peer, - Routes: make(map[state.Source]state.NeighRoute), + Routes: make(map[netip.Prefix]state.NeighRoute), Eps: make([]state.Endpoint, 0), } cfg := s.GetRouter(peer) diff --git a/core/nylon_tc.go b/core/nylon_tc.go index fe1c6ffe..0f6c05f6 100644 --- a/core/nylon_tc.go +++ b/core/nylon_tc.go @@ -1,6 +1,8 @@ package core import ( + "net/netip" + "github.com/encodeous/nylon/polyamide/conn" "github.com/encodeous/nylon/polyamide/device" "github.com/encodeous/nylon/protocol" @@ -17,6 +19,27 @@ const ( func (n *Nylon) InstallTC(s *state.State) { r := Get[*NylonRouter](s) + if state.DBG_trace_tc { + n.Device.InstallFilter(func(dev *device.Device, packet *device.TCElement) (device.TCAction, error) { + if packet.Validate() { // make sure it's an IP packet + peer := packet.FromPeer + if peer == nil { + peer = packet.ToPeer + } + src := packet.GetSrc() + dst := packet.GetDst() + if src.IsValid() && + dst.IsValid() && + peer != nil && + src != netip.IPv4Unspecified() && src != netip.IPv6Unspecified() && + dst != netip.IPv4Unspecified() && dst != netip.IPv6Unspecified() { + dev.Log.Verbosef("Unhandled TC packet: %v -> %v, peer %s", packet.GetSrc(), packet.GetDst(), peer) + } + } + return device.TcPass, nil + }) + } + // bounce back packets if using system routing if n.env.UseSystemRouting { n.Device.InstallFilter(func(dev *device.Device, packet *device.TCElement) (device.TCAction, error) { @@ -32,7 +55,9 @@ func (n *Nylon) InstallTC(s *state.State) { entry, ok := r.ForwardTable.Lookup(packet.GetDst()) if ok && !packet.Incoming() { packet.ToPeer = entry.Peer - //dev.Log.Verbosef("Fwd packet: %v -> %v, via %s", packet.GetSrc(), packet.GetDst(), entry.Nh) + if state.DBG_trace_tc { + dev.Log.Verbosef("Fwd packet: %v -> %v, via %s", packet.GetSrc(), packet.GetDst(), entry.Nh) + } return device.TcForward, nil } return device.TcPass, nil @@ -43,6 +68,9 @@ func (n *Nylon) InstallTC(s *state.State) { entry, ok := r.ForwardTable.Lookup(packet.GetDst()) if ok { packet.ToPeer = entry.Peer + if state.DBG_trace_tc { + dev.Log.Verbosef("Fwd packet: %v -> %v, via %s", packet.GetSrc(), packet.GetDst(), entry.Nh) + } return device.TcForward, nil } return device.TcPass, nil @@ -58,6 +86,9 @@ func (n *Nylon) InstallTC(s *state.State) { packet.DecrementTTL() } if ttl == 0 { + if state.DBG_trace_tc { + dev.Log.Verbosef("TTL Expired: %v -> %v, via %s", packet.GetSrc(), packet.GetDst()) + } return device.TcBounce, nil } } diff --git a/core/router.go b/core/router.go index 024919c5..26dd1f0b 100644 --- a/core/router.go +++ b/core/router.go @@ -197,7 +197,7 @@ func (r *NylonRouter) Init(s *state.State) error { func (r *NylonRouter) ComputeSysRouteTable() []netip.Prefix { prefixes := make([]netip.Prefix, 0) selectedSelf := make(map[netip.Prefix]struct{}) - for entry, v := range r.ForwardTable.All() { + for entry, v := range r.Routes { prefixes = append(prefixes, entry) if v.Nh == r.Id { selectedSelf[entry] = struct{}{} diff --git a/core/router_algo.go b/core/router_algo.go index 6ce3c3af..2fe954f8 100644 --- a/core/router_algo.go +++ b/core/router_algo.go @@ -153,7 +153,7 @@ func RunGC(s *state.RouterState, r Router) { for src := range s.Sources { found := false for _, neigh := range s.Neighbours { - if _, ok := neigh.Routes[src]; ok { + if nSrc, ok := neigh.Routes[src.Prefix]; ok && nSrc.Source == src { found = true break } @@ -289,7 +289,7 @@ func HandleNeighbourUpdate(s *state.RouterState, r Router, neighId state.NodeId, n := s.GetNeighbour(neighId) - _, ok := n.Routes[adv.Source] + _, ok := n.Routes[adv.Prefix] if adv.Metric == state.INF { r.SendAckRetract(neighId, adv.Source.Prefix) @@ -317,7 +317,7 @@ func HandleNeighbourUpdate(s *state.RouterState, r Router, neighId state.NodeId, // metric carried by the update. // create the route - n.Routes[adv.Source] = state.NeighRoute{ + n.Routes[adv.Prefix] = state.NeighRoute{ PubRoute: adv, ExpireAt: time.Now().Add(state.RouteExpiryTime), } @@ -366,13 +366,13 @@ func HandleNeighbourUpdate(s *state.RouterState, r Router, neighId state.NodeId, // update (possibly a retraction) MUST be sent in a timely manner as // described in Section 3.7.2. - nr := n.Routes[adv.Source] + nr := n.Routes[adv.Prefix] nr.PubRoute = adv if adv.Metric != state.INF { nr.ExpireAt = time.Now().Add(state.RouteExpiryTime) } - n.Routes[adv.Source] = nr + n.Routes[adv.Prefix] = nr } } @@ -502,9 +502,7 @@ func ComputeRoutes(s *state.RouterState, r Router) { } // enumerate through neighbour advertisements - for S, adv := range neigh.Routes { - prefix := S.Prefix - + for prefix, adv := range neigh.Routes { // Cost(A, B) + Cost(S, B) totalCost := AddMetric(CAB, adv.Metric) @@ -568,7 +566,7 @@ func ComputeRoutes(s *state.RouterState, r Router) { for prefix, newRoute := range newTable { oldRoute, exists := s.Routes[prefix] - if !exists { + if !exists || oldRoute.Metric == state.INF { r.TableInsertRoute(prefix, newRoute) r.Log(RouteChanged, "inserted", "prefix", prefix, "new", newRoute) } else if oldRoute.Nh != newRoute.Nh { diff --git a/core/router_harness.go b/core/router_harness.go index e3cfec0b..95a2a9cb 100644 --- a/core/router_harness.go +++ b/core/router_harness.go @@ -178,7 +178,7 @@ func MakeNeighbours(ids ...state.NodeId) []*state.Neighbour { for _, id := range ids { neighs = append(neighs, &state.Neighbour{ Id: id, - Routes: make(map[state.Source]state.NeighRoute), + Routes: make(map[netip.Prefix]state.NeighRoute), }) } return neighs diff --git a/e2e/harness.go b/e2e/harness.go index 5081625a..ad0f80aa 100644 --- a/e2e/harness.go +++ b/e2e/harness.go @@ -213,6 +213,7 @@ func (h *Harness) StartNode(name string, ip string, centralConfigPath, nodeConfi } } }, + Name: h.t.Name() + "-" + name, } container, err := testcontainers.GenericContainer(h.ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, @@ -254,6 +255,39 @@ func (h *Harness) WaitForLog(nodeName string, pattern string) { } } } +func (h *Harness) WaitForMatch(nodeName string, pattern string) { + h.mu.Lock() + buffer, ok := h.LogBuffers[nodeName] + h.mu.Unlock() + if !ok { + h.t.Fatalf("log buffer for node %s not found", nodeName) + } + + // Compile the regex once before the loop + re, err := regexp.Compile(pattern) + if err != nil { + h.t.Fatalf("invalid regex pattern %q: %v", pattern, err) + } + + // Poll the buffer + timeout := time.After(30 * time.Second) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-timeout: + h.t.Fatalf("timed out waiting for regex match %q in node %s", pattern, nodeName) + case <-ticker.C: + // Check against the compiled regex + if re.MatchString(buffer.String()) { + return + } + case <-h.ctx.Done(): + h.t.Fatal("context canceled") + } + } +} func (h *Harness) Cleanup() { h.mu.Lock() defer h.mu.Unlock() @@ -299,6 +333,34 @@ func (h *Harness) Exec(nodeName string, cmd []string) (string, string, error) { return stdout, stderr, nil } +type BackgroundExec struct { + Stdout string + Stderr string + Err error + done chan struct{} +} + +func (e *BackgroundExec) Wait() (string, string, error) { + select { + case <-e.done: + break + case <-time.After(15 * time.Second): + return "", "", fmt.Errorf("timed out waiting for command to finish") + } + return e.Stdout, e.Stderr, e.Err +} + +func (h *Harness) ExecBackground(nodeName string, cmd []string) *BackgroundExec { + bg := &BackgroundExec{ + done: make(chan struct{}), + } + go func() { + defer close(bg.done) + bg.Stdout, bg.Stderr, bg.Err = h.Exec(nodeName, cmd) + }() + return bg +} + // GetIP returns the IP address of the node in the test network func (h *Harness) GetIP(nodeName string) (string, error) { h.mu.Lock() diff --git a/e2e/healthcheck_test.go b/e2e/healthcheck_test.go index 5711ff42..4115c446 100644 --- a/e2e/healthcheck_test.go +++ b/e2e/healthcheck_test.go @@ -3,11 +3,14 @@ package e2e import ( + "fmt" "net/netip" + "strings" "testing" "time" "github.com/encodeous/nylon/state" + "github.com/stretchr/testify/assert" ) func TestHealthcheckPing(t *testing.T) { @@ -104,8 +107,8 @@ func TestHealthcheckPing(t *testing.T) { // 5. Wait for convergence t.Log("Waiting for convergence...") - h.WaitForLog("node3", "installing new route prefix=10.0.1.4/32") - h.WaitForLog("node1", "installing new route prefix=10.0.0") + h.WaitForMatch("node3", ".+old.router=node2.+old.prefix=10.0.1.4\\/32.+new.router=node1.+new.prefix=10.0.1.4\\/32.+") + h.WaitForLog("node1", "prefix=10.0.0.3/32") // ping from 3 to 10.0.0.4 stdout, stderr, err := h.Exec("node3", []string{"ping", "-c", "3", "10.0.1.4"}) @@ -114,15 +117,18 @@ func TestHealthcheckPing(t *testing.T) { } t.Logf("Ping output:\n%s", stdout) + msg := "hello from node 3" // listen on node 1 - stdout, stderr, err = h.Exec("node1", []string{"bash", "-c", "nc -l -p 8888 &"}) + bg := h.ExecBackground("node1", []string{"nc", "-l", "8888"}) + // send on node 3 + stdout, stderr, err = h.Exec("node3", []string{"bash", "-c", fmt.Sprintf("echo '%s' | nc -N 10.0.1.4 8888", msg)}) if err != nil { t.Fatalf("Failed: %v\nStdout: %s\nStderr: %s", err, stdout, stderr) } - // send on node 3 - stdout, stderr, err = h.Exec("node3", []string{"bash", "-c", "echo 'hello from node 3'"}) + //time.Sleep(time.Hour) + stdout, stderr, err = bg.Wait() if err != nil { - t.Fatalf("Failed: %v\nStdout: %s\nStderr: %s", err, stdout, stderr) + t.Fatalf("Failed to listen: %v\nStdout: %s\nStderr: %s", err, stdout, stderr) } - h.WaitForLog("node1", "hello from node 3") + assert.Equal(t, msg, strings.TrimSpace(stdout)) } diff --git a/state/debug.go b/state/debug.go index c7ff52fb..0971e30a 100644 --- a/state/debug.go +++ b/state/debug.go @@ -5,3 +5,4 @@ var DBG_log_wireguard = false var DBG_log_repo_updates = false var DBG_debug = false var DBG_trace = false +var DBG_trace_tc = false diff --git a/state/routing.go b/state/routing.go index 01c0c7fb..eb18a90c 100644 --- a/state/routing.go +++ b/state/routing.go @@ -2,6 +2,7 @@ package state import ( "fmt" + "log/slog" "net/netip" "slices" "strings" @@ -16,6 +17,13 @@ type Source struct { netip.Prefix } +func (s Source) LogValue() slog.Value { + return slog.GroupValue( + slog.String("router", string(s.NodeId)), + slog.String("prefix", s.Prefix.String()), + ) +} + func (s Source) String() string { return fmt.Sprintf("(router: %s, prefix: %s)", s.NodeId, s.Prefix) } @@ -60,7 +68,7 @@ func (s *RouterState) StringRoutes() string { type Neighbour struct { Id NodeId - Routes map[Source]NeighRoute + Routes map[netip.Prefix]NeighRoute Eps []Endpoint } @@ -82,6 +90,15 @@ func (r PubRoute) String() string { return fmt.Sprintf("(router: %s, prefix: %s, seqno: %d, metric: %d)", r.NodeId, r.Prefix, r.Seqno, r.Metric) } +func (r PubRoute) LogValue() slog.Value { + return slog.GroupValue( + slog.String("router", string(r.NodeId)), + slog.String("prefix", r.Prefix.String()), + slog.Uint64("seqno", uint64(r.Seqno)), + slog.Uint64("metric", uint64(r.Metric)), + ) +} + type NeighRoute struct { PubRoute ExpireAt time.Time // when the route expires @@ -98,6 +115,16 @@ func (r SelRoute) String() string { return fmt.Sprintf("(nh: %s, router: %s, prefix: %s, seqno: %d, metric: %d)", r.Nh, r.NodeId, r.Prefix, r.Seqno, r.Metric) } +func (r SelRoute) LogValue() slog.Value { + return slog.GroupValue( + slog.Any("nh", r.Nh), // Use Any if Nh is an object/interface + slog.String("router", string(r.NodeId)), + slog.String("prefix", r.Prefix.String()), + slog.Uint64("seqno", uint64(r.Seqno)), + slog.Uint64("metric", uint64(r.Metric)), + ) +} + func (s *RouterState) GetNeighbour(node NodeId) *Neighbour { nIdx := slices.IndexFunc(s.Neighbours, func(neighbour *Neighbour) bool { return neighbour.Id == node From 3a8c682fc79151faceee8f98897e76931c03aee4 Mon Sep 17 00:00:00 2001 From: Adam Chen Date: Sat, 31 Jan 2026 22:03:58 +0000 Subject: [PATCH 4/4] fix: allow parallel runs by allocating node ips dynamically --- e2e/connectivity_test.go | 11 +++--- e2e/harness.go | 56 ++++++++++--------------------- e2e/healthcheck_test.go | 11 +++--- e2e/main_test.go | 72 ++++++++++++++++++++++++++++++++++++++++ e2e/network_allocator.go | 58 ++++++++++++++++++++++++++++++++ 5 files changed, 155 insertions(+), 53 deletions(-) create mode 100644 e2e/main_test.go create mode 100644 e2e/network_allocator.go diff --git a/e2e/connectivity_test.go b/e2e/connectivity_test.go index 6e3b5bf5..b8711c3e 100644 --- a/e2e/connectivity_test.go +++ b/e2e/connectivity_test.go @@ -16,10 +16,7 @@ func TestConnectivity(t *testing.T) { t.Parallel() // Use a specific subnet for this test to avoid conflicts - subnet := "172.30.0.0/24" - gateway := "172.30.0.1" - - h := NewHarness(t, subnet, gateway) + h := NewHarness(t) // Generate keys node1Key := state.GenerateKey() @@ -27,9 +24,9 @@ func TestConnectivity(t *testing.T) { node3Key := state.GenerateKey() // IPs in the docker network - node1IP := "172.30.0.10" - node2IP := "172.30.0.11" - node3IP := "172.30.0.12" + node1IP := GetIP(h.Subnet, 10) + node2IP := GetIP(h.Subnet, 11) + node3IP := GetIP(h.Subnet, 12) // Internal Nylon IPs node1NylonIP := "10.0.0.1" diff --git a/e2e/harness.go b/e2e/harness.go index ad0f80aa..f1ecd724 100644 --- a/e2e/harness.go +++ b/e2e/harness.go @@ -15,7 +15,6 @@ import ( "testing" "time" - "github.com/docker/docker/api/types/build" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/network" "github.com/docker/docker/pkg/stdcopy" @@ -25,8 +24,9 @@ import ( ) const ( - ImageName = "nylon-debug:latest" - AppPort = "57175/udp" + ImageName = "nylon-debug:latest" + AppPort = "57175/udp" + WaitTimeout = 2 * time.Minute ) type Harness struct { @@ -38,6 +38,8 @@ type Harness struct { LogBuffers map[string]*LogBuffer ImageName string RootDir string + Subnet string + Gateway string } type LogBuffer struct { mu sync.Mutex @@ -55,8 +57,8 @@ func (l *LogBuffer) String() string { return l.buf.String() } -// NewHarness creates a test harness with a specific subnet to avoid collisions -func NewHarness(t *testing.T, subnet, gateway string) *Harness { +// NewHarness creates a test harness with a unique subnet +func NewHarness(t *testing.T) *Harness { ctx := context.Background() // Find root directory (assuming we are in e2e/) wd, err := os.Getwd() @@ -75,6 +77,10 @@ func NewHarness(t *testing.T, subnet, gateway string) *Harness { } rootDir = parent } + + subnet, gateway := GlobalNetworkAllocator.Allocate() + t.Logf("Allocated subnet: %s, gateway: %s", subnet, gateway) + // Create network with specific subnet newNetwork, err := tcnetwork.New(ctx, tcnetwork.WithAttachable(), @@ -98,44 +104,16 @@ func NewHarness(t *testing.T, subnet, gateway string) *Harness { Nodes: make(map[string]testcontainers.Container), LogBuffers: make(map[string]*LogBuffer), RootDir: rootDir, + Subnet: subnet, + Gateway: gateway, } - h.buildImage() + // Image building is handled in MainTest t.Cleanup(func() { h.Cleanup() }) return h } -func (h *Harness) buildImage() { - h.t.Log("Pre-building nylon-debug:latest image...") - req := testcontainers.ContainerRequest{ - FromDockerfile: testcontainers.FromDockerfile{ - Context: h.RootDir, - Dockerfile: "Dockerfile", - KeepImage: true, - Repo: "nylon-debug", - Tag: "latest", - BuildOptionsModifier: func(buildOptions *build.ImageBuildOptions) { - buildOptions.Target = "debug" - }, - }, - } - - // Creating the container triggers the build - c, err := testcontainers.GenericContainer(h.ctx, testcontainers.GenericContainerRequest{ - ContainerRequest: req, - Started: false, - }) - if err != nil { - h.t.Fatalf("Failed to build image: %v", err) - } - - // We don't need this container, just the image. - if err := c.Terminate(h.ctx); err != nil { - h.t.Logf("Warning: failed to terminate builder container: %v", err) - } -} - var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) func StripAnsi(s string) string { @@ -239,7 +217,7 @@ func (h *Harness) WaitForLog(nodeName string, pattern string) { h.t.Fatalf("log buffer for node %s not found", nodeName) } // Poll the buffer - timeout := time.After(15 * time.Second) + timeout := time.After(WaitTimeout) ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() for { @@ -270,7 +248,7 @@ func (h *Harness) WaitForMatch(nodeName string, pattern string) { } // Poll the buffer - timeout := time.After(30 * time.Second) + timeout := time.After(WaitTimeout) ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() @@ -344,7 +322,7 @@ func (e *BackgroundExec) Wait() (string, string, error) { select { case <-e.done: break - case <-time.After(15 * time.Second): + case <-time.After(WaitTimeout): return "", "", fmt.Errorf("timed out waiting for command to finish") } return e.Stdout, e.Stderr, e.Err diff --git a/e2e/healthcheck_test.go b/e2e/healthcheck_test.go index 4115c446..9513e6c1 100644 --- a/e2e/healthcheck_test.go +++ b/e2e/healthcheck_test.go @@ -20,10 +20,7 @@ func TestHealthcheckPing(t *testing.T) { t.Parallel() // Use a specific subnet for this test to avoid conflicts - subnet := "172.30.1.0/24" - gateway := "172.30.1.1" - - h := NewHarness(t, subnet, gateway) + h := NewHarness(t) // Generate keys node1Key := state.GenerateKey() @@ -31,9 +28,9 @@ func TestHealthcheckPing(t *testing.T) { node3Key := state.GenerateKey() // IPs in the docker network - node1IP := "172.30.1.10" - node2IP := "172.30.1.11" - node3IP := "172.30.1.12" + node1IP := GetIP(h.Subnet, 10) + node2IP := GetIP(h.Subnet, 11) + node3IP := GetIP(h.Subnet, 12) // Internal Nylon IPs node1NylonIP := "10.0.0.1" diff --git a/e2e/main_test.go b/e2e/main_test.go new file mode 100644 index 00000000..f71935f1 --- /dev/null +++ b/e2e/main_test.go @@ -0,0 +1,72 @@ +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/docker/docker/api/types/build" + "github.com/testcontainers/testcontainers-go" +) + +func TestMain(m *testing.M) { + if err := buildImage(); err != nil { + fmt.Fprintf(os.Stderr, "Failed to build image: %v\n", err) + os.Exit(1) + } + os.Exit(m.Run()) +} + +func buildImage() error { + ctx := context.Background() + // Find root directory + wd, err := os.Getwd() + if err != nil { + return err + } + // Traversing up to find go.mod + rootDir := wd + for { + if _, err := os.Stat(filepath.Join(rootDir, "go.mod")); err == nil { + break + } + parent := filepath.Dir(rootDir) + if parent == rootDir { + return fmt.Errorf("could not find project root") + } + rootDir = parent + } + + fmt.Println("Pre-building nylon-debug:latest image...") + req := testcontainers.ContainerRequest{ + FromDockerfile: testcontainers.FromDockerfile{ + Context: rootDir, + Dockerfile: "Dockerfile", + KeepImage: true, + Repo: "nylon-debug", + Tag: "latest", + BuildOptionsModifier: func(buildOptions *build.ImageBuildOptions) { + buildOptions.Target = "debug" + }, + }, + } + + // Creating the container triggers the build + c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: false, + }) + if err != nil { + return fmt.Errorf("failed to build image: %v", err) + } + + // We don't need this container, just the image. + if err := c.Terminate(ctx); err != nil { + fmt.Printf("Warning: failed to terminate builder container: %v\n", err) + } + return nil +} diff --git a/e2e/network_allocator.go b/e2e/network_allocator.go new file mode 100644 index 00000000..46195c5f --- /dev/null +++ b/e2e/network_allocator.go @@ -0,0 +1,58 @@ +package e2e + +import ( + "fmt" + "sync" +) + +type NetworkAllocator struct { + mu sync.Mutex + counter int +} + +var GlobalNetworkAllocator = &NetworkAllocator{} + +// Allocate returns a unique subnet and gateway for a test run. +// It assumes a base network of 172.20.0.0/16 and hands out /24 subnets. +// e.g. 0 -> 172.20.0.0/24 (Gateway 172.20.0.1) +// +// 1 -> 172.20.1.0/24 (Gateway 172.20.1.1) +func (s *NetworkAllocator) Allocate() (string, string) { + s.mu.Lock() + defer s.mu.Unlock() + + idx := s.counter + s.counter++ + + subnet := fmt.Sprintf("172.20.%d.0/24", idx) + gateway := fmt.Sprintf("172.20.%d.1", idx) + + return subnet, gateway +} + +// GetIP returns an IP address within the allocated subnet for a given host suffix. +// subnet is expected to be in "172.20.x.0/24" format. +// suffix is the last octet (e.g. 10). +func GetIP(subnet string, suffix int) string { + // Simple parsing, assuming the format generated by Allocate + // subnet: 172.20.x.0/24 + base := subnet[:len(subnet)-4] // remove /24 + // base: 172.20.x.0 + // we want 172.20.x. + + // find the last dot + lastDot := -1 + for i := len(base) - 1; i >= 0; i-- { + if base[i] == '.' { + lastDot = i + break + } + } + + if lastDot == -1 { + panic(fmt.Sprintf("invalid subnet format: %s", subnet)) + } + + prefix := base[:lastDot+1] // 172.20.x. + return fmt.Sprintf("%s%d", prefix, suffix) +}