From fc77623d387b7d7732d6a2b7da16fb4b17c5c5da Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:14:44 +0200 Subject: [PATCH 01/24] test: add integration test framework for CRD to gNMI pipeline Integration tests validate the full reconciliation pipeline from Kubernetes CRD to gNMI JSON output. Uses envtest with an in-process gNMI server that accumulates state, allowing tests to run in seconds without real network infrastructure. Tests auto-discover from testdata// directories, enabling multi-provider coverage. Each test uses txtar format: K8s YAML resources followed by expected gNMI JSON state. Resources are created sequentially, waiting for readiness conditions before proceeding to handle dependencies between resources. JSON comparison uses normalizeJSON() to sort arrays recursively, avoiding flaky tests from non-deterministic field ordering in gNMI responses. Cleanup removes finalizers in two passes before deletion. This prevents controllers from re-reconciling during teardown, which would cause conflicts with the test's expected clean state. Add WithBindAddress option to allow binding to 0.0.0.0 for container deployments. The server defaults to 127.0.0.1 for safe in-process tests, but when running as a standalone server in a pod, it needs to listen on all interfaces. Signed-off-by: Pujol --- .license-scan-overrides.jsonl | 1 + Dockerfile | 1 + Makefile | 5 + Makefile.maker.yaml | 13 +- go.mod | 4 + go.sum | 2 + test/gnmi/main.go | 365 ++------------------- test/gnmi/testserver/server.go | 572 +++++++++++++++++++++++++++++++++ test/integration/main_test.go | 333 +++++++++++++++++++ test/integration/suite_test.go | 371 +++++++++++++++++++++ 10 files changed, 1325 insertions(+), 342 deletions(-) create mode 100644 test/gnmi/testserver/server.go create mode 100644 test/integration/main_test.go create mode 100644 test/integration/suite_test.go diff --git a/.license-scan-overrides.jsonl b/.license-scan-overrides.jsonl index faa62bcb5..f6e9877b5 100644 --- a/.license-scan-overrides.jsonl +++ b/.license-scan-overrides.jsonl @@ -1,6 +1,7 @@ {"name": "github.com/chzyer/logex", "licenceType": "MIT"} {"name": "github.com/grpc-ecosystem/go-grpc-middleware/v2", "licenceType": "Apache-2.0"} {"name": "github.com/hashicorp/vault/api/auth/approle", "licenceType": "MPL-2.0"} +{"name": "github.com/ironcore-dev/gnmi-test-server", "licenceType": "Apache-2.0"} {"name": "github.com/jpillora/longestcommon", "licenceType": "MIT"} {"name": "github.com/logrusorgru/aurora", "licenceType": "Unlicense"} {"name": "github.com/mattn/go-localereader", "licenceType": "MIT"} diff --git a/Dockerfile b/Dockerfile index 8619d4333..ab5dad76b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,7 @@ WORKDIR /workspace RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=bind,source=go.mod,target=go.mod \ --mount=type=bind,source=go.sum,target=go.sum \ + --mount=type=bind,source=test/gnmi,target=test/gnmi \ go mod download -x RUN --mount=type=bind,target=. \ diff --git a/Makefile b/Makefile index 3e039d76b..a8c776423 100644 --- a/Makefile +++ b/Makefile @@ -188,6 +188,11 @@ docker-push-test-lab: FORCE docker-push-test-lab @printf "\e[1;36m>> $(CONTAINER_TOOL) push $(TEST_LAB_IMG)\e[0m\n" @$(CONTAINER_TOOL) push $(TEST_LAB_IMG) + +test-integration: FORCE install-setup-envtest + @printf "\e[1;36m>> go test ./test/integration/ -v\e[0m\n" + @KUBEBUILDER_ASSETS=$$(setup-envtest use 1.35 -p path) go test ./test/integration/ -v + ROOT_DIR := $(shell pwd) DOCS_IMG ?= ironcore-dev/network-operator-docs:latest diff --git a/Makefile.maker.yaml b/Makefile.maker.yaml index aca9ebf97..cb315d6b8 100644 --- a/Makefile.maker.yaml +++ b/Makefile.maker.yaml @@ -33,7 +33,7 @@ golangciLint: shellCheck: ignorePaths: - - 'docs/node_modules/*' + - "docs/node_modules/*" goReleaser: createConfig: true @@ -41,7 +41,7 @@ goReleaser: license: addHeaders: true checkDependencies: true - copyright: 'SAP SE or an SAP affiliate company and IronCore contributors' + copyright: "SAP SE or an SAP affiliate company and IronCore contributors" spdx: Apache-2.0 nix: @@ -57,7 +57,7 @@ renovate: - felix-kaestner testPackages: - except: '/test' + except: "/test" githubWorkflow: ci: @@ -72,7 +72,7 @@ githubWorkflow: enabled: false variables: - GO_BUILDENV: 'CGO_ENABLED=0' + GO_BUILDENV: "CGO_ENABLED=0" verbatim: | # Image to use all building/pushing image targets @@ -230,6 +230,11 @@ verbatim: | @printf "\e[1;36m>> $(CONTAINER_TOOL) push $(TEST_LAB_IMG)\e[0m\n" @$(CONTAINER_TOOL) push $(TEST_LAB_IMG) + + test-integration: FORCE install-setup-envtest + @printf "\e[1;36m>> go test ./test/integration/ -v\e[0m\n" + @KUBEBUILDER_ASSETS=$$(setup-envtest use 1.35 -p path) go test ./test/integration/ -v + ROOT_DIR := $(shell pwd) DOCS_IMG ?= ironcore-dev/network-operator-docs:latest diff --git a/go.mod b/go.mod index 84faea6f8..544537f8c 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/go-logr/logr v1.4.3 github.com/google/go-cmp v0.7.0 github.com/onsi/ginkgo/v2 v2.30.0 + github.com/ironcore-dev/gnmi-test-server v0.0.0-00010101000000-000000000000 github.com/onsi/gomega v1.41.0 github.com/openconfig/gnmi v0.14.1 github.com/openconfig/gnoi v0.8.0 @@ -91,6 +92,7 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect + github.com/tidwall/sjson v1.2.5 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect @@ -129,3 +131,5 @@ require ( sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect ) + +replace github.com/ironcore-dev/gnmi-test-server => ./test/gnmi diff --git a/go.sum b/go.sum index e3ceb88ae..d6ac70af1 100644 --- a/go.sum +++ b/go.sum @@ -193,10 +193,12 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 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/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU= github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc= github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= diff --git a/test/gnmi/main.go b/test/gnmi/main.go index ef2018cde..37e59e21b 100644 --- a/test/gnmi/main.go +++ b/test/gnmi/main.go @@ -4,362 +4,51 @@ package main import ( - "bytes" "context" - "crypto/tls" - "encoding/json" "flag" - "fmt" - "io" "log" - "net" - "net/http" - "strconv" - "strings" - "sync" - "time" + "os" + "os/signal" + "syscall" - gpb "github.com/openconfig/gnmi/proto/gnmi" - "github.com/tidwall/gjson" - "github.com/tidwall/sjson" - "google.golang.org/grpc" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials" - "google.golang.org/grpc/reflection" - "google.golang.org/grpc/status" - - gtls "github.com/openconfig/gnmi/testing/fake/testing/tls" + "github.com/ironcore-dev/gnmi-test-server/testserver" ) -var _ gpb.GNMIServer = (*Server)(nil) - -// Server implements the GNMI gRPC server -type Server struct { - gpb.UnimplementedGNMIServer - - State *State -} - -func (s *Server) Capabilities(_ context.Context, _ *gpb.CapabilityRequest) (*gpb.CapabilityResponse, error) { - return &gpb.CapabilityResponse{SupportedEncodings: []gpb.Encoding{gpb.Encoding_JSON}}, nil -} - -func (s *Server) Get(_ context.Context, req *gpb.GetRequest) (*gpb.GetResponse, error) { - notifications := make([]*gpb.Notification, 0, len(req.GetPath())) - for _, path := range req.GetPath() { - if len(path.GetElem()) == 0 { - return nil, status.Error(codes.InvalidArgument, "root path is not allowed") - } - log.Printf("Getting path: %v", path) - notifications = append(notifications, &gpb.Notification{ - Timestamp: time.Now().UnixNano(), - Update: []*gpb.Update{ - { - Path: path, - Val: &gpb.TypedValue{ - Value: &gpb.TypedValue_JsonVal{ - JsonVal: s.State.Get(path), - }, - }, - }, - }, - }) - } - return &gpb.GetResponse{ - Notification: notifications, - }, nil -} - -func (s *Server) Set(_ context.Context, req *gpb.SetRequest) (*gpb.SetResponse, error) { - log.Printf("Received Set request: %v", req) - res := make([]*gpb.UpdateResult, 0, len(req.GetDelete())+len(req.GetUpdate())) - for _, del := range req.GetDelete() { - log.Printf("Deleting path: %v", del) - res = append(res, &gpb.UpdateResult{ - Timestamp: time.Now().UnixNano(), - Path: del, - Op: gpb.UpdateResult_DELETE, - }) - s.State.Del(del) - } - for _, replace := range req.GetReplace() { - log.Printf("Replacing path: %v with value: %q", replace.GetPath(), replace.GetVal().GetJsonVal()) - res = append(res, &gpb.UpdateResult{ - Timestamp: time.Now().UnixNano(), - Path: replace.Path, - Op: gpb.UpdateResult_REPLACE, - }) - // Delete the existing value at the path and set the new value. - s.State.Del(replace.GetPath()) - s.State.Set(replace.GetPath(), replace.GetVal().GetJsonVal()) - } - for _, update := range req.GetUpdate() { - log.Printf("Updating path: %v with value: %q", update.GetPath(), update.GetVal().GetJsonVal()) - res = append(res, &gpb.UpdateResult{ - Timestamp: time.Now().UnixNano(), - Path: update.Path, - Op: gpb.UpdateResult_UPDATE, - }) - // The value will automatically be merged into the existing state. - s.State.Set(update.GetPath(), update.GetVal().GetJsonVal()) - } - // TODO: Handle UnionReplace - return &gpb.SetResponse{ - Response: res, - Timestamp: time.Now().UnixNano(), - }, nil -} - -func (s *Server) Subscribe(stream grpc.BidiStreamingServer[gpb.SubscribeRequest, gpb.SubscribeResponse]) error { - req, err := stream.Recv() - switch { - case err == io.EOF: - return nil - case err != nil: - return err - case req.GetSubscribe() == nil: - return status.Errorf(codes.InvalidArgument, "the subscribe request must contain a subscription definition") - } - - switch req.GetRequest().(type) { - case *gpb.SubscribeRequest_Poll: - return status.Errorf(codes.InvalidArgument, "invalid request type: %T", req.GetRequest()) - case *gpb.SubscribeRequest_Subscribe: - } - - switch mode := req.GetSubscribe().GetMode(); mode { - case gpb.SubscriptionList_ONCE: - log.Printf("Received Subscribe request with ONCE mode") - - paths := make([]*gpb.Path, 0, len(req.GetSubscribe().GetSubscription())) - for _, r := range req.GetSubscribe().GetSubscription() { - paths = append(paths, r.GetPath()) - } - - res, err := s.Get(stream.Context(), &gpb.GetRequest{ - Prefix: req.GetSubscribe().GetPrefix(), - Path: paths, - Encoding: req.GetSubscribe().GetEncoding(), - UseModels: req.GetSubscribe().GetUseModels(), - Extension: req.GetExtension(), - }) - if err != nil { - return err - } - - for _, notification := range res.GetNotification() { - if err := stream.Send(&gpb.SubscribeResponse{ - Response: &gpb.SubscribeResponse_Update{ - Update: notification, - }, - }); err != nil { - return status.Errorf(codes.Internal, "failed to send response: %v", err) - } - } - - case gpb.SubscriptionList_STREAM: - return status.Errorf(codes.Unimplemented, "subscribe method Stream not implemented") - case gpb.SubscriptionList_POLL: - return status.Errorf(codes.Unimplemented, "subscribe method Poll not implemented") - default: - return status.Errorf(codes.InvalidArgument, "unknown subscribe request mode: %v", mode) - } - - return nil -} - -// handleState handles HTTP requests to the /v1/state endpoint -func (s *Server) handleState(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodGet: - s.State.RLock() - defer s.State.RUnlock() - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - if len(s.State.Buf) == 0 { - w.Write([]byte("{}")) - return - } - var buf bytes.Buffer - if err := json.Compact(&buf, s.State.Buf); err != nil { - log.Printf("Failed to compact JSON: %v", err) - w.WriteHeader(http.StatusInternalServerError) - w.Write([]byte("Internal Server Error")) - return - } - w.Write(buf.Bytes()) - case http.MethodDelete: - s.State.Lock() - defer s.State.Unlock() - s.State.Buf = nil - w.WriteHeader(http.StatusNoContent) - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } -} - -// State represents a JSON body that can be manipulated using [sjson] syntax. -type State struct { - sync.RWMutex - - Buf []byte -} - -func (s State) Get(path *gpb.Path) []byte { - s.RLock() - defer s.RUnlock() - var sb strings.Builder - for _, elem := range path.GetElem() { - if elem.GetName() == "" { - continue - } - if sb.Len() > 0 { - sb.WriteByte('|') - } - sb.WriteString(elem.GetName()) - if len(elem.GetKey()) == 0 { - continue - } - for k, v := range elem.GetKey() { - sb.WriteByte('|') - sb.WriteString(`#(`) - sb.WriteString(k) - sb.WriteString(`=="`) - sb.WriteString(v) - sb.WriteString(`")#`) - } - } - res := gjson.GetBytes(s.Buf, sb.String()) - if !res.Exists() || (res.IsArray() && len(res.Array()) == 0) { - return []byte("null") - } - return []byte(res.Raw) -} - -func (s *State) Set(path *gpb.Path, raw []byte) { - s.Lock() - defer s.Unlock() - var sb strings.Builder - for _, elem := range path.GetElem() { - if elem.GetName() == "" { - continue - } - if sb.Len() > 0 { - sb.WriteByte('.') - } - sb.WriteString(elem.GetName()) - if len(elem.GetKey()) == 0 { - continue - } - var idx int - gjson.GetBytes(s.Buf, sb.String()).ForEach(func(_, r gjson.Result) bool { - for k, v := range elem.GetKey() { - if r.Get(k).String() != v { - idx++ - return true - } - } - return false - }) - sb.WriteByte('.') - sb.WriteString(strconv.Itoa(idx)) - } - s.Buf, _ = sjson.SetRawBytes(s.Buf, sb.String(), raw) //nolint:errcheck -} - -func (s *State) Del(path *gpb.Path) { - s.Lock() - defer s.Unlock() - var sb strings.Builder - for _, elem := range path.GetElem() { - if elem.GetName() == "" { - continue - } - if sb.Len() > 0 { - sb.WriteByte('.') - } - sb.WriteString(elem.GetName()) - if len(elem.GetKey()) == 0 { - continue - } - var ( - idx int - found bool - ) - gjson.GetBytes(s.Buf, sb.String()).ForEach(func(_, r gjson.Result) bool { - for k, v := range elem.GetKey() { - if r.Get(k).String() != v { - idx++ - return true - } - } - found = true - return false - }) - if !found { - return - } - sb.WriteByte('.') - sb.WriteString(strconv.Itoa(idx)) - } - - s.Buf, _ = sjson.DeleteBytes(s.Buf, sb.String()) //nolint:errcheck -} - func main() { // Parse command line flags port := flag.Int("port", 9339, "The gRPC server port") httpPort := flag.Int("http-port", 8000, "The HTTP server port") flag.Parse() - // Create a listener on the specified port - lis, err := net.Listen("tcp", fmt.Sprintf(":%d", *port)) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start the server using the reusable NewTestServer function + // Bind to 0.0.0.0 to accept connections from other pods in the cluster + server, grpcAddr, httpAddr, err := testserver.NewTestServer( + ctx, + testserver.WithGRPCPort(*port), + testserver.WithHTTPPort(*httpPort), + testserver.WithBindAddress("0.0.0.0"), + ) if err != nil { - log.Fatalf("Failed to listen on port %d: %v", *port, err) + log.Fatalf("Failed to start server: %v", err) } - // Create a TLS certificate for gRPC server - // This is a self-signed certificate for testing purposes. - cert, err := gtls.NewCert() - if err != nil { - log.Fatalf("Failed to create TLS certificate: %v", err) - } - - // Create a new gRPC server with TLS - grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{ - Certificates: []tls.Certificate{cert}, - }))) - - // Create our server implementation - server := &Server{State: &State{}} - - // Register the GNMIService with our server implementation - gpb.RegisterGNMIServer(grpcServer, server) - - // Enable reflection for easier testing with tools like grpcurl - reflection.Register(grpcServer) - - // Setup HTTP server - http.HandleFunc("/v1/state", server.handleState) - httpServer := &http.Server{Addr: fmt.Sprintf(":%d", *httpPort)} - - // Start HTTP server in a goroutine - go func() { - log.Printf("Starting HTTP server on port %d", *httpPort) - log.Printf("HTTP endpoint available at: /v1/state") - if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { - log.Fatalf("Failed to serve HTTP server: %v", err) - } - }() - - log.Printf("Starting gRPC server on port %d", *port) - log.Printf("Server is ready to accept connections...") + log.Printf("gRPC server listening on %s", grpcAddr) + log.Printf("HTTP server listening on %s", httpAddr) + log.Printf("HTTP endpoint available at: /v1/state") log.Printf("Use --port flag to specify a different gRPC port (default: 9339)") log.Printf("Use --http-port flag to specify a different HTTP port (default: 8000)") log.Printf("Available services: GNMI") - // Start serving - if err := grpcServer.Serve(lis); err != nil { - log.Fatalf("Failed to serve gRPC server: %v", err) + // Wait for interrupt signal + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + <-sigCh + + log.Println("Shutting down...") + if err := server.Close(); err != nil { + log.Printf("Error during shutdown: %v", err) } } diff --git a/test/gnmi/testserver/server.go b/test/gnmi/testserver/server.go new file mode 100644 index 000000000..8578f476d --- /dev/null +++ b/test/gnmi/testserver/server.go @@ -0,0 +1,572 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package testserver + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "log" + "net" + "net/http" + "strconv" + "strings" + "sync" + "time" + + gpb "github.com/openconfig/gnmi/proto/gnmi" + "github.com/tidwall/gjson" + "github.com/tidwall/sjson" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/reflection" + "google.golang.org/grpc/status" + + gtls "github.com/openconfig/gnmi/testing/fake/testing/tls" +) + +var _ gpb.GNMIServer = (*Server)(nil) + +// Server implements the GNMI gRPC server +type Server struct { + gpb.UnimplementedGNMIServer + + State *State + + grpcServer *grpc.Server + httpServer *http.Server + grpcAddr string + httpAddr string +} + +// ServerOption configures the test server +type ServerOption func(*serverConfig) + +type serverConfig struct { + grpcPort int + httpPort int + bindAddress string + stripDMEMarkers bool + dmeMarkerValue string +} + +// WithGRPCPort sets a specific gRPC port (default: 0 for random) +func WithGRPCPort(port int) ServerOption { + return func(c *serverConfig) { + c.grpcPort = port + } +} + +// WithHTTPPort sets a specific HTTP port (default: 0 for random) +func WithHTTPPort(port int) ServerOption { + return func(c *serverConfig) { + c.httpPort = port + } +} + +// WithBindAddress sets the address to bind to (default: 127.0.0.1). +// Use "0.0.0.0" to listen on all interfaces (required for container/pod deployments). +func WithBindAddress(addr string) ServerOption { + return func(c *serverConfig) { + c.bindAddress = addr + } +} + +// WithNXOSBehavior configures the server to emulate NX-OS device behavior: +// - Strips fields with DME_UNSET_PROPERTY_MARKER value when storing (the marker +// means "unset this field", not "store this literal string") +// - Returns empty TypedValue for non-existent paths (instead of NOT_FOUND error) +func WithNXOSBehavior() ServerOption { + return func(c *serverConfig) { + c.stripDMEMarkers = true + c.dmeMarkerValue = "DME_UNSET_PROPERTY_MARKER" + } +} + +// NewTestServer starts an in-process gNMI + HTTP server. +// By default, it uses random available ports. Use WithGRPCPort/WithHTTPPort to specify ports. +// Returns the server, gRPC address, HTTP address, and any error. +func NewTestServer(ctx context.Context, opts ...ServerOption) (*Server, string, string, error) { + cfg := &serverConfig{ + grpcPort: 0, // Random port by default + httpPort: 0, // Random port by default + bindAddress: "127.0.0.1", // Localhost by default (safe for in-process tests) + } + for _, opt := range opts { + opt(cfg) + } + + // Create a listener on the specified port + grpcLis, err := net.Listen("tcp", fmt.Sprintf("%s:%d", cfg.bindAddress, cfg.grpcPort)) + if err != nil { + return nil, "", "", fmt.Errorf("failed to listen for gRPC: %w", err) + } + + httpLis, err := net.Listen("tcp", fmt.Sprintf("%s:%d", cfg.bindAddress, cfg.httpPort)) + if err != nil { + grpcLis.Close() + return nil, "", "", fmt.Errorf("failed to listen for HTTP: %w", err) + } + + // Create a TLS certificate for gRPC server + cert, err := gtls.NewCert() + if err != nil { + grpcLis.Close() + httpLis.Close() + return nil, "", "", fmt.Errorf("failed to create TLS certificate: %w", err) + } + + // Create a new gRPC server with TLS + grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{cert}, + }))) + + // Create our server implementation + server := &Server{ + State: &State{ + stripDMEMarkers: cfg.stripDMEMarkers, + dmeMarkerValue: cfg.dmeMarkerValue, + }, + grpcServer: grpcServer, + grpcAddr: grpcLis.Addr().String(), + httpAddr: httpLis.Addr().String(), + } + + // Register the GNMIService with our server implementation + gpb.RegisterGNMIServer(grpcServer, server) + + // Enable reflection for easier testing + reflection.Register(grpcServer) + + // Setup HTTP server + mux := http.NewServeMux() + mux.HandleFunc("/v1/state", server.handleState) + server.httpServer = &http.Server{Handler: mux} + + // Start HTTP server in a goroutine + go func() { + log.Printf("Starting HTTP server on %s", server.httpAddr) + if err := server.httpServer.Serve(httpLis); err != nil && err != http.ErrServerClosed { + log.Printf("HTTP server error: %v", err) + } + }() + + // Start gRPC server in a goroutine + go func() { + log.Printf("Starting gRPC server on %s", server.grpcAddr) + if err := grpcServer.Serve(grpcLis); err != nil { + log.Printf("gRPC server error: %v", err) + } + }() + + return server, server.grpcAddr, server.httpAddr, nil +} + +// GRPCAddr returns the gRPC server address +func (s *Server) GRPCAddr() string { + return s.grpcAddr +} + +// HTTPAddr returns the HTTP server address +func (s *Server) HTTPAddr() string { + return s.httpAddr +} + +// GetState returns the current JSON state +func (s *Server) GetState() ([]byte, error) { + s.State.RLock() + defer s.State.RUnlock() + if len(s.State.Buf) == 0 { + return []byte("{}"), nil + } + var buf bytes.Buffer + if err := json.Compact(&buf, s.State.Buf); err != nil { + return nil, fmt.Errorf("failed to compact JSON: %w", err) + } + return buf.Bytes(), nil +} + +// ClearState clears all accumulated state +func (s *Server) ClearState() { + s.State.Lock() + defer s.State.Unlock() + s.State.Buf = nil +} + +// Close gracefully shuts down the server +func (s *Server) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var errs []error + if s.httpServer != nil { + if err := s.httpServer.Shutdown(ctx); err != nil { + errs = append(errs, fmt.Errorf("HTTP shutdown: %w", err)) + } + } + if s.grpcServer != nil { + s.grpcServer.GracefulStop() + } + if len(errs) > 0 { + return errs[0] + } + return nil +} + +func (s *Server) Capabilities(_ context.Context, _ *gpb.CapabilityRequest) (*gpb.CapabilityResponse, error) { + return &gpb.CapabilityResponse{SupportedEncodings: []gpb.Encoding{gpb.Encoding_JSON}}, nil +} + +func (s *Server) Get(_ context.Context, req *gpb.GetRequest) (*gpb.GetResponse, error) { + notifications := make([]*gpb.Notification, 0, len(req.GetPath())) + for _, path := range req.GetPath() { + if len(path.GetElem()) == 0 { + return nil, status.Error(codes.InvalidArgument, "root path is not allowed") + } + log.Printf("Getting path: %v", path) + notifications = append(notifications, &gpb.Notification{ + Timestamp: time.Now().UnixNano(), + Update: []*gpb.Update{ + { + Path: path, + Val: &gpb.TypedValue{ + Value: &gpb.TypedValue_JsonVal{ + JsonVal: s.State.Get(path), + }, + }, + }, + }, + }) + } + return &gpb.GetResponse{ + Notification: notifications, + }, nil +} + +func (s *Server) Set(_ context.Context, req *gpb.SetRequest) (*gpb.SetResponse, error) { + log.Printf("Received Set request: %v", req) + res := make([]*gpb.UpdateResult, 0, len(req.GetDelete())+len(req.GetUpdate())) + for _, del := range req.GetDelete() { + log.Printf("Deleting path: %v", del) + res = append(res, &gpb.UpdateResult{ + Timestamp: time.Now().UnixNano(), + Path: del, + Op: gpb.UpdateResult_DELETE, + }) + s.State.Del(del) + } + for _, replace := range req.GetReplace() { + log.Printf("Replacing path: %v with value: %q", replace.GetPath(), replace.GetVal().GetJsonVal()) + res = append(res, &gpb.UpdateResult{ + Timestamp: time.Now().UnixNano(), + Path: replace.Path, + Op: gpb.UpdateResult_REPLACE, + }) + // Delete the existing value at the path and set the new value. + s.State.Del(replace.GetPath()) + s.State.Set(replace.GetPath(), replace.GetVal().GetJsonVal()) + } + for _, update := range req.GetUpdate() { + log.Printf("Updating path: %v with value: %q", update.GetPath(), update.GetVal().GetJsonVal()) + res = append(res, &gpb.UpdateResult{ + Timestamp: time.Now().UnixNano(), + Path: update.Path, + Op: gpb.UpdateResult_UPDATE, + }) + // The value will automatically be merged into the existing state. + s.State.Set(update.GetPath(), update.GetVal().GetJsonVal()) + } + // TODO: Handle UnionReplace + return &gpb.SetResponse{ + Response: res, + Timestamp: time.Now().UnixNano(), + }, nil +} + +func (s *Server) Subscribe(stream grpc.BidiStreamingServer[gpb.SubscribeRequest, gpb.SubscribeResponse]) error { + req, err := stream.Recv() + switch { + case err == io.EOF: + return nil + case err != nil: + return err + case req.GetSubscribe() == nil: + return status.Errorf(codes.InvalidArgument, "the subscribe request must contain a subscription definition") + } + + switch req.GetRequest().(type) { + case *gpb.SubscribeRequest_Poll: + return status.Errorf(codes.InvalidArgument, "invalid request type: %T", req.GetRequest()) + case *gpb.SubscribeRequest_Subscribe: + } + + switch mode := req.GetSubscribe().GetMode(); mode { + case gpb.SubscriptionList_ONCE: + log.Printf("Received Subscribe request with ONCE mode") + + paths := make([]*gpb.Path, 0, len(req.GetSubscribe().GetSubscription())) + for _, r := range req.GetSubscribe().GetSubscription() { + paths = append(paths, r.GetPath()) + } + + res, err := s.Get(stream.Context(), &gpb.GetRequest{ + Prefix: req.GetSubscribe().GetPrefix(), + Path: paths, + Encoding: req.GetSubscribe().GetEncoding(), + UseModels: req.GetSubscribe().GetUseModels(), + Extension: req.GetExtension(), + }) + if err != nil { + return err + } + + for _, notification := range res.GetNotification() { + if err := stream.Send(&gpb.SubscribeResponse{ + Response: &gpb.SubscribeResponse_Update{ + Update: notification, + }, + }); err != nil { + return status.Errorf(codes.Internal, "failed to send response: %v", err) + } + } + + case gpb.SubscriptionList_STREAM: + return status.Errorf(codes.Unimplemented, "subscribe method Stream not implemented") + case gpb.SubscriptionList_POLL: + return status.Errorf(codes.Unimplemented, "subscribe method Poll not implemented") + default: + return status.Errorf(codes.InvalidArgument, "unknown subscribe request mode: %v", mode) + } + + return nil +} + +// handleState handles HTTP requests to the /v1/state endpoint +func (s *Server) handleState(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + state, err := s.GetState() + if err != nil { + log.Printf("Failed to get state: %v", err) + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("Internal Server Error")) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write(state) + case http.MethodDelete: + s.ClearState() + w.WriteHeader(http.StatusNoContent) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } +} + +// State represents a JSON body that can be manipulated using [sjson] syntax. +type State struct { + sync.RWMutex + + Buf []byte + + // NX-OS behavior options + stripDMEMarkers bool + dmeMarkerValue string +} + +// stripMarkerFields removes fields with the DME marker value from JSON recursively. +// This emulates NX-OS behavior where these markers mean "unset this field" +// rather than "store this literal string". +func (s *State) stripMarkerFields(data []byte) []byte { + if !s.stripDMEMarkers || s.dmeMarkerValue == "" { + return data + } + return s.stripMarkersRecursive(data) +} + +// stripMarkersRecursive walks the JSON structure and removes marker fields at all levels. +func (s *State) stripMarkersRecursive(data []byte) []byte { + parsed := gjson.ParseBytes(data) + if !parsed.IsObject() && !parsed.IsArray() { + return data + } + + if parsed.IsArray() { + // Process each array element + var results []string + parsed.ForEach(func(_, value gjson.Result) bool { + processed := s.stripMarkersRecursive([]byte(value.Raw)) + results = append(results, string(processed)) + return true + }) + return []byte("[" + strings.Join(results, ",") + "]") + } + + // It's an object - process fields + var toDelete []string + parsed.ForEach(func(key, value gjson.Result) bool { + keyStr := key.String() + if value.Type == gjson.String && value.String() == s.dmeMarkerValue { + toDelete = append(toDelete, keyStr) + } else if value.IsObject() || value.IsArray() { + // Recurse into nested structures + processed := s.stripMarkersRecursive([]byte(value.Raw)) + data, _ = sjson.SetRawBytes(data, keyStr, processed) + } + return true + }) + for _, key := range toDelete { + data, _ = sjson.DeleteBytes(data, key) + } + return data +} + +func (s *State) Get(path *gpb.Path) []byte { + s.RLock() + defer s.RUnlock() + var sb strings.Builder + for _, elem := range path.GetElem() { + if elem.GetName() == "" { + continue + } + if sb.Len() > 0 { + sb.WriteByte('|') + } + sb.WriteString(elem.GetName()) + if len(elem.GetKey()) == 0 { + continue + } + for k, v := range elem.GetKey() { + sb.WriteByte('|') + sb.WriteString(`#(`) + sb.WriteString(k) + sb.WriteString(`=="`) + sb.WriteString(v) + sb.WriteString(`")#`) + } + } + res := gjson.GetBytes(s.Buf, sb.String()) + if !res.Exists() || (res.IsArray() && len(res.Array()) == 0) { + // Return empty bytes for non-existent paths. This triggers gnmiext's + // ErrNil handling (len(b) == 0), matching real NX-OS behavior which + // returns empty TypedValue for paths that don't exist yet. + return []byte{} + } + return []byte(res.Raw) +} + +func (s *State) Set(path *gpb.Path, raw []byte) { + s.Lock() + defer s.Unlock() + + // Strip DME marker fields if NX-OS behavior is enabled + raw = s.stripMarkerFields(raw) + + elems := path.GetElem() + var sb strings.Builder + + for i, elem := range elems { + if elem.GetName() == "" { + continue + } + if sb.Len() > 0 { + sb.WriteByte('.') + } + sb.WriteString(elem.GetName()) + + if len(elem.GetKey()) == 0 { + continue + } + + // Find existing array index or append + var idx int + gjson.GetBytes(s.Buf, sb.String()).ForEach(func(_, r gjson.Result) bool { + for k, v := range elem.GetKey() { + if r.Get(k).String() != v { + idx++ + return true + } + } + return false + }) + sb.WriteByte('.') + sb.WriteString(strconv.Itoa(idx)) + + // Inject keys into this list element if it's not the final element + // (for the final element, keys go into raw below) + if i < len(elems)-1 { + currentPath := sb.String() + current := gjson.GetBytes(s.Buf, currentPath) + if !current.Exists() || current.Raw == "null" { + // Create the element with its keys + keyObj := make(map[string]string) + for k, v := range elem.GetKey() { + keyObj[k] = v + } + keyJSON, _ := json.Marshal(keyObj) + s.Buf, _ = sjson.SetRawBytes(s.Buf, currentPath, keyJSON) + } else { + // Element exists, ensure keys are set + for k, v := range elem.GetKey() { + if !gjson.GetBytes(s.Buf, currentPath+"."+k).Exists() { + s.Buf, _ = sjson.SetBytes(s.Buf, currentPath+"."+k, v) + } + } + } + } + } + + // For the final element, inject its keys (from the last keyed element) into raw + lastElem := elems[len(elems)-1] + for k, v := range lastElem.GetKey() { + if !gjson.GetBytes(raw, k).Exists() { + raw, _ = sjson.SetBytes(raw, k, v) + } + } + + s.Buf, _ = sjson.SetRawBytes(s.Buf, sb.String(), raw) //nolint:errcheck +} + +func (s *State) Del(path *gpb.Path) { + s.Lock() + defer s.Unlock() + var sb strings.Builder + for _, elem := range path.GetElem() { + if elem.GetName() == "" { + continue + } + if sb.Len() > 0 { + sb.WriteByte('.') + } + sb.WriteString(elem.GetName()) + if len(elem.GetKey()) == 0 { + continue + } + var ( + idx int + found bool + ) + gjson.GetBytes(s.Buf, sb.String()).ForEach(func(_, r gjson.Result) bool { + for k, v := range elem.GetKey() { + if r.Get(k).String() != v { + idx++ + return true + } + } + found = true + return false + }) + if !found { + return + } + sb.WriteByte('.') + sb.WriteString(strconv.Itoa(idx)) + } + + s.Buf, _ = sjson.DeleteBytes(s.Buf, sb.String()) //nolint:errcheck +} diff --git a/test/integration/main_test.go b/test/integration/main_test.go new file mode 100644 index 000000000..07a9c42be --- /dev/null +++ b/test/integration/main_test.go @@ -0,0 +1,333 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package integration + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "golang.org/x/tools/txtar" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" + + nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +// resourceRegistry maps txtar file prefixes to GVKs for cleanup ordering. +// Resources are deleted in reverse order (last registered first). +var resourceRegistry = []schema.GroupVersionKind{ + v1alpha1.GroupVersion.WithKind("Interface"), + v1alpha1.GroupVersion.WithKind("VLAN"), + v1alpha1.GroupVersion.WithKind("VRF"), + v1alpha1.GroupVersion.WithKind("NTP"), + v1alpha1.GroupVersion.WithKind("DNS"), + v1alpha1.GroupVersion.WithKind("LLDP"), + v1alpha1.GroupVersion.WithKind("Banner"), + nxv1alpha1.GroupVersion.WithKind("InterfaceConfig"), + nxv1alpha1.GroupVersion.WithKind("LLDPConfig"), + v1alpha1.GroupVersion.WithKind("RoutingPolicy"), + v1alpha1.GroupVersion.WithKind("PrefixSet"), + // Add new resource types here as needed +} + +var _ = Describe("Integration", func() { + for _, providerCfg := range SupportedProviders { + // Skip providers that don't have testdata + testdataDir := filepath.Join("testdata", string(providerCfg.Name)) + if _, err := os.Stat(testdataDir); os.IsNotExist(err) { + continue + } + + Describe(fmt.Sprintf("Provider: %s", providerCfg.Name), Ordered, func() { + var ptc *ProviderTestContext + var device *v1alpha1.Device + var deviceName string + + BeforeAll(func() { + By(fmt.Sprintf("setting up %s provider", providerCfg.Name)) + ptc = SetupProviderTest(providerCfg) + }) + + AfterAll(func() { + By(fmt.Sprintf("tearing down %s provider", providerCfg.Name)) + TeardownProviderTest(ptc) + }) + + BeforeEach(func() { + By("creating a test device with unique name") + var err error + device, err = CreateTestDevice(ctx, k8sClient, "") + Expect(err).NotTo(HaveOccurred()) + deviceName = device.Name + }) + + AfterEach(func() { + By("cleaning up resources") + cleanupAllResources() + + // Delete the device and wait for it to be gone + if device != nil { + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, device))).To(Succeed()) + Eventually(func(g Gomega) { + err := k8sClient.Get(ctx, client.ObjectKeyFromObject(device), &v1alpha1.Device{}) + g.Expect(client.IgnoreNotFound(err)).To(Succeed()) + g.Expect(err).To(HaveOccurred(), "Device should be deleted") + }).Should(Succeed()) + } + + // Clear gNMI state AFTER all resources are deleted and controllers have settled. + By("clearing gNMI state for next test") + time.Sleep(100 * time.Millisecond) + ClearGNMIState() + }) + + // Discover test files for this provider + testFiles, err := filepath.Glob(filepath.Join(testdataDir, "*.txt")) + if err != nil { + Fail(fmt.Sprintf("Failed to glob testdata: %v", err)) + } + + for _, testFile := range testFiles { + testName := filepath.Base(testFile) + testName = testName[:len(testName)-4] // remove .txt + + It("should reconcile "+testName, func(ctx SpecContext) { + By("parsing testdata file") + a, err := txtar.ParseFile(testFile) + Expect(err).NotTo(HaveOccurred(), "Failed to parse test file: %s", testFile) + Expect(len(a.Files)).To(BeNumerically(">=", 2), "Expected at least 2 files (resource(s) and state)") + + // Separate resources from state + var stateData []byte + var resources []txtar.File + for _, f := range a.Files { + if f.Name == "state" { + stateData = f.Data + } else { + resources = append(resources, f) + } + } + Expect(stateData).NotTo(BeEmpty(), "Expected '-- state --' section in testdata") + Expect(resources).NotTo(BeEmpty(), "Expected at least one resource in testdata") + + By(fmt.Sprintf("creating %d resource(s) from testdata", len(resources))) + for _, res := range resources { + obj := createResource(ctx, res, deviceName) + // Wait for each resource to be Ready before creating the next one. + waitForReady(ctx, obj) + } + + By("verifying gNMI state matches expected JSON") + state, err := GetGNMIState() + Expect(err).NotTo(HaveOccurred()) + + err = CompareJSON(string(state), string(stateData)) + Expect(err).NotTo(HaveOccurred(), "gNMI state does not match expected JSON") + }) + } + }) + } +}) + +// createResource creates a K8s resource from txtar file data. +// The file name format is "kind/name" (e.g., "prefixset/my-prefixset"). +// It substitutes "device" in deviceRef.name with the actual device name. +func createResource(ctx SpecContext, res txtar.File, deviceName string) client.Object { + obj := &unstructured.Unstructured{} + Expect(yaml.Unmarshal(res.Data, obj)).To(Succeed(), "Failed to unmarshal %s", res.Name) + + // Update deviceRef.name to use the actual device name + if spec, ok := obj.Object["spec"].(map[string]any); ok { + if deviceRef, ok := spec["deviceRef"].(map[string]any); ok { + deviceRef["name"] = deviceName + } + } + // Also update the device label + labels := obj.GetLabels() + if labels != nil { + if _, ok := labels[v1alpha1.DeviceLabel]; ok { + labels[v1alpha1.DeviceLabel] = deviceName + obj.SetLabels(labels) + } + } + + Expect(k8sClient.Create(ctx, obj)).To(Succeed(), "Failed to create %s", res.Name) + return obj +} + +// waitForReady waits for a resource to have Configured=True condition. +// Skips resources that don't have status conditions (e.g., InterfaceConfig). +func waitForReady(ctx SpecContext, obj client.Object) { + key := client.ObjectKeyFromObject(obj) + gvk := obj.GetObjectKind().GroupVersionKind() + + // Config-only resources don't have status conditions. + // They are just referenced by other resources, not reconciled independently. + switch gvk.Kind { + case "InterfaceConfig", "LLDPConfig", "BGPConfig", "NVEConfig", "ManagementAccessConfig": + return + } + + Eventually(func(g Gomega) { + // Fetch fresh copy + fresh := &unstructured.Unstructured{} + fresh.SetGroupVersionKind(gvk) + g.Expect(k8sClient.Get(ctx, key, fresh)).To(Succeed()) + + // Extract conditions from status + conditions, found, err := unstructured.NestedSlice(fresh.Object, "status", "conditions") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(found).To(BeTrue(), "Conditions should be set for %s/%s", gvk.Kind, key.Name) + g.Expect(conditions).NotTo(BeEmpty(), "Conditions should not be empty for %s/%s", gvk.Kind, key.Name) + + // Check for Configured=True (or Ready=True for resources without sub-conditions) + for _, c := range conditions { + cond, ok := c.(map[string]any) + if !ok { + continue + } + // Interface has sub-conditions; check Configured instead of Ready + if cond["type"] == v1alpha1.ConfiguredCondition { + g.Expect(cond["status"]).To(Equal(string(metav1.ConditionTrue)), + "%s/%s should be Configured, got reason: %v, message: %v", + gvk.Kind, key.Name, cond["reason"], cond["message"]) + return + } + } + // Fallback: check Ready for resources without ConfiguredCondition (e.g., PrefixSet) + for _, c := range conditions { + cond, ok := c.(map[string]any) + if !ok { + continue + } + if cond["type"] == v1alpha1.ReadyCondition { + g.Expect(cond["status"]).To(Equal(string(metav1.ConditionTrue)), + "%s/%s should be Ready, got reason: %v, message: %v", + gvk.Kind, key.Name, cond["reason"], cond["message"]) + } + } + }).Should(Succeed()) +} + +// cleanupAllResources deletes all test resources in the correct order. +// It forcibly removes finalizers if deletion is blocked. +func cleanupAllResources() { + for _, gvk := range resourceRegistry { + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(schema.GroupVersionKind{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind + "List", + }) + + if err := k8sClient.List(ctx, list, client.InNamespace(metav1.NamespaceDefault)); err != nil { + if meta.IsNoMatchError(err) { + continue // CRD not installed, skip + } + Expect(err).NotTo(HaveOccurred(), "Failed to list %s", gvk.Kind) + } + + // First pass: remove ALL finalizers to prevent controller re-reconciliation + for i := range list.Items { + item := &list.Items[i] + if len(item.GetFinalizers()) > 0 { + item.SetFinalizers(nil) + err := k8sClient.Update(ctx, item) + if apierrors.IsConflict(err) { + fresh := &unstructured.Unstructured{} + fresh.SetGroupVersionKind(item.GroupVersionKind()) + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(item), fresh)).To(Succeed()) + fresh.SetFinalizers(nil) + Expect(k8sClient.Update(ctx, fresh)).To(Succeed()) + } else { + Expect(err).NotTo(HaveOccurred()) + } + } + } + + // Second pass: delete all resources + for i := range list.Items { + item := &list.Items[i] + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, item))).To(Succeed()) + } + + // Wait for deletion + Eventually(func(g Gomega) { + freshList := &unstructured.UnstructuredList{} + freshList.SetGroupVersionKind(list.GetObjectKind().GroupVersionKind()) + g.Expect(k8sClient.List(ctx, freshList, client.InNamespace(metav1.NamespaceDefault))).To(Succeed()) + g.Expect(freshList.Items).To(BeEmpty(), "%s resources should be deleted", gvk.Kind) + }).Should(Succeed()) + } +} + +// CompareJSON compares two JSON strings semantically (ignoring key order and array order). +func CompareJSON(got, want string) error { + var gotData, wantData any + if err := json.Unmarshal([]byte(got), &gotData); err != nil { + return fmt.Errorf("failed to parse got JSON: %w", err) + } + if err := json.Unmarshal([]byte(want), &wantData); err != nil { + return fmt.Errorf("failed to parse want JSON: %w", err) + } + + // Normalize both by sorting arrays recursively + gotNorm := normalizeJSON(gotData) + wantNorm := normalizeJSON(wantData) + + gotBytes, err := json.Marshal(gotNorm) + if err != nil { + return fmt.Errorf("failed to normalize got JSON: %w", err) + } + wantBytes, err := json.Marshal(wantNorm) + if err != nil { + return fmt.Errorf("failed to normalize want JSON: %w", err) + } + + if !bytes.Equal(gotBytes, wantBytes) { + return fmt.Errorf("JSON mismatch:\ngot: %s\nwant: %s", string(gotBytes), string(wantBytes)) + } + return nil +} + +// normalizeJSON recursively sorts arrays and returns a normalized structure. +func normalizeJSON(v any) any { + switch val := v.(type) { + case map[string]any: + result := make(map[string]any, len(val)) + for k, v := range val { + result[k] = normalizeJSON(v) + } + return result + case []any: + // Normalize each element first + normalized := make([]any, len(val)) + for i, elem := range val { + normalized[i] = normalizeJSON(elem) + } + // Sort the array by JSON representation + sort.Slice(normalized, func(i, j int) bool { + bi, _ := json.Marshal(normalized[i]) //nolint:errcheck // comparison only + bj, _ := json.Marshal(normalized[j]) //nolint:errcheck // comparison only + return string(bi) < string(bj) + }) + return normalized + default: + return v + } +} diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go new file mode 100644 index 000000000..eee2b5a9f --- /dev/null +++ b/test/integration/suite_test.go @@ -0,0 +1,371 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package integration + +import ( + "context" + "crypto/tls" + "errors" + "os" + "path/filepath" + "slices" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/events" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/controller/core" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/provider/cisco/iosxr" + "github.com/ironcore-dev/network-operator/internal/provider/cisco/nxos" + "github.com/ironcore-dev/network-operator/internal/resourcelock" + + gnmitestserver "github.com/ironcore-dev/gnmi-test-server/testserver" +) + +// ProviderType represents the network device provider to test against. +type ProviderType string + +// ProviderFactory creates a new provider instance. +type ProviderFactory = func() provider.Provider + +const ( + ProviderNXOS ProviderType = "nxos" + ProviderIOSXR ProviderType = "iosxr" +) + +// ProviderConfig holds the configuration for a provider test. +type ProviderConfig struct { + Name ProviderType + NewProvider ProviderFactory +} + +// SupportedProviders lists all providers to test. +var SupportedProviders = []ProviderConfig{ + {Name: ProviderNXOS, NewProvider: func() provider.Provider { return nxos.NewProvider() }}, + {Name: ProviderIOSXR, NewProvider: func() provider.Provider { return iosxr.NewProvider() }}, +} + +var ( + ctx context.Context + cancel context.CancelFunc + testEnv *envtest.Environment + k8sClient client.Client + restCfg *rest.Config + + // gNMI test server + gnmiServer *gnmitestserver.Server + gnmiAddr string +) + +func TestIntegration(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Integration Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + SetDefaultEventuallyTimeout(30 * time.Second) + SetDefaultEventuallyPollingInterval(time.Second) + + ctx, cancel = context.WithCancel(ctrl.SetupSignalHandler()) + + By("starting in-process gNMI test server") + var err error + gnmiServer, gnmiAddr, _, err = gnmitestserver.NewTestServer(ctx, gnmitestserver.WithNXOSBehavior()) + Expect(err).NotTo(HaveOccurred()) + GinkgoLogr.Info("gNMI server started", "grpc", gnmiAddr) + + By("bootstrapping test environment") + err = corev1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + err = v1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + err = nxv1alpha1.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if dir := detectTestBinaryDir(); dir != "" { + testEnv.BinaryAssetsDirectory = dir + } + + restCfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(restCfg).NotTo(BeNil()) + + k8sClient, err = client.New(restCfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + Eventually(func() error { + var namespace corev1.Namespace + return k8sClient.Get(ctx, client.ObjectKey{Name: metav1.NamespaceDefault}, &namespace) + }).Should(Succeed()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + + var errs []error + if gnmiServer != nil { + if err := gnmiServer.Close(); err != nil { + errs = append(errs, err) + } + } + + if testEnv != nil { + if err := testEnv.Stop(); err != nil { + errs = append(errs, err) + } + } + + Expect(errors.Join(errs...)).NotTo(HaveOccurred(), "errors during teardown") +}) + +// ProviderTestContext holds the context for a provider-specific test run. +type ProviderTestContext struct { + Provider ProviderType + Manager ctrl.Manager + Locker *resourcelock.ResourceLocker + CancelFunc context.CancelFunc +} + +// SetupProviderTest creates a new manager with controllers for the given provider. +// Call the returned cleanup function in AfterEach/AfterAll. +func SetupProviderTest(providerCfg ProviderConfig) *ProviderTestContext { + GinkgoHelper() + + providerCtx, providerCancel := context.WithCancel(ctx) //nolint:gosec // cancel stored in ProviderTestContext + + mgr, err := ctrl.NewManager(restCfg, ctrl.Options{ + Scheme: scheme.Scheme, + Logger: GinkgoLogr, + }) + Expect(err).ToNot(HaveOccurred()) + + // Ignore events during tests + recorder := events.NewFakeRecorder(0) + go func() { + for range recorder.Events { //nolint:revive // intentionally drain events + } + }() + + locker, err := resourcelock.NewResourceLocker(mgr.GetClient(), metav1.NamespaceDefault, 15*time.Second, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + err = mgr.Add(locker) + Expect(err).NotTo(HaveOccurred()) + + providerFunc := providerCfg.NewProvider + + // Register the controllers + err = (&core.PrefixSetReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.RoutingPolicyReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.InterfaceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.VLANReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.VRFReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.NTPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.DNSReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.LLDPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.BannerReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + + go func() { + defer GinkgoRecover() + err = mgr.Start(providerCtx) + if providerCtx.Err() == nil { + Expect(err).ToNot(HaveOccurred(), "failed to run manager") + } + }() + + return &ProviderTestContext{ + Provider: providerCfg.Name, + Manager: mgr, + Locker: locker, + CancelFunc: providerCancel, + } +} + +// TeardownProviderTest stops the manager for a provider test. +func TeardownProviderTest(ptc *ProviderTestContext) { + if ptc != nil && ptc.CancelFunc != nil { + ptc.CancelFunc() + } +} + +// detectTestBinaryDir locates the first directory in the k8s binary path. +func detectTestBinaryDir() string { + basePath := filepath.Join("..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + return "" + } + idx := slices.IndexFunc(entries, func(e os.DirEntry) bool { + return e.IsDir() + }) + if idx >= 0 { + return filepath.Join(basePath, entries[idx].Name()) + } + return "" +} + +// GNMIAddr returns the gNMI server address for tests to use +func GNMIAddr() string { + return gnmiAddr +} + +// GetGNMIState returns the current accumulated gNMI state as JSON +func GetGNMIState() ([]byte, error) { + return gnmiServer.GetState() +} + +// ClearGNMIState clears the accumulated gNMI state +func ClearGNMIState() { + gnmiServer.ClearState() +} + +// NewGNMIConnection creates a gRPC connection to the in-process gNMI server +func NewGNMIConnection() (*grpc.ClientConn, error) { + return grpc.NewClient( + gnmiAddr, + grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{ + InsecureSkipVerify: true, //nolint:gosec + })), + ) +} + +// CreateTestDevice creates a Device pointing to the in-process gNMI server. +// If name is empty, a unique name is generated using GenerateName. +func CreateTestDevice(ctx context.Context, c client.Client, name string) (*v1alpha1.Device, error) { + device := &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: metav1.NamespaceDefault, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: gnmiAddr, + }, + }, + } + if name == "" { + device.GenerateName = "test-device-" + } else { + device.Name = name + } + if err := c.Create(ctx, device); err != nil { + return nil, err + } + + // Set the device status to Running so that dependent resources can reconcile + device.Status.Phase = v1alpha1.DevicePhaseRunning + if err := c.Status().Update(ctx, device); err != nil { + return nil, err + } + + return device, nil +} + +// TestdataPath returns the path to testdata for the given provider. +// Example: testdata/nxos/interfaces.txt +func TestdataPath(provider ProviderType, filename string) string { + return filepath.Join("testdata", string(provider), filename) +} From 56d02f44fa52adc79cf2edd4010c6b3426311fcf Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:14:48 +0200 Subject: [PATCH 02/24] docs: add AGENTS.md Explain test architecture, txtar format, and how to add new tests. Signed-off-by: Pujol --- test/integration/AGENTS.md | 95 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 test/integration/AGENTS.md diff --git a/test/integration/AGENTS.md b/test/integration/AGENTS.md new file mode 100644 index 000000000..15f9fce7e --- /dev/null +++ b/test/integration/AGENTS.md @@ -0,0 +1,95 @@ +# Integration Tests + +Tests CRD → gNMI JSON flow using envtest + in-process gNMI server. + +## Architecture + +``` +envtest (in-process K8s API) + ↓ +Real Controllers (PrefixSet, RoutingPolicy, Interface) + ↓ +Provider (NX-OS, IOS-XR, etc.) + ↓ +In-process gNMI Test Server (accumulates state as JSON) +``` + +## Multi-Provider Testing + +Tests run for all providers that have testdata. Each provider gets its own +`Describe` block with isolated controller manager. + +``` +testdata/ +├── nxos/ # Cisco NX-OS provider tests +│ ├── interfaces.txt +│ └── routingpolicy_prefixset.txt +└── iosxr/ # Cisco IOS-XR provider tests (when added) + └── ... +``` + +To add tests for a new provider: +1. Create `testdata//` directory +2. Add txtar test files with CRD YAML + expected gNMI JSON +3. The provider will be automatically discovered and tested + +## Testdata Format (txtar) + +``` +-- kind/name -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: PrefixSet +metadata: + name: my-prefixset # K8s object name (can differ from spec.name) + namespace: default +spec: + deviceRef: + name: device # substituted at runtime with generated device name + name: MY-PREFIXSET # Device/gNMI name (used in expected state) + ... +-- state -- +{ + "System": { ... expected gNMI JSON ... } +} +``` + +- Resources are created in order listed +- `deviceRef.name: device` is auto-replaced with the test's unique device name +- K8s `metadata.name` should differ from `spec.name` to verify correct field usage +- Comment expected state to explain why each gNMI path is present + +## Resource Dependencies + +Some resources depend on others (e.g., BGPPeer requires BGP, NVE requires Interface). +**Pack dependent resources into a single test file** rather than relying on test ordering. + +Examples: +- `bgp_bgppeer.txt` - Interface (loopback) + BGPConfig + BGP + BGPPeer +- `nve.txt` - Interface (loopback) + NVE +- `ospf.txt` - Interface + OSPF +- `evpninstance.txt` - VRF + EVPNInstance + +This ensures tests are self-contained and don't depend on alphabetical execution order. + +## Adding New Tests + +1. Create `testdata//.txt` with resource YAML + expected JSON state +2. Tests are auto-discovered from `*.txt` files in the provider directory +3. If new resource type, add GVK to `resourceRegistry` in `main_test.go` + +## Key Behaviors + +- **Condition check**: Interface uses `ConfiguredCondition`, others use `ReadyCondition` +- **JSON comparison**: Semantic (key order independent) +- **Cleanup**: Finalizers removed in two passes to avoid controller conflicts +- **Sequential creation**: Resources created and waited-for in order (handles dependencies) + +## Run + +```bash +# Run all provider tests +KUBEBUILDER_ASSETS=$(setup-envtest use 1.35 -p path) go test ./test/integration/... + +# Verbose output +KUBEBUILDER_ASSETS=$(setup-envtest use 1.35 -p path) go test ./test/integration/... -v +``` From 6de3dae361eff92ce174466fa2d05a726e6acfd5 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:14:59 +0200 Subject: [PATCH 03/24] test: add banner integration test Signed-off-by: Pujol --- test/integration/testdata/nxos/banner.txt | 33 +++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 test/integration/testdata/nxos/banner.txt diff --git a/test/integration/testdata/nxos/banner.txt b/test/integration/testdata/nxos/banner.txt new file mode 100644 index 000000000..0b3c7d704 --- /dev/null +++ b/test/integration/testdata/nxos/banner.txt @@ -0,0 +1,33 @@ +# NX-OS Banner Integration Test +# +# Tests login banner configuration. +# +# Expected gNMI paths: +# userext-items/preloginbanner-items - Pre-login banner message + +-- banner/prelogin -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Banner +metadata: + name: prelogin + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + type: PreLogin + message: + inline: "Authorized users only. All activity is monitored." + +-- state -- +{ + "System": { + "userext-items": { + "preloginbanner-items": { + "delimiter": "^", + "message": "Authorized users only. All activity is monitored." + } + } + } +} From db42686fd91d1d34f084878fb81d6dc40c96c20f Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:15:08 +0200 Subject: [PATCH 04/24] test: add DNS integration test Signed-off-by: Pujol --- test/integration/testdata/nxos/dns.txt | 56 ++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 test/integration/testdata/nxos/dns.txt diff --git a/test/integration/testdata/nxos/dns.txt b/test/integration/testdata/nxos/dns.txt new file mode 100644 index 000000000..932c98fc2 --- /dev/null +++ b/test/integration/testdata/nxos/dns.txt @@ -0,0 +1,56 @@ +# NX-OS DNS Integration Test +# +# Tests DNS server and domain configuration. +# +# Expected gNMI paths: +# dns-items - DNS configuration with servers and domain + +-- dns/config -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: DNS +metadata: + name: config + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + adminState: Up + domain: example.com + servers: + - address: 10.10.10.10 + vrfName: management + +-- state -- +{ + "System": { + "dns-items": { + "adminSt": "enabled", + "prof-items": { + "Prof-list": [ + { + "name": "default", + "dom-items": { + "name": "example.com" + }, + "vrf-items": { + "Vrf-list": [ + { + "name": "management", + "prov-items": { + "Provider-list": [ + { + "addr": "10.10.10.10" + } + ] + } + } + ] + } + } + ] + } + } + } +} From 0caa76194ef72fbb7f93fa81d77ea050285896eb Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:15:08 +0200 Subject: [PATCH 05/24] test: add interfaceconfig integration test (NX-OS specific) Signed-off-by: Pujol --- .../testdata/nxos/interfaceconfig.txt | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 test/integration/testdata/nxos/interfaceconfig.txt diff --git a/test/integration/testdata/nxos/interfaceconfig.txt b/test/integration/testdata/nxos/interfaceconfig.txt new file mode 100644 index 000000000..c6f24bc17 --- /dev/null +++ b/test/integration/testdata/nxos/interfaceconfig.txt @@ -0,0 +1,187 @@ +# NX-OS InterfaceConfig Integration Test +# +# Tests Cisco NX-OS provider-specific interface configuration via InterfaceConfig CRD: +# - SpanningTree: port type (edge/network), BPDU Guard, BPDU Filter +# - BufferBoost: enable/disable on physical interfaces +# - LACP options: VPC convergence, suspend individual +# +# Test topology: +# eth1/2 (Physical L2) - SpanningTree Edge + BPDUGuard, BufferBoost disabled +# po20 (Aggregate L2) - SpanningTree Network + BPDUFilter, LACP VPCConvergence, SuspendIndividual=false +# +# Expected gNMI paths: +# intf-items/phys-items/PhysIf-list[id=eth1/2]/physExtd-items/bufferBoost = "disable" +# intf-items/aggr-items/AggrIf-list[id=po20]/lacpVpcConvergence = "enable" +# intf-items/aggr-items/AggrIf-list[id=po20]/suspIndividual = "disable" +# stp-items/inst-items/if-items/If-list[id=eth1/2]/mode = "edge" +# stp-items/inst-items/if-items/If-list[id=eth1/2]/bpduguard = "enable" +# stp-items/inst-items/if-items/If-list[id=po20]/mode = "network" +# stp-items/inst-items/if-items/If-list[id=po20]/bpdufilter = "enable" + +-- interfaceconfig/edge-port-config -- +apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 +kind: InterfaceConfig +metadata: + name: interface-nxconfig-edge + namespace: default +spec: + bufferBoost: + enabled: false + spanningTree: + portType: Edge + bpduGuard: true + +-- interfaceconfig/network-pc-config -- +apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 +kind: InterfaceConfig +metadata: + name: interface-nxconfig-po + namespace: default +spec: + lacp: + vpcConvergence: true + suspendIndividual: false + spanningTree: + portType: Network + bpduFilter: true + +-- interface/stp-edge-port -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: eth1-2 + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: eth1/2 + type: Physical + adminState: Up + mtu: 1500 + description: "Edge port with STP and BufferBoost config" + providerConfigRef: + apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 + kind: InterfaceConfig + name: interface-nxconfig-edge + switchport: + mode: Trunk + nativeVlan: 1 + allowedVlans: + - 10 + +-- interface/stp-network-pc -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: po-20 + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: po20 + type: Aggregate + adminState: Up + mtu: 1500 + description: "Network port-channel with LACP and STP config" + providerConfigRef: + apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 + kind: InterfaceConfig + name: interface-nxconfig-po + switchport: + mode: Trunk + nativeVlan: 1 + allowedVlans: + - 20 + aggregation: + controlProtocol: + mode: Active + memberInterfaceRefs: + - name: eth1-2 + +-- state -- +{ + "System": { + "fm-items": { + "lacp-items": { + "adminSt": "enabled" + } + }, + "intf-items": { + "phys-items": { + "PhysIf-list": [ + { + "accessVlan": "vlan-1", + "adminSt": "up", + "descr": "Edge port with STP and BufferBoost config", + "FECMode": "auto", + "id": "eth1/2", + "layer": "Layer2", + "medium": "broadcast", + "mode": "trunk", + "mtu": 1500, + "nativeVlan": "vlan-1", + "trunkVlans": "10", + "userCfgdFlags": "admin_layer,admin_mtu,admin_state", + "physExtd-items": { + "bufferBoost": "disable" + } + } + ] + }, + "aggr-items": { + "AggrIf-list": [ + { + "accessVlan": "vlan-1", + "adminSt": "up", + "aggrExtd-items": { + "bufferBoost": "enable" + }, + "descr": "Network port-channel with LACP and STP config", + "id": "po20", + "lacpVpcConvergence": "enable", + "layer": "Layer2", + "medium": "broadcast", + "mode": "trunk", + "mtu": 1500, + "nativeVlan": "vlan-1", + "pcMode": "active", + "suspIndividual": "disable", + "trunkVlans": "20", + "userCfgdFlags": "admin_layer,admin_mtu,admin_state", + "rsmbrIfs-items": { + "RsMbrIfs-list": [ + { + "tDn": "/System/intf-items/phys-items/PhysIf-list[id='eth1/2']" + } + ] + } + } + ] + } + }, + "stp-items": { + "inst-items": { + "if-items": { + "If-list": [ + { + "id": "eth1/2", + "mode": "edge", + "bpdufilter": "default", + "bpduguard": "enable" + }, + { + "id": "po20", + "mode": "network", + "bpdufilter": "enable", + "bpduguard": "default" + } + ] + } + } + } + } +} From f958e4181d83dbff6301f8958b36414b5592e38e Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:15:09 +0200 Subject: [PATCH 06/24] test: add interfaces integration test Tests three interface types: Loopback, Physical L3 with BFD, Aggregate L2 with vPC. Signed-off-by: Pujol --- test/integration/testdata/nxos/interfaces.txt | 292 ++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 test/integration/testdata/nxos/interfaces.txt diff --git a/test/integration/testdata/nxos/interfaces.txt b/test/integration/testdata/nxos/interfaces.txt new file mode 100644 index 000000000..d42957028 --- /dev/null +++ b/test/integration/testdata/nxos/interfaces.txt @@ -0,0 +1,292 @@ +# Integration test for Interface resources +# +# Tests three interface types with dependencies: +# loopback-vtep -> Loopback with IPv4 address (spec.name: lo0) +# uplink-spine1 -> Physical L3 with unnumbered IPv4 + BFD (spec.name: eth1/1) +# host1-pc -> Aggregate L2 trunk with LACP, vPC (spec.name: po10) +# +# K8s metadata.name differs from spec.name to verify the provider uses spec.name +# for gNMI paths, not the K8s object name. +# +# Resources must be created in order due to dependencies. +# +# Expected gNMI state mapping (see internal/provider/cisco/nxos/provider.go): +# +# fm-items/bfd-items: uplink-spine1 has bfd.enabled=true -> Feature "bfd" enabled +# fm-items/lacp-items: host1-pc is Aggregate -> Feature "lacp" enabled +# +# bfd-items/inst-items: uplink-spine1.bfd config -> BFD interface settings +# +# icmpv4-items: L3 interfaces (lo0, eth1/1) get ICMP config +# - lo0: "port-unreachable,redirect" (loopback default) +# - eth1/1: "port-unreachable" (physical default) +# +# intf-items/lb-items: loopback-vtep is Loopback -> LbRtdIf-list[id=lo0] +# intf-items/phys-items: uplink-spine1 is Physical -> PhysIf-list[id=eth1/1] +# intf-items/aggr-items: host1-pc is Aggregate -> AggrIf-list[id=po10] +# - rsmbrIfs-items: memberInterfaceRefs -> member list +# - pcMode: aggregation.controlProtocol.mode=Active -> "active" +# +# ipv4-items: L3 interfaces with IPv4 config +# - lo0: ipv4.addresses -> Addr-list +# - eth1/1: ipv4.unnumbered -> unnumbered ref +# +# stp-items: host1-pc is L2 Aggregate (no IPv4) -> SpanningTree config +# Defaults: mode=default, bpdufilter=default, bpduguard=default +# +# vpc-items: host1-pc.aggregation.multichassis.id=2 -> VPCIf with id=2 +# References the port-channel via tDn +-- interface/loopback-vtep -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + labels: + networking.metal.ironcore.dev/device-name: device + name: loopback-vtep + namespace: default +spec: + deviceRef: + name: device + name: lo0 + description: NVE/VTEP Leaf1 + adminState: Up + type: Loopback + ipv4: + addresses: + - 10.0.0.10/32 +-- interface/uplink-spine1 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + labels: + networking.metal.ironcore.dev/device-name: device + name: uplink-spine1 + namespace: default +spec: + deviceRef: + name: device + name: eth1/1 + description: Leaf1 to Spine1 + adminState: Up + type: Physical + mtu: 9216 + ipv4: + unnumbered: + interfaceRef: + name: loopback-vtep + bfd: + enabled: true + desiredMinimumTxInterval: 300ms + requiredMinimumReceive: 300ms + detectionMultiplier: 3 +-- interface/host1-pc -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + labels: + networking.metal.ironcore.dev/device-name: device + name: host1-pc + namespace: default +spec: + deviceRef: + name: device + name: po10 + description: vPC to Host1 + adminState: Up + type: Aggregate + mtu: 1500 + switchport: + mode: Trunk + nativeVlan: 1 + allowedVlans: [10] + aggregation: + controlProtocol: + mode: Active + memberInterfaceRefs: + - name: uplink-spine1 + multichassis: + enabled: true + id: 2 +-- state -- +{ + "System": { + "bfd-items": { + "inst-items": { + "if-items": { + "If-list": [ + { + "adminSt": "enabled", + "id": "eth1/1", + "ifka-items": { + "detectMult": 3, + "minRxIntvl": 300, + "minTxIntvl": 300 + } + } + ] + } + } + }, + "fm-items": { + "bfd-items": { + "adminSt": "enabled" + }, + "lacp-items": { + "adminSt": "enabled" + } + }, + "icmpv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "ctrl": "port-unreachable,redirect", + "id": "lo0" + }, + { + "ctrl": "port-unreachable", + "id": "eth1/1" + } + ] + } + } + ] + } + } + }, + "intf-items": { + "aggr-items": { + "AggrIf-list": [ + { + "accessVlan": "vlan-1", + "adminSt": "up", + "aggrExtd-items": { + "bufferBoost": "enable" + }, + "descr": "vPC to Host1", + "id": "po10", + "lacpVpcConvergence": "disable", + "layer": "Layer2", + "medium": "broadcast", + "mode": "trunk", + "mtu": 1500, + "nativeVlan": "vlan-1", + "pcMode": "active", + "rsmbrIfs-items": { + "RsMbrIfs-list": [ + { + "tDn": "/System/intf-items/phys-items/PhysIf-list[id='eth1/1']" + } + ] + }, + "suspIndividual": "enable", + "trunkVlans": "10", + "userCfgdFlags": "admin_layer,admin_mtu,admin_state" + } + ] + }, + "lb-items": { + "LbRtdIf-list": [ + { + "adminSt": "up", + "descr": "NVE/VTEP Leaf1", + "id": "lo0", + "rtvrfMbr-items": { + "tDn": "/System/inst-items/Inst-list[name='default']" + } + } + ] + }, + "phys-items": { + "PhysIf-list": [ + { + "FECMode": "auto", + "accessVlan": "unknown", + "adminSt": "up", + "descr": "Leaf1 to Spine1", + "id": "eth1/1", + "layer": "Layer3", + "medium": "p2p", + "mode": "access", + "mtu": 9216, + "nativeVlan": "unknown", + "physExtd-items": { + "bufferBoost": "enable" + }, + "rtvrfMbr-items": { + "tDn": "/System/inst-items/Inst-list[name='default']" + }, + "trunkVlans": "1-4094", + "userCfgdFlags": "admin_layer,admin_mtu,admin_state" + } + ] + } + }, + "ipv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "addr-items": { + "Addr-list": [ + { + "addr": "10.0.0.10/32", + "pref": 0, + "tag": 0, + "type": "primary" + } + ] + }, + "id": "lo0" + }, + { + "id": "eth1/1", + "unnumbered": "lo0" + } + ] + } + } + ] + } + } + }, + "stp-items": { + "inst-items": { + "if-items": { + "If-list": [ + { + "bpdufilter": "default", + "bpduguard": "default", + "id": "po10", + "mode": "default" + } + ] + } + } + }, + "vpc-items": { + "inst-items": { + "dom-items": { + "if-items": { + "If-list": [ + { + "id": 2, + "rsvpcConf-items": { + "tDn": "/System/intf-items/aggr-items/AggrIf-list[id='po10']" + } + } + ] + } + } + } + } + } +} From 473fc0484f728f2d9b762d0962adc19dd2e4c259 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:15:09 +0200 Subject: [PATCH 07/24] test: add LLDP integration test Signed-off-by: Pujol --- test/integration/testdata/nxos/lldp.txt | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 test/integration/testdata/nxos/lldp.txt diff --git a/test/integration/testdata/nxos/lldp.txt b/test/integration/testdata/nxos/lldp.txt new file mode 100644 index 000000000..7965746f2 --- /dev/null +++ b/test/integration/testdata/nxos/lldp.txt @@ -0,0 +1,52 @@ +# NX-OS LLDP Integration Test +# +# Tests LLDP (Link Layer Discovery Protocol) configuration with provider-specific +# LLDPConfig for NX-OS specific settings (holdTime, initDelay). +# +# Expected gNMI paths: +# fm-items/lldp-items - LLDP feature manager +# lldp-items/inst-items - LLDP instance configuration + +-- lldpconfig/lldp-settings -- +apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 +kind: LLDPConfig +metadata: + name: lldp-settings + namespace: default +spec: + holdTime: 180 + initDelay: 5 + +-- lldp/config -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: LLDP +metadata: + name: config + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + adminState: Up + providerConfigRef: + apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 + kind: LLDPConfig + name: lldp-settings + +-- state -- +{ + "System": { + "fm-items": { + "lldp-items": { + "adminSt": "enabled" + } + }, + "lldp-items": { + "inst-items": { + "holdTime": 180, + "initDelayTime": 5 + } + } + } +} From 41876b3f79d938e0472b1b585c9a456d0ee5ecb0 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:15:09 +0200 Subject: [PATCH 08/24] test: add NTP integration test Signed-off-by: Pujol --- test/integration/testdata/nxos/ntp.txt | 55 ++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 test/integration/testdata/nxos/ntp.txt diff --git a/test/integration/testdata/nxos/ntp.txt b/test/integration/testdata/nxos/ntp.txt new file mode 100644 index 000000000..95488d90e --- /dev/null +++ b/test/integration/testdata/nxos/ntp.txt @@ -0,0 +1,55 @@ +# NX-OS NTP Integration Test +# +# Tests NTP server configuration. +# +# Expected gNMI paths: +# time-items - NTP configuration with servers + +-- ntp/config -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: NTP +metadata: + name: config + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + adminState: Up + sourceInterfaceName: mgmt0 + servers: + - address: ntp.example.com + prefer: true + vrfName: management + +-- state -- +{ + "System": { + "fm-items": { + "ntpd-items": { + "adminSt": "enabled" + } + }, + "time-items": { + "adminSt": "enabled", + "logging": "disabled", + "prov-items": { + "NtpProvider-list": [ + { + "keyId": 0, + "maxPoll": 6, + "minPoll": 4, + "name": "ntp.example.com", + "preferred": true, + "provT": "server", + "vrf": "management" + } + ] + }, + "srcIf-items": { + "srcIf": "mgmt0" + } + } + } +} From 8e29c6becbc06176a6d64374b6c826aeb65382b8 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:15:09 +0200 Subject: [PATCH 09/24] test: add routedvlan integration test Signed-off-by: Pujol --- test/integration/testdata/nxos/routedvlan.txt | 135 ++++++++++++++++++ 1 file changed, 135 insertions(+) create mode 100644 test/integration/testdata/nxos/routedvlan.txt diff --git a/test/integration/testdata/nxos/routedvlan.txt b/test/integration/testdata/nxos/routedvlan.txt new file mode 100644 index 000000000..1391c363c --- /dev/null +++ b/test/integration/testdata/nxos/routedvlan.txt @@ -0,0 +1,135 @@ +# NX-OS RoutedVLAN (SVI) Integration Test +# +# Tests L3 VLAN interface (SVI - Switched Virtual Interface). +# RoutedVLAN interfaces provide L3 routing for a VLAN. +# +# Test topology: +# VLAN 10 (L2 VLAN) +# vlan10 (RoutedVLAN/SVI with IPv4 address) +# +# Expected gNMI paths: +# intf-items/svi-items/If-list[id=vlan10] - SVI interface +# ipv4-items/.../If-list[id=vlan10] - IPv4 address on SVI + +-- vlan/vlan10 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: VLAN +metadata: + name: vlan10 + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + id: 10 + name: SERVERS + +-- interface/svi-vlan10 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: svi-vlan10 + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: vlan10 + type: RoutedVLAN + adminState: Up + mtu: 9000 + description: "SVI for VLAN 10 - Servers" + vlanRef: + name: vlan10 + ipv4: + addresses: + - 10.10.0.1/24 + +-- state -- +{ + "System": { + "bd-items": { + "bd-items": { + "BD-list": [ + { + "BdState": "active", + "adminSt": "active", + "fabEncap": "vlan-10", + "name": "SERVERS" + } + ] + } + }, + "fm-items": { + "ifvlan-items": { + "adminSt": "enabled" + } + }, + "icmpv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "vlan10", + "ctrl": "port-unreachable" + } + ] + } + } + ] + } + } + }, + "intf-items": { + "svi-items": { + "If-list": [ + { + "adminSt": "up", + "descr": "SVI for VLAN 10 - Servers", + "id": "vlan10", + "medium": "bcast", + "mtu": 9000, + "rtvrfMbr-items": { + "tDn": "/System/inst-items/Inst-list[name='default']" + }, + "vlanId": 10 + } + ] + } + }, + "ipv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "vlan10", + "addr-items": { + "Addr-list": [ + { + "addr": "10.10.0.1/24", + "pref": 0, + "tag": 0, + "type": "primary" + } + ] + } + } + ] + } + } + ] + } + } + } + } +} From aa04417f0dda2eebd3fa5e4e75f06b2377119a07 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:15:09 +0200 Subject: [PATCH 10/24] test: add routingpolicy and prefixset integration test Signed-off-by: Pujol --- .../testdata/nxos/routingpolicy_prefixset.txt | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 test/integration/testdata/nxos/routingpolicy_prefixset.txt diff --git a/test/integration/testdata/nxos/routingpolicy_prefixset.txt b/test/integration/testdata/nxos/routingpolicy_prefixset.txt new file mode 100644 index 000000000..3443e7301 --- /dev/null +++ b/test/integration/testdata/nxos/routingpolicy_prefixset.txt @@ -0,0 +1,150 @@ +# RoutingPolicy with PrefixSet reference +# +# Tests that: +# - PrefixSet uses spec.name (not metadata.name) for gNMI path +# - RoutingPolicy route-map references PrefixSet via tDn using spec.name +# - IPv4 and IPv6 prefix sets go to different gNMI paths (pfxlistv4 vs pfxlistv6) +# +# K8s names vs spec.names: +# test-prefixset-v4 -> IPV4-NETWORKS (pfxlistv4-items) +# test-prefixset-v6 -> IPV6-NETWORKS (pfxlistv6-items) +# rm-import -> RM-IMPORT (rtmap-items) +-- prefixset/test-prefixset-v4 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: PrefixSet +metadata: + name: test-prefixset-v4 + namespace: default + labels: + networking.metal.ironcore.dev/device-name: device +spec: + deviceRef: + name: device + name: IPV4-NETWORKS + entries: + - sequence: 10 + prefix: 10.0.0.0/8 +-- prefixset/test-prefixset-v6 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: PrefixSet +metadata: + name: test-prefixset-v6 + namespace: default + labels: + networking.metal.ironcore.dev/device-name: device +spec: + deviceRef: + name: device + name: IPV6-NETWORKS + entries: + - sequence: 10 + prefix: "2001:db8::/32" +-- routingpolicy/rm-import -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: RoutingPolicy +metadata: + name: rm-import + namespace: default + labels: + networking.metal.ironcore.dev/device-name: device +spec: + deviceRef: + name: device + name: RM-IMPORT + statements: + - sequence: 10 + conditions: + matchPrefixSet: + prefixSetRef: + name: test-prefixset-v4 + actions: + routeDisposition: AcceptRoute + - sequence: 20 + conditions: + matchPrefixSet: + prefixSetRef: + name: test-prefixset-v6 + actions: + routeDisposition: AcceptRoute +-- state -- +{ + "System": { + "rpm-items": { + "pfxlistv4-items": { + "RuleV4-list": [ + { + "name": "IPV4-NETWORKS", + "ent-items": { + "Entry-list": [ + { + "action": "permit", + "criteria": "exact", + "fromPfxLen": 0, + "order": 10, + "pfx": "10.0.0.0/8", + "toPfxLen": 0 + } + ] + } + } + ] + }, + "pfxlistv6-items": { + "RuleV6-list": [ + { + "name": "IPV6-NETWORKS", + "ent-items": { + "Entry-list": [ + { + "action": "permit", + "criteria": "exact", + "fromPfxLen": 0, + "order": 10, + "pfx": "2001:db8::/32", + "toPfxLen": 0 + } + ] + } + } + ] + }, + "rtmap-items": { + "Rule-list": [ + { + "name": "RM-IMPORT", + "ent-items": { + "Entry-list": [ + { + "action": "permit", + "order": 10, + "mrtdst-items": { + "rsrtDstAtt-items": { + "RsRtDstAtt-list": [ + { + "tDn": "/System/rpm-items/pfxlistv4-items/RuleV4-list[name='IPV4-NETWORKS']" + } + ] + } + } + }, + { + "action": "permit", + "order": 20, + "mrtdst-items": { + "rsrtDstAtt-items": { + "RsRtDstAtt-list": [ + { + "tDn": "/System/rpm-items/pfxlistv6-items/RuleV6-list[name='IPV6-NETWORKS']" + } + ] + } + } + } + ] + } + } + ] + } + } + } +} From 3f30cdb229fabaea437ecfce18f9642b03af8e31 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:15:09 +0200 Subject: [PATCH 11/24] test: add subinterface integration test Signed-off-by: Pujol --- .../testdata/nxos/subinterface.txt | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 test/integration/testdata/nxos/subinterface.txt diff --git a/test/integration/testdata/nxos/subinterface.txt b/test/integration/testdata/nxos/subinterface.txt new file mode 100644 index 000000000..66eba89f8 --- /dev/null +++ b/test/integration/testdata/nxos/subinterface.txt @@ -0,0 +1,142 @@ +# NX-OS Subinterface Integration Test +# +# Tests L3 subinterface with 802.1q encapsulation. +# Subinterfaces require a parent physical interface and VLAN tag. +# +# Test topology: +# eth1/3 (Physical L3 parent) +# eth1/3.100 (Subinterface with VLAN tag 100) +# +# Expected gNMI paths: +# intf-items/phys-items/PhysIf-list[id=eth1/3] - parent interface (L3) +# intf-items/encrtd-items/EncRtdIf-list[id=eth1/3.100] - subinterface +# ipv4-items/.../If-list[id=eth1/3.100] - IPv4 address on subinterface + +-- interface/parent-eth -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: parent-eth + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: eth1/3 + type: Physical + adminState: Up + mtu: 9216 + description: "Parent interface for subinterfaces" + +-- interface/subif-vlan100 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: subif-vlan100 + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: eth1/3.100 + type: Subinterface + adminState: Up + mtu: 1500 + description: "L3 Subinterface VLAN 100" + parentInterfaceRef: + name: parent-eth + encapsulation: + type: "802.1q" + tag: 100 + ipv4: + addresses: + - 10.100.0.1/24 + +-- state -- +{ + "System": { + "intf-items": { + "phys-items": { + "PhysIf-list": [ + { + "FECMode": "auto", + "accessVlan": "vlan-1", + "adminSt": "up", + "descr": "Parent interface for subinterfaces", + "id": "eth1/3", + "layer": "Layer2", + "medium": "broadcast", + "mode": "access", + "mtu": 9216, + "nativeVlan": "vlan-1", + "physExtd-items": { + "bufferBoost": "enable" + }, + "trunkVlans": "1-4094", + "userCfgdFlags": "admin_layer,admin_mtu,admin_state" + } + ] + }, + "encrtd-items": { + "EncRtdIf-list": [ + { + "adminSt": "up", + "descr": "L3 Subinterface VLAN 100", + "encap": "vlan-100", + "id": "eth1/3.100", + "mediumType": "broadcast", + "mtu": 1500, + "mtuInherit": false, + "rtvrfMbr-items": { + "tDn": "/System/inst-items/Inst-list[name='default']" + } + } + ] + } + }, + "ipv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "eth1/3.100", + "addr-items": { + "Addr-list": [ + { + "addr": "10.100.0.1/24", + "pref": 0, + "tag": 0, + "type": "primary" + } + ] + } + } + ] + } + } + ] + } + } + }, + "stp-items": { + "inst-items": { + "if-items": { + "If-list": [ + { + "id": "eth1/3", + "mode": "default", + "bpdufilter": "default", + "bpduguard": "default" + } + ] + } + } + } + } +} From ee3d5c6ede28522eb33458cc0331731b18be4de2 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:15:09 +0200 Subject: [PATCH 12/24] test: add VRF integration test Signed-off-by: Pujol --- test/integration/testdata/nxos/vrf.txt | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 test/integration/testdata/nxos/vrf.txt diff --git a/test/integration/testdata/nxos/vrf.txt b/test/integration/testdata/nxos/vrf.txt new file mode 100644 index 000000000..cd4e57af9 --- /dev/null +++ b/test/integration/testdata/nxos/vrf.txt @@ -0,0 +1,42 @@ +# NX-OS VRF Integration Test +# +# Tests VRF (Virtual Routing and Forwarding) configuration. +# +# Expected gNMI paths: +# inst-items/Inst-list - VRF instance configuration + +-- vrf/tenant1 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: VRF +metadata: + name: tenant1 + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: TENANT-1 + description: "Tenant 1 VRF" + +-- state -- +{ + "System": { + "inst-items": { + "Inst-list": [ + { + "name": "TENANT-1", + "descr": "Tenant 1 VRF", + "l3vni": false, + "dom-items": { + "Dom-list": [ + { + "name": "TENANT-1" + } + ] + } + } + ] + } + } +} From cfc574fdce1dbac2598b8f950958f46f46b3da21 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:15:34 +0200 Subject: [PATCH 13/24] test: add OSPF and PIM integration tests with interface dependencies OSPF and PIM require interfaces to be configured first. Test files include the interface dependency. Signed-off-by: Pujol --- test/integration/main_test.go | 2 + test/integration/suite_test.go | 19 +++ test/integration/testdata/nxos/ospf.txt | 147 ++++++++++++++++++++++++ test/integration/testdata/nxos/pim.txt | 130 +++++++++++++++++++++ 4 files changed, 298 insertions(+) create mode 100644 test/integration/testdata/nxos/ospf.txt create mode 100644 test/integration/testdata/nxos/pim.txt diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 07a9c42be..569ff15b6 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -38,6 +38,8 @@ var resourceRegistry = []schema.GroupVersionKind{ v1alpha1.GroupVersion.WithKind("DNS"), v1alpha1.GroupVersion.WithKind("LLDP"), v1alpha1.GroupVersion.WithKind("Banner"), + v1alpha1.GroupVersion.WithKind("OSPF"), + v1alpha1.GroupVersion.WithKind("PIM"), nxv1alpha1.GroupVersion.WithKind("InterfaceConfig"), nxv1alpha1.GroupVersion.WithKind("LLDPConfig"), v1alpha1.GroupVersion.WithKind("RoutingPolicy"), diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go index eee2b5a9f..1829ace63 100644 --- a/test/integration/suite_test.go +++ b/test/integration/suite_test.go @@ -269,6 +269,25 @@ func SetupProviderTest(providerCfg ProviderConfig) *ProviderTestContext { }).SetupWithManager(providerCtx, mgr) Expect(err).NotTo(HaveOccurred()) + err = (&core.OSPFReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.PIMReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = mgr.Start(providerCtx) diff --git a/test/integration/testdata/nxos/ospf.txt b/test/integration/testdata/nxos/ospf.txt new file mode 100644 index 000000000..fe284cc84 --- /dev/null +++ b/test/integration/testdata/nxos/ospf.txt @@ -0,0 +1,147 @@ +# NX-OS OSPF Integration Test +# +# OSPF requires at least one interface. We create a loopback first. + +-- interface/lo-ospf -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: lo-ospf + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: lo10 + type: Loopback + adminState: Up + ipv4: + addresses: + - 10.255.255.10/32 + +-- ospf/underlay -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: OSPF +metadata: + name: underlay + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + adminState: Up + instance: UNDERLAY + routerId: 10.255.255.10 + interfaceRefs: + - name: lo-ospf + area: "0.0.0.0" + +-- state -- +{ + "System": { + "fm-items": { + "ospf-items": { + "adminSt": "enabled" + } + }, + "icmpv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "lo10", + "ctrl": "port-unreachable,redirect" + } + ] + } + } + ] + } + } + }, + "intf-items": { + "lb-items": { + "LbRtdIf-list": [ + { + "adminSt": "up", + "id": "lo10", + "rtvrfMbr-items": { + "tDn": "/System/inst-items/Inst-list[name='default']" + } + } + ] + } + }, + "ipv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "lo10", + "addr-items": { + "Addr-list": [ + { + "addr": "10.255.255.10/32", + "pref": 0, + "tag": 0, + "type": "primary" + } + ] + } + } + ] + } + } + ] + } + } + }, + "ospf-items": { + "inst-items": { + "Inst-list": [ + { + "name": "UNDERLAY", + "adminSt": "enabled", + "dom-items": { + "Dom-list": [ + { + "name": "default", + "adminSt": "enabled", + "adjChangeLogLevel": "none", + "bwRef": 40000, + "bwRefUnit": "mbps", + "ctrl": "default-passive", + "dist": 110, + "rtrId": "10.255.255.10", + "if-items": { + "If-list": [ + { + "id": "lo10", + "adminSt": "enabled", + "advertiseSecondaries": true, + "area": "0.0.0.0", + "bfdCtrl": "unspecified", + "nwT": "unspecified", + "passiveCtrl": "disabled" + } + ] + } + } + ] + } + } + ] + } + } + } +} diff --git a/test/integration/testdata/nxos/pim.txt b/test/integration/testdata/nxos/pim.txt new file mode 100644 index 000000000..de227701a --- /dev/null +++ b/test/integration/testdata/nxos/pim.txt @@ -0,0 +1,130 @@ +# NX-OS PIM Integration Test +# +# PIM requires at least one interface. We create a loopback first. + +-- interface/lo-pim -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: lo-pim + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: lo20 + type: Loopback + adminState: Up + ipv4: + addresses: + - 10.255.255.20/32 + +-- pim/multicast -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: PIM +metadata: + name: multicast + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + adminState: Up + interfaceRefs: + - name: lo-pim + sparse: true + +-- state -- +{ + "System": { + "fm-items": { + "pim-items": { + "adminSt": "enabled" + } + }, + "icmpv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "lo20", + "ctrl": "port-unreachable,redirect" + } + ] + } + } + ] + } + } + }, + "intf-items": { + "lb-items": { + "LbRtdIf-list": [ + { + "adminSt": "up", + "id": "lo20", + "rtvrfMbr-items": { + "tDn": "/System/inst-items/Inst-list[name='default']" + } + } + ] + } + }, + "ipv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "lo20", + "addr-items": { + "Addr-list": [ + { + "addr": "10.255.255.20/32", + "pref": 0, + "tag": 0, + "type": "primary" + } + ] + } + } + ] + } + } + ] + } + } + }, + "pim-items": { + "adminSt": "enabled", + "inst-items": { + "adminSt": "enabled", + "dom-items": { + "Dom-list": [ + { + "name": "default", + "adminSt": "enabled", + "if-items": { + "If-list": [ + { + "id": "lo20", + "pimSparseMode": true + } + ] + } + } + ] + } + } + } + } +} From 1420badeb5b4e2db1fd10c5f4e77f184635c295a Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:15:51 +0200 Subject: [PATCH 14/24] test: add NVE integration test with loopback dependency Signed-off-by: Pujol --- test/integration/main_test.go | 1 + test/integration/suite_test.go | 10 ++ test/integration/testdata/nxos/nve.txt | 132 +++++++++++++++++++++++++ 3 files changed, 143 insertions(+) create mode 100644 test/integration/testdata/nxos/nve.txt diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 569ff15b6..f1845ee77 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -40,6 +40,7 @@ var resourceRegistry = []schema.GroupVersionKind{ v1alpha1.GroupVersion.WithKind("Banner"), v1alpha1.GroupVersion.WithKind("OSPF"), v1alpha1.GroupVersion.WithKind("PIM"), + v1alpha1.GroupVersion.WithKind("NetworkVirtualizationEdge"), nxv1alpha1.GroupVersion.WithKind("InterfaceConfig"), nxv1alpha1.GroupVersion.WithKind("LLDPConfig"), v1alpha1.GroupVersion.WithKind("RoutingPolicy"), diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go index 1829ace63..aa3d3c015 100644 --- a/test/integration/suite_test.go +++ b/test/integration/suite_test.go @@ -288,6 +288,16 @@ func SetupProviderTest(providerCfg ProviderConfig) *ProviderTestContext { }).SetupWithManager(providerCtx, mgr) Expect(err).NotTo(HaveOccurred()) + err = (&core.NetworkVirtualizationEdgeReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = mgr.Start(providerCtx) diff --git a/test/integration/testdata/nxos/nve.txt b/test/integration/testdata/nxos/nve.txt new file mode 100644 index 000000000..04ffc9c63 --- /dev/null +++ b/test/integration/testdata/nxos/nve.txt @@ -0,0 +1,132 @@ +# NX-OS NVE (Network Virtualization Edge) Integration Test +# +# NVE requires a loopback interface as source. We create the loopback first. + +-- interface/lo-nve -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: lo-nve + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: lo1 + type: Loopback + adminState: Up + ipv4: + addresses: + - 10.0.0.1/32 + +-- nve/nve1 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: NetworkVirtualizationEdge +metadata: + name: nve1 + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + adminState: Up + sourceInterfaceRef: + name: lo-nve + hostReachability: BGP + +-- state -- +{ + "System": { + "eps-items": { + "epId-items": { + "Ep-list": [ + { + "epId": 1, + "adminSt": "enabled", + "advertiseVmac": false, + "holdDownTime": 180, + "hostReach": "bgp", + "sourceInterface": "lo1", + "suppressARP": false + } + ] + } + }, + "fm-items": { + "evpn-items": { + "adminSt": "enabled" + }, + "nvo-items": { + "adminSt": "enabled" + } + }, + "hmm-items": { + "fwdinst-items": { + "adminSt": "disabled", + "amac": "" + } + }, + "icmpv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "lo1", + "ctrl": "port-unreachable,redirect" + } + ] + } + } + ] + } + } + }, + "intf-items": { + "lb-items": { + "LbRtdIf-list": [ + { + "adminSt": "up", + "id": "lo1", + "rtvrfMbr-items": { + "tDn": "/System/inst-items/Inst-list[name='default']" + } + } + ] + } + }, + "ipv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "id": "lo1", + "addr-items": { + "Addr-list": [ + { + "addr": "10.0.0.1/32", + "pref": 0, + "tag": 0, + "type": "primary" + } + ] + } + } + ] + } + } + ] + } + } + } + } +} From 8b58c2feab7c770e5acea3fc9b8ac36fa8c7c9d6 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:16:08 +0200 Subject: [PATCH 15/24] test: add EVPNInstance integration test (Routed/L3VNI type) Signed-off-by: Pujol --- test/integration/main_test.go | 1 + test/integration/suite_test.go | 9 ++++ .../testdata/nxos/evpninstance.txt | 52 +++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 test/integration/testdata/nxos/evpninstance.txt diff --git a/test/integration/main_test.go b/test/integration/main_test.go index f1845ee77..83d54b532 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -41,6 +41,7 @@ var resourceRegistry = []schema.GroupVersionKind{ v1alpha1.GroupVersion.WithKind("OSPF"), v1alpha1.GroupVersion.WithKind("PIM"), v1alpha1.GroupVersion.WithKind("NetworkVirtualizationEdge"), + v1alpha1.GroupVersion.WithKind("EVPNInstance"), nxv1alpha1.GroupVersion.WithKind("InterfaceConfig"), nxv1alpha1.GroupVersion.WithKind("LLDPConfig"), v1alpha1.GroupVersion.WithKind("RoutingPolicy"), diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go index aa3d3c015..0a7ff66fc 100644 --- a/test/integration/suite_test.go +++ b/test/integration/suite_test.go @@ -298,6 +298,15 @@ func SetupProviderTest(providerCfg ProviderConfig) *ProviderTestContext { }).SetupWithManager(providerCtx, mgr) Expect(err).NotTo(HaveOccurred()) + err = (&core.EVPNInstanceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = mgr.Start(providerCtx) diff --git a/test/integration/testdata/nxos/evpninstance.txt b/test/integration/testdata/nxos/evpninstance.txt new file mode 100644 index 000000000..8313658d2 --- /dev/null +++ b/test/integration/testdata/nxos/evpninstance.txt @@ -0,0 +1,52 @@ +# NX-OS EVPNInstance Integration Test +# +# Tests EVPN Instance (L3VNI) for IP-VRF in VXLAN fabric. +# Using Routed type which doesn't require a VLAN dependency. + +-- evpninstance/l3vni -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: EVPNInstance +metadata: + name: l3vni + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + vni: 50000 + type: Routed + routeDistinguisher: "auto" + +-- state -- +{ + "System": { + "eps-items": { + "epId-items": { + "Ep-list": [ + { + "epId": "1", + "nws-items": { + "vni-items": { + "Nw-list": [ + { + "vni": 50000, + "associateVrfFlag": true + } + ] + } + } + } + ] + } + }, + "fm-items": { + "nvo-items": { + "adminSt": "enabled" + }, + "vnsegment-items": { + "adminSt": "enabled" + } + } + } +} From 6530452653401d7051b218cf1840222f1721d5a7 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:16:40 +0200 Subject: [PATCH 16/24] test: add VPCDomain integration test (NX-OS specific) Signed-off-by: Pujol --- test/integration/main_test.go | 1 + test/integration/suite_test.go | 12 ++ test/integration/testdata/nxos/vpcdomain.txt | 179 +++++++++++++++++++ 3 files changed, 192 insertions(+) create mode 100644 test/integration/testdata/nxos/vpcdomain.txt diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 83d54b532..456a738b4 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -44,6 +44,7 @@ var resourceRegistry = []schema.GroupVersionKind{ v1alpha1.GroupVersion.WithKind("EVPNInstance"), nxv1alpha1.GroupVersion.WithKind("InterfaceConfig"), nxv1alpha1.GroupVersion.WithKind("LLDPConfig"), + nxv1alpha1.GroupVersion.WithKind("VPCDomain"), v1alpha1.GroupVersion.WithKind("RoutingPolicy"), v1alpha1.GroupVersion.WithKind("PrefixSet"), // Add new resource types here as needed diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go index 0a7ff66fc..223103d33 100644 --- a/test/integration/suite_test.go +++ b/test/integration/suite_test.go @@ -31,6 +31,7 @@ import ( nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + nx "github.com/ironcore-dev/network-operator/internal/controller/cisco/nx" "github.com/ironcore-dev/network-operator/internal/controller/core" "github.com/ironcore-dev/network-operator/internal/provider" "github.com/ironcore-dev/network-operator/internal/provider/cisco/iosxr" @@ -307,6 +308,17 @@ func SetupProviderTest(providerCfg ProviderConfig) *ProviderTestContext { }).SetupWithManager(providerCtx, mgr) Expect(err).NotTo(HaveOccurred()) + // NX-OS specific controllers + err = (&nx.VPCDomainReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = mgr.Start(providerCtx) diff --git a/test/integration/testdata/nxos/vpcdomain.txt b/test/integration/testdata/nxos/vpcdomain.txt new file mode 100644 index 000000000..18614ae3f --- /dev/null +++ b/test/integration/testdata/nxos/vpcdomain.txt @@ -0,0 +1,179 @@ +# NX-OS VPCDomain Integration Test +# +# Tests vPC (Virtual Port Channel) domain configuration. +# VPCDomain is NX-OS specific (nx.cisco.networking.metal.ironcore.dev/v1alpha1). +# Requires a port-channel interface for the peer-link with member interfaces. +# +# Using this payload on a clean device will fail as features must be already enabled. + + +-- interface/eth1 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: eth1 + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: eth1/1 + type: Physical + adminState: Up + +-- interface/vpc-peerlink -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: vpc-peerlink + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: po1 + type: Aggregate + adminState: Up + aggregation: + memberInterfaceRefs: + - name: eth1 + controlProtocol: + mode: Active + +-- vpcdomain/vpc1 -- +apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 +kind: VPCDomain +metadata: + name: vpc1 + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + domainId: 100 + adminState: Up + rolePriority: 32667 + systemPriority: 32667 + peer: + adminState: Up + interfaceRef: + name: vpc-peerlink + keepalive: + destination: 10.0.0.2 + source: 10.0.0.1 + vrfName: management + +-- state -- +{ + "System": { + "fm-items": { + "lacp-items": { + "adminSt": "enabled" + }, + "vpc-items": { + "adminSt": "enabled" + } + }, + "intf-items": { + "aggr-items": { + "AggrIf-list": [ + { + "id": "po1", + "adminSt": "up", + "accessVlan": "vlan-1", + "aggrExtd-items": { + "bufferBoost": "enable" + }, + "lacpVpcConvergence": "disable", + "layer": "Layer2", + "medium": "broadcast", + "mode": "access", + "mtu": 1500, + "nativeVlan": "vlan-1", + "pcMode": "active", + "rsmbrIfs-items": { + "RsMbrIfs-list": [ + { + "tDn": "/System/intf-items/phys-items/PhysIf-list[id='eth1/1']" + } + ] + }, + "suspIndividual": "enable", + "trunkVlans": "1-4094", + "userCfgdFlags": "admin_layer,admin_state" + } + ] + }, + "phys-items": { + "PhysIf-list": [ + { + "id": "eth1/1", + "FECMode": "auto", + "accessVlan": "vlan-1", + "adminSt": "up", + "layer": "Layer2", + "medium": "broadcast", + "mode": "access", + "mtu": 1500, + "nativeVlan": "vlan-1", + "physExtd-items": { + "bufferBoost": "enable" + }, + "trunkVlans": "1-4094", + "userCfgdFlags": "admin_layer,admin_state" + } + ] + } + }, + "stp-items": { + "inst-items": { + "if-items": { + "If-list": [ + { + "id": "eth1/1", + "bpdufilter": "default", + "bpduguard": "default", + "mode": "default" + }, + { + "id": "po1", + "bpdufilter": "default", + "bpduguard": "default", + "mode": "default" + } + ] + } + } + }, + "vpc-items": { + "inst-items": { + "dom-items": { + "id": 100, + "adminSt": "enabled", + "autoRecovery": "disabled", + "autoRecoveryIntvl": 240, + "delayRestoreSVI": 10, + "delayRestoreVPC": 30, + "fastConvergence": "disabled", + "keepalive-items": { + "destIp": "10.0.0.2", + "srcIp": "10.0.0.1", + "vrf": "management", + "peerlink-items": { + "adminSt": "enabled", + "id": "po1" + } + }, + "l3PeerRouter": "disabled", + "peerGw": "disabled", + "peerSwitch": "disabled", + "rolePrio": 32667, + "sysPrio": 32667 + } + } + } + } +} From 6fd3a5002319bb08140246f81cbab52ff60f983c Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:17:00 +0200 Subject: [PATCH 17/24] test: add BGP and BGPPeer integration test Tests BGP router + peer configuration with loopback interface and BGPConfig. Resources packed together since BGPPeer requires BGP to exist first. Signed-off-by: Pujol --- test/integration/main_test.go | 3 + test/integration/suite_test.go | 20 ++ .../integration/testdata/nxos/bgp_bgppeer.txt | 244 ++++++++++++++++++ 3 files changed, 267 insertions(+) create mode 100644 test/integration/testdata/nxos/bgp_bgppeer.txt diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 456a738b4..4cb3aacd3 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -45,8 +45,11 @@ var resourceRegistry = []schema.GroupVersionKind{ nxv1alpha1.GroupVersion.WithKind("InterfaceConfig"), nxv1alpha1.GroupVersion.WithKind("LLDPConfig"), nxv1alpha1.GroupVersion.WithKind("VPCDomain"), + nxv1alpha1.GroupVersion.WithKind("BGPConfig"), v1alpha1.GroupVersion.WithKind("RoutingPolicy"), v1alpha1.GroupVersion.WithKind("PrefixSet"), + v1alpha1.GroupVersion.WithKind("BGP"), + v1alpha1.GroupVersion.WithKind("BGPPeer"), // Add new resource types here as needed } diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go index 223103d33..c8bbb6377 100644 --- a/test/integration/suite_test.go +++ b/test/integration/suite_test.go @@ -319,6 +319,26 @@ func SetupProviderTest(providerCfg ProviderConfig) *ProviderTestContext { }).SetupWithManager(providerCtx, mgr) Expect(err).NotTo(HaveOccurred()) + err = (&core.BGPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.BGPPeerReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = mgr.Start(providerCtx) diff --git a/test/integration/testdata/nxos/bgp_bgppeer.txt b/test/integration/testdata/nxos/bgp_bgppeer.txt new file mode 100644 index 000000000..5872b502b --- /dev/null +++ b/test/integration/testdata/nxos/bgp_bgppeer.txt @@ -0,0 +1,244 @@ +# NX-OS BGP and BGPPeer Integration Test +# +# Tests BGP instance with L2VPN EVPN address family and a BGP peer (neighbor). +# Uses loopback interface as update-source (common for EVPN peering). +# Includes NX-OS specific BGPConfig for advertise-pip setting. +# +# Resource dependency chain: +# loopback-vtep -> Loopback interface (spec.name: lo0) for BGP router-id/update-source +# evpn-settings -> BGPConfig (NX-OS provider config) for advertise-pip +# evpn-bgp -> BGP instance with L2VPN EVPN, references BGPConfig +# spine1 -> BGPPeer referencing BGP instance and loopback for localAddress +# +# Expected gNMI state mapping: +# +# fm-items/bgp-items: BGP exists -> Feature "bgp" enabled +# fm-items/evpn-items: L2VPN EVPN enabled -> Feature "evpn" enabled +# +# bgp-items/inst-items: BGP instance configuration +# - asn: 65000 spec.asNumber -> AS number +# - adminSt: enabled BGP active +# dom-items/Dom-list[name='default']: BGP VRF domain +# - rtrId: 10.0.0.1 spec.routerId -> router ID +# - af-items/DomAf-list: spec.addressFamilies +# - type: l2vpn-evpn L2VPN EVPN address family +# - advPip: enabled BGPConfig.addressFamilies.l2vpnEvpn.advertisePIP +# - retainRttAll: enabled routeTargetPolicy.retainAll=true +# - peer-items/Peer-list: BGPPeer configuration +# - addr: 10.0.0.2 spec.address -> peer address +# - asn: 65000 spec.asNumber -> peer AS +# - srcIf: lo0 localAddress.interfaceRef -> update-source +# - af-items/PeerAf-list: peer address families +# - sendComExt/sendComStd sendCommunity=Both -> extended+standard +# - ctrl: rr-client routeReflectorClient=true +# +# intf-items/lb-items: Loopback interface +# ipv4-items: Loopback IPv4 address +# icmpv4-items: ICMP config for loopback + +-- interface/loopback-vtep -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: loopback-vtep + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: lo0 + adminState: Up + type: Loopback + ipv4: + addresses: + - 10.0.0.1/32 + +-- bgpconfig/evpn-settings -- +apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 +kind: BGPConfig +metadata: + name: evpn-settings + namespace: default +spec: + addressFamilies: + l2vpnEvpn: + advertisePIP: true + +-- bgp/evpn-bgp -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: BGP +metadata: + name: evpn-bgp + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + asNumber: 65000 + routerId: "10.0.0.1" + providerConfigRef: + apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 + kind: BGPConfig + name: evpn-settings + addressFamilies: + l2vpnEvpn: + enabled: true + routeTargetPolicy: + retainAll: true + +-- bgppeer/spine1 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: BGPPeer +metadata: + name: spine1 + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + bgpRef: + name: evpn-bgp + address: "10.0.0.2" + asNumber: 65000 + description: "EVPN peering with spine1" + localAddress: + interfaceRef: + name: loopback-vtep + addressFamilies: + l2vpnEvpn: + enabled: true + sendCommunity: Both + routeReflectorClient: true + +-- state -- +{ + "System": { + "bgp-items": { + "inst-items": { + "adminSt": "enabled", + "asn": "65000", + "dom-items": { + "Dom-list": [ + { + "name": "default", + "rtrId": "10.0.0.1", + "rtrIdAuto": "disabled", + "af-items": { + "DomAf-list": [ + { + "advPip": "enabled", + "exportGwIp": "disabled", + "maxExtEcmp": 1, + "maxExtIntEcmp": 1, + "retainRttAll": "enabled", + "type": "l2vpn-evpn" + } + ] + }, + "peer-items": { + "Peer-list": [ + { + "addr": "10.0.0.2", + "adminSt": "enabled", + "asn": "65000", + "asnType": "none", + "name": "EVPN peering with spine1", + "srcIf": "lo0", + "af-items": { + "PeerAf-list": [ + { + "ctrl": "rr-client", + "sendComExt": "enabled", + "sendComStd": "enabled", + "type": "l2vpn-evpn" + } + ] + } + } + ] + }, + "peercont-items": { + "PeerCont-list": [ + { + "name": "__operator-managed--default__" + } + ] + } + } + ] + } + } + }, + "fm-items": { + "bgp-items": { + "adminSt": "enabled" + }, + "evpn-items": { + "adminSt": "enabled" + } + }, + "icmpv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "ctrl": "port-unreachable,redirect", + "id": "lo0" + } + ] + } + } + ] + } + } + }, + "intf-items": { + "lb-items": { + "LbRtdIf-list": [ + { + "adminSt": "up", + "id": "lo0", + "rtvrfMbr-items": { + "tDn": "/System/inst-items/Inst-list[name='default']" + } + } + ] + } + }, + "ipv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "default", + "if-items": { + "If-list": [ + { + "addr-items": { + "Addr-list": [ + { + "addr": "10.0.0.1/32", + "pref": 0, + "tag": 0, + "type": "primary" + } + ] + }, + "id": "lo0" + } + ] + } + } + ] + } + } + } + } +} From d269c80070d14ab1d0bf99ca8ac85592505cdf70 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:17:17 +0200 Subject: [PATCH 18/24] test: add Syslog integration test Signed-off-by: Pujol --- test/integration/main_test.go | 1 + test/integration/suite_test.go | 9 +++ test/integration/testdata/nxos/syslog.txt | 91 +++++++++++++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 test/integration/testdata/nxos/syslog.txt diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 4cb3aacd3..c8c31d3d1 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -50,6 +50,7 @@ var resourceRegistry = []schema.GroupVersionKind{ v1alpha1.GroupVersion.WithKind("PrefixSet"), v1alpha1.GroupVersion.WithKind("BGP"), v1alpha1.GroupVersion.WithKind("BGPPeer"), + v1alpha1.GroupVersion.WithKind("Syslog"), // Add new resource types here as needed } diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go index c8bbb6377..ced252ac8 100644 --- a/test/integration/suite_test.go +++ b/test/integration/suite_test.go @@ -339,6 +339,15 @@ func SetupProviderTest(providerCfg ProviderConfig) *ProviderTestContext { }).SetupWithManager(providerCtx, mgr) Expect(err).NotTo(HaveOccurred()) + err = (&core.SyslogReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = mgr.Start(providerCtx) diff --git a/test/integration/testdata/nxos/syslog.txt b/test/integration/testdata/nxos/syslog.txt new file mode 100644 index 000000000..4786db795 --- /dev/null +++ b/test/integration/testdata/nxos/syslog.txt @@ -0,0 +1,91 @@ +# NX-OS Syslog Integration Test +# +# Tests syslog configuration with remote log servers and facilities. +# +# Expected gNMI state mapping: +# +# logging-items/loglevel-items: Log level/facility configuration +# facility-items/Facility-list: spec.facilities -> facility severity levels +# - facilityName: Local7 facility.name -> facility name +# - severityLevel: information facility.severity=Info -> information +# +# syslog-items: Syslog service configuration +# logginghistory-items: Logging history buffer +# - level: information follows facility severity +# - size: 500 default buffer size +# originid-items: Syslog origin ID +# - idtype: string origin type +# - idvalue: logging K8s metadata.name used as origin ID +# rdst-items/RemoteDest-list: spec.servers -> remote syslog servers +# - host: 10.0.0.100 server.address -> destination host +# - severity: warnings server.severity=Warning -> warnings +# - vrfName: management server.vrfName -> VRF for reaching server +# - port: 514 default syslog port +# - transport: udp default transport +# - forwardingFacility: local7 default forwarding facility +# source-items: Syslog source interface +# - adminState: enabled syslog enabled +# - ifName: mgmt0 default source interface + +-- syslog/logging -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Syslog +metadata: + name: logging + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + servers: + - address: "10.0.0.100" + severity: Warning + vrfName: management + facilities: + - name: Local7 + severity: Info + +-- state -- +{ + "System": { + "logging-items": { + "loglevel-items": { + "facility-items": { + "Facility-list": [ + { + "facilityName": "Local7", + "severityLevel": "information" + } + ] + } + } + }, + "syslog-items": { + "logginghistory-items": { + "level": "information", + "size": 500 + }, + "originid-items": { + "idtype": "string", + "idvalue": "logging" + }, + "rdst-items": { + "RemoteDest-list": [ + { + "forwardingFacility": "local7", + "host": "10.0.0.100", + "port": 514, + "severity": "warnings", + "transport": "udp", + "vrfName": "management" + } + ] + }, + "source-items": { + "adminState": "enabled", + "ifName": "mgmt0" + } + } + } +} From 72c6d347977728bceb1a2a6c41a057ec8dd85b04 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:17:44 +0200 Subject: [PATCH 19/24] test: add SNMP integration test Signed-off-by: Pujol --- test/integration/main_test.go | 1 + test/integration/suite_test.go | 9 +++ test/integration/testdata/nxos/snmp.txt | 86 +++++++++++++++++++++++++ 3 files changed, 96 insertions(+) create mode 100644 test/integration/testdata/nxos/snmp.txt diff --git a/test/integration/main_test.go b/test/integration/main_test.go index c8c31d3d1..cbe7838ce 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -51,6 +51,7 @@ var resourceRegistry = []schema.GroupVersionKind{ v1alpha1.GroupVersion.WithKind("BGP"), v1alpha1.GroupVersion.WithKind("BGPPeer"), v1alpha1.GroupVersion.WithKind("Syslog"), + v1alpha1.GroupVersion.WithKind("SNMP"), // Add new resource types here as needed } diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go index ced252ac8..5029bba90 100644 --- a/test/integration/suite_test.go +++ b/test/integration/suite_test.go @@ -348,6 +348,15 @@ func SetupProviderTest(providerCfg ProviderConfig) *ProviderTestContext { }).SetupWithManager(providerCtx, mgr) Expect(err).NotTo(HaveOccurred()) + err = (&core.SNMPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = mgr.Start(providerCtx) diff --git a/test/integration/testdata/nxos/snmp.txt b/test/integration/testdata/nxos/snmp.txt new file mode 100644 index 000000000..146d9ce80 --- /dev/null +++ b/test/integration/testdata/nxos/snmp.txt @@ -0,0 +1,86 @@ +# NX-OS SNMP Integration Test +# +# Tests SNMP configuration with contact, location, source interface, hosts, and communities. +# +# Expected gNMI state mapping: +# +# snmp-items/inst-items: SNMP instance configuration +# sysinfo-items: System information +# - sysContact spec.contact -> contact string +# - sysLocation spec.location -> location string +# globals-items: Global settings +# - srcInterfaceTraps-items spec.sourceInterfaceName -> trap source interface +# - srcInterfaceInforms-items spec.sourceInterfaceName -> inform source interface +# host-items/Host-list: spec.hosts -> SNMP notification hosts +# - hostName: 10.0.0.100 host.address -> destination address +# - notifType: traps host.type (default=Traps) +# - version: v2c default SNMP version +# - udpPortID: 162 default SNMP trap port +# community-items/CommSecP-list: spec.communities -> SNMP communities +# - name: public community.name -> community string +# - grpName: network-operator community.group -> access group +# - commAcess: unspecified default access level (typo in NX-OS model) +# traps-items: Trap configuration (empty = defaults) + +-- snmp/config -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: SNMP +metadata: + name: config + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + contact: "admin@example.com" + location: "DC1-Rack42" + sourceInterfaceName: "mgmt0" + hosts: + - address: "10.0.0.100" + communities: + - name: "public" + group: "network-operator" + +-- state -- +{ + "System": { + "snmp-items": { + "inst-items": { + "community-items": { + "CommSecP-list": [ + { + "commAcess": "unspecified", + "grpName": "network-operator", + "name": "public" + } + ] + }, + "globals-items": { + "srcInterfaceInforms-items": { + "ifname": "mgmt0" + }, + "srcInterfaceTraps-items": { + "ifname": "mgmt0" + } + }, + "host-items": { + "Host-list": [ + { + "hostName": "10.0.0.100", + "notifType": "traps", + "secLevel": "noauth", + "udpPortID": 162, + "version": "v2c" + } + ] + }, + "sysinfo-items": { + "sysContact": "admin@example.com", + "sysLocation": "DC1-Rack42" + }, + "traps-items": {} + } + } + } +} From 3317b3832f1ab6e0bbe08427d01fd69fbc40ed4a Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:18:19 +0200 Subject: [PATCH 20/24] test: add ManagementAccess integration test Signed-off-by: Pujol --- test/integration/main_test.go | 1 + test/integration/suite_test.go | 9 ++ .../testdata/nxos/managementaccess.txt | 83 +++++++++++++++++++ 3 files changed, 93 insertions(+) create mode 100644 test/integration/testdata/nxos/managementaccess.txt diff --git a/test/integration/main_test.go b/test/integration/main_test.go index cbe7838ce..470974643 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -52,6 +52,7 @@ var resourceRegistry = []schema.GroupVersionKind{ v1alpha1.GroupVersion.WithKind("BGPPeer"), v1alpha1.GroupVersion.WithKind("Syslog"), v1alpha1.GroupVersion.WithKind("SNMP"), + v1alpha1.GroupVersion.WithKind("ManagementAccess"), // Add new resource types here as needed } diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go index 5029bba90..992cbfffd 100644 --- a/test/integration/suite_test.go +++ b/test/integration/suite_test.go @@ -357,6 +357,15 @@ func SetupProviderTest(providerCfg ProviderConfig) *ProviderTestContext { }).SetupWithManager(providerCtx, mgr) Expect(err).NotTo(HaveOccurred()) + err = (&core.ManagementAccessReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = mgr.Start(providerCtx) diff --git a/test/integration/testdata/nxos/managementaccess.txt b/test/integration/testdata/nxos/managementaccess.txt new file mode 100644 index 000000000..5a646574d --- /dev/null +++ b/test/integration/testdata/nxos/managementaccess.txt @@ -0,0 +1,83 @@ +# NX-OS ManagementAccess Integration Test +# +# Tests management access configuration for gRPC and SSH. +# +# Expected gNMI state mapping: +# +# fm-items/grpc-items: gRPC enabled -> Feature "grpc" enabled +# fm-items/ssh-items: SSH enabled -> Feature "ssh" enabled +# +# grpc-items: gRPC service configuration +# - port: 9339 spec.grpc.port -> gRPC port +# - useVrf: default spec.grpc.vrfName -> VRF for gRPC +# gnmi-items: gNMI-specific settings +# - maxCalls: 8 spec.grpc.gnmi.maxConcurrentCall +# - keepAliveTimeout: 600 spec.grpc.gnmi.keepAliveTimeout (seconds) +# +# terml-items/ln-items: Terminal line settings (SSH) +# cons-items: Console settings (defaults) +# vty-items: VTY (SSH) settings +# - execTmeout-items.timeout: 10 spec.ssh.timeout (minutes) +# - ssLmt-items.sesLmt: 32 spec.ssh.sessionLimit + +-- managementaccess/config -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: ManagementAccess +metadata: + name: config + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + grpc: + enabled: true + port: 9339 + vrfName: default + gnmi: + maxConcurrentCall: 8 + keepAliveTimeout: 600s + ssh: + enabled: true + timeout: 10m + sessionLimit: 32 + +-- state -- +{ + "System": { + "fm-items": { + "grpc-items": { + "adminSt": "enabled" + }, + "ssh-items": { + "adminSt": "enabled" + } + }, + "grpc-items": { + "gnmi-items": { + "keepAliveTimeout": 600, + "maxCalls": 8 + }, + "port": 9339, + "useVrf": "default" + }, + "terml-items": { + "ln-items": { + "cons-items": { + "execTmeout-items": { + "timeout": 0 + } + }, + "vty-items": { + "execTmeout-items": { + "timeout": 10 + }, + "ssLmt-items": { + "sesLmt": 32 + } + } + } + } + } +} From 7b5430dc41f6c933640e2f69b9bb9b6bad9ac8c7 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:18:19 +0200 Subject: [PATCH 21/24] test: add AccessControlList integration test Signed-off-by: Pujol --- test/integration/main_test.go | 1 + test/integration/suite_test.go | 9 +++ test/integration/testdata/nxos/acl.txt | 81 ++++++++++++++++++++++++++ 3 files changed, 91 insertions(+) create mode 100644 test/integration/testdata/nxos/acl.txt diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 470974643..8b6a42f85 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -53,6 +53,7 @@ var resourceRegistry = []schema.GroupVersionKind{ v1alpha1.GroupVersion.WithKind("Syslog"), v1alpha1.GroupVersion.WithKind("SNMP"), v1alpha1.GroupVersion.WithKind("ManagementAccess"), + v1alpha1.GroupVersion.WithKind("AccessControlList"), // Add new resource types here as needed } diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go index 992cbfffd..b967b37f5 100644 --- a/test/integration/suite_test.go +++ b/test/integration/suite_test.go @@ -366,6 +366,15 @@ func SetupProviderTest(providerCfg ProviderConfig) *ProviderTestContext { }).SetupWithManager(providerCtx, mgr) Expect(err).NotTo(HaveOccurred()) + err = (&core.AccessControlListReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = mgr.Start(providerCtx) diff --git a/test/integration/testdata/nxos/acl.txt b/test/integration/testdata/nxos/acl.txt new file mode 100644 index 000000000..34b7befb4 --- /dev/null +++ b/test/integration/testdata/nxos/acl.txt @@ -0,0 +1,81 @@ +# NX-OS Access Control List Integration Test +# +# Tests ACL configuration with permit/deny entries. +# +# K8s metadata.name (test-acl) differs from spec.name (BLOCK-EXTERNAL) to verify +# the provider uses spec.name for gNMI paths. +# +# Expected gNMI state mapping: +# +# acl-items/ipv4-items/name-items: AccessControlList with IPv4 entries +# ACL-list[name='BLOCK-EXTERNAL']: spec.name -> ACL name +# seq-items/ACE-list: spec.entries -> ACE entries +# - seqNum: 10/20 entry.sequence -> sequence number +# - action: permit/deny entry.action -> forwarding action +# - protocol: 0 entry.protocol=IP -> protocol number (0=IP) +# - srcPrefix/srcPrefixLength: entry.sourceAddress -> source IP/mask +# - dstPrefix: entry.destinationAddress -> destination IP +# +# Note: description field is not mapped to gNMI remarks in current provider. + +-- accesscontrollist/test-acl -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: AccessControlList +metadata: + name: test-acl + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: BLOCK-EXTERNAL + entries: + - sequence: 10 + action: Permit + protocol: IP + sourceAddress: 10.0.0.0/8 + destinationAddress: 0.0.0.0/0 + description: "Allow internal" + - sequence: 20 + action: Deny + protocol: IP + sourceAddress: 0.0.0.0/0 + destinationAddress: 0.0.0.0/0 + description: "Deny all" + +-- state -- +{ + "System": { + "acl-items": { + "ipv4-items": { + "name-items": { + "ACL-list": [ + { + "name": "BLOCK-EXTERNAL", + "seq-items": { + "ACE-list": [ + { + "action": "deny", + "dstPrefix": "0.0.0.0", + "protocol": 0, + "seqNum": 20, + "srcPrefix": "0.0.0.0" + }, + { + "action": "permit", + "dstPrefix": "0.0.0.0", + "protocol": 0, + "seqNum": 10, + "srcPrefix": "10.0.0.0", + "srcPrefixLength": 8 + } + ] + } + } + ] + } + } + } + } +} From 2a859bfbd99b01b2c98ba77cc5ea35eb86bdf449 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:18:52 +0200 Subject: [PATCH 22/24] test: add DHCPRelay integration test Signed-off-by: Pujol --- test/integration/main_test.go | 1 + test/integration/suite_test.go | 10 + test/integration/testdata/nxos/dhcprelay.txt | 192 +++++++++++++++++++ 3 files changed, 203 insertions(+) create mode 100644 test/integration/testdata/nxos/dhcprelay.txt diff --git a/test/integration/main_test.go b/test/integration/main_test.go index 8b6a42f85..bc4b9151c 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -54,6 +54,7 @@ var resourceRegistry = []schema.GroupVersionKind{ v1alpha1.GroupVersion.WithKind("SNMP"), v1alpha1.GroupVersion.WithKind("ManagementAccess"), v1alpha1.GroupVersion.WithKind("AccessControlList"), + v1alpha1.GroupVersion.WithKind("DHCPRelay"), // Add new resource types here as needed } diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go index b967b37f5..34ebee05d 100644 --- a/test/integration/suite_test.go +++ b/test/integration/suite_test.go @@ -375,6 +375,16 @@ func SetupProviderTest(providerCfg ProviderConfig) *ProviderTestContext { }).SetupWithManager(providerCtx, mgr) Expect(err).NotTo(HaveOccurred()) + err = (&core.DHCPRelayReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = mgr.Start(providerCtx) diff --git a/test/integration/testdata/nxos/dhcprelay.txt b/test/integration/testdata/nxos/dhcprelay.txt new file mode 100644 index 000000000..60ab62b72 --- /dev/null +++ b/test/integration/testdata/nxos/dhcprelay.txt @@ -0,0 +1,192 @@ +# NX-OS DHCP Relay Integration Test +# +# Tests DHCP relay configuration with server addresses and interface references. +# DHCPRelay requires at least one interface to relay DHCP messages. +# +# Resource dependency chain: +# vlan100 -> VLAN (id: 100, name: DHCP-VLAN) +# svi100 -> RoutedVLAN Interface referencing vlan100 (spec.name: Vlan100) +# relay-config -> DHCPRelay with server and interface reference +# +# Expected gNMI state mapping: +# +# bd-items/bd-items/BD-list: VLAN resource +# - fabEncap: vlan-100 spec.id -> VLAN encapsulation +# - name: DHCP-VLAN spec.name -> VLAN name +# - adminSt/BdState: active spec.adminState=Up -> active state +# +# fm-items/dhcp-items: DHCPRelay exists -> Feature "dhcp" enabled +# fm-items/ifvlan-items: RoutedVLAN exists -> Feature "ifvlan" enabled +# +# dhcp-items/inst-items/relayif-items: DHCPRelay configuration +# RelayIf-list[id='vlan100']: interfaceRefs -> relay interface +# RelayAddr-list[address]: servers -> DHCP server addresses +# +# intf-items/svi-items/If-list: RoutedVLAN Interface (SVI) +# - id: vlan100 spec.name -> interface ID +# - vlanId: 100 vlanRef -> VLAN ID +# - rtvrfMbr-items.tDn: default VRF membership +# +# ipv4-items: L3 interface with IPv4 address +# - addr: 192.168.100.1/24 ipv4.addresses -> IP address +# +# icmpv4-items: ICMP settings for L3 interface + +-- vlan/vlan100 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: VLAN +metadata: + name: vlan100 + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + id: 100 + name: DHCP-VLAN + +-- interface/svi100 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: svi100 + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: Vlan100 + adminState: Up + type: RoutedVLAN + vlanRef: + name: vlan100 + ipv4: + addresses: + - 192.168.100.1/24 + +-- dhcprelay/relay-config -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: DHCPRelay +metadata: + name: relay-config + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + servers: + - "10.0.0.10" + interfaceRefs: + - name: svi100 + +-- state -- +{ + "System": { + "bd-items": { + "bd-items": { + "BD-list": [ + { + "BdState": "active", + "adminSt": "active", + "fabEncap": "vlan-100", + "name": "DHCP-VLAN" + } + ] + } + }, + "dhcp-items": { + "inst-items": { + "relayif-items": { + "RelayIf-list": [ + { + "addr-items": { + "RelayAddr-list": [ + { + "address": "10.0.0.10", + "vrf": "!unspecified" + } + ] + }, + "id": "vlan100" + } + ] + } + } + }, + "fm-items": { + "dhcp-items": { + "adminSt": "enabled" + }, + "ifvlan-items": { + "adminSt": "enabled" + } + }, + "icmpv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "if-items": { + "If-list": [ + { + "ctrl": "port-unreachable", + "id": "vlan100" + } + ] + }, + "name": "default" + } + ] + } + } + }, + "intf-items": { + "svi-items": { + "If-list": [ + { + "adminSt": "up", + "descr": "", + "id": "vlan100", + "medium": "bcast", + "mtu": 1500, + "rtvrfMbr-items": { + "tDn": "/System/inst-items/Inst-list[name='default']" + }, + "vlanId": 100 + } + ] + } + }, + "ipv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "if-items": { + "If-list": [ + { + "addr-items": { + "Addr-list": [ + { + "addr": "192.168.100.1/24", + "pref": 0, + "tag": 0, + "type": "primary" + } + ] + }, + "id": "vlan100" + } + ] + }, + "name": "default" + } + ] + } + } + } + } +} From 5d930193969d021b48178537b0721ab5a0c3b4dc Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 11 Jun 2026 01:18:52 +0200 Subject: [PATCH 23/24] test: add ISIS integration test Signed-off-by: Pujol --- test/integration/main_test.go | 1 + test/integration/suite_test.go | 9 ++ test/integration/testdata/nxos/isis.txt | 177 ++++++++++++++++++++++++ 3 files changed, 187 insertions(+) create mode 100644 test/integration/testdata/nxos/isis.txt diff --git a/test/integration/main_test.go b/test/integration/main_test.go index bc4b9151c..c057a212a 100644 --- a/test/integration/main_test.go +++ b/test/integration/main_test.go @@ -55,6 +55,7 @@ var resourceRegistry = []schema.GroupVersionKind{ v1alpha1.GroupVersion.WithKind("ManagementAccess"), v1alpha1.GroupVersion.WithKind("AccessControlList"), v1alpha1.GroupVersion.WithKind("DHCPRelay"), + v1alpha1.GroupVersion.WithKind("ISIS"), // Add new resource types here as needed } diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go index 34ebee05d..b02a028a0 100644 --- a/test/integration/suite_test.go +++ b/test/integration/suite_test.go @@ -385,6 +385,15 @@ func SetupProviderTest(providerCfg ProviderConfig) *ProviderTestContext { }).SetupWithManager(providerCtx, mgr) Expect(err).NotTo(HaveOccurred()) + err = (&core.ISISReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(providerCtx, mgr) + Expect(err).NotTo(HaveOccurred()) + go func() { defer GinkgoRecover() err = mgr.Start(providerCtx) diff --git a/test/integration/testdata/nxos/isis.txt b/test/integration/testdata/nxos/isis.txt new file mode 100644 index 000000000..57673b133 --- /dev/null +++ b/test/integration/testdata/nxos/isis.txt @@ -0,0 +1,177 @@ +# NX-OS ISIS Integration Test +# +# Tests IS-IS routing protocol configuration with interface references. +# ISIS requires at least one interface to participate in the IS-IS domain. +# +# Resource dependency chain: +# ethernet1 -> Physical Interface (spec.name: Ethernet1/1 -> eth1/1) +# fabric-isis -> ISIS instance referencing ethernet1 +# +# K8s metadata.name (fabric-isis) differs from spec.instance (FABRIC) to verify +# the provider uses spec.instance for gNMI paths. +# +# Expected gNMI state mapping: +# +# fm-items/isis-items: ISIS exists -> Feature "isis" enabled +# +# intf-items/phys-items/PhysIf-list: Physical interface +# - id: eth1/1 spec.name normalized (Ethernet1/1 -> eth1/1) +# - layer: Layer3 L3 interface (has IPv4 config) +# - adminSt: up spec.adminState=Up +# +# ipv4-items: L3 interface with IPv4 address +# - addr: 10.0.1.1/30 ipv4.addresses -> IP address +# +# isis-items/inst-items/Inst-list: ISIS instance configuration +# - name: FABRIC spec.instance -> IS-IS instance name +# - adminSt: enabled spec.adminState=Up -> enabled +# dom-items/Dom-list: IS-IS domain (default VRF) +# - net: 49.0001... spec.networkEntityTitle -> NET +# - isType: l2 spec.type=Level2 -> level type +# - af-items/DomAf-list: spec.addressFamilies +# - type: v4 IPv4Unicast -> v4 +# - if-items/If-list: spec.interfaceRefs -> participating interfaces +# - id: eth1/1 interface reference +# - v4enable: true IPv4 enabled on interface +# - networkTypeP2P: on point-to-point network type + +-- interface/ethernet1 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: ethernet1 + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: Ethernet1/1 + adminState: Up + type: Physical + ipv4: + addresses: + - 10.0.1.1/30 + +-- isis/fabric-isis -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: ISIS +metadata: + name: fabric-isis + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + adminState: Up + instance: FABRIC + networkEntityTitle: "49.0001.0000.0000.0001.00" + type: Level2 + addressFamilies: + - IPv4Unicast + interfaceRefs: + - name: ethernet1 + +-- state -- +{ + "System": { + "fm-items": { + "isis-items": { + "adminSt": "enabled" + } + }, + "intf-items": { + "phys-items": { + "PhysIf-list": [ + { + "FECMode": "auto", + "accessVlan": "unknown", + "adminSt": "up", + "id": "eth1/1", + "layer": "Layer3", + "medium": "broadcast", + "mode": "access", + "mtu": 1500, + "nativeVlan": "unknown", + "physExtd-items": { + "bufferBoost": "enable" + }, + "rtvrfMbr-items": { + "tDn": "/System/inst-items/Inst-list[name='default']" + }, + "trunkVlans": "1-4094", + "userCfgdFlags": "admin_layer,admin_state" + } + ] + } + }, + "ipv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "if-items": { + "If-list": [ + { + "addr-items": { + "Addr-list": [ + { + "addr": "10.0.1.1/30", + "pref": 0, + "tag": 0, + "type": "primary" + } + ] + }, + "id": "eth1/1" + } + ] + }, + "name": "default" + } + ] + } + } + }, + "isis-items": { + "inst-items": { + "Inst-list": [ + { + "adminSt": "enabled", + "dom-items": { + "Dom-list": [ + { + "af-items": { + "DomAf-list": [ + { + "type": "v4" + } + ] + }, + "if-items": { + "If-list": [ + { + "id": "eth1/1", + "networkTypeP2P": "on", + "v4Bfd": "inheritVrf", + "v4enable": true, + "v6Bfd": "", + "v6enable": false + } + ] + }, + "isType": "l2", + "name": "default", + "net": "49.0001.0000.0000.0001.00", + "passiveDflt": "l2" + } + ] + }, + "name": "FABRIC" + } + ] + } + } + } +} From 7390aa7d9c4988da7ed65d88098bc9400da37280 Mon Sep 17 00:00:00 2001 From: Pujol Date: Sat, 13 Jun 2026 18:47:22 +0200 Subject: [PATCH 24/24] WIP: e2e test refactoring checkpoint --- .github/renovate.json | 80 +++ .github/workflows/checks.yaml | 48 ++ .github/workflows/codeql.yaml | 46 ++ .goreleaser.yaml | 2 + .license-scan-overrides.jsonl | 1 - .typos.toml | 18 +- Makefile | 44 +- Makefile.maker.yaml | 32 +- config/default/kustomization.yaml | 2 +- config/develop/gnmi-test-server.yaml | 2 +- config/develop/kustomization.yaml | 1 - config/develop/manager_patch.yaml | 1 + internal/transport/gnmiext/client_test.go | 2 +- test/e2e/AGENTS.md | 146 ++++ test/e2e/cluster_suite_test.go | 90 +++ test/e2e/cluster_test.go | 467 +++++++++++++ test/e2e/e2e_test.go | 446 ------------- test/e2e/envtest_suite_test.go | 34 + test/e2e/envtest_test.go | 626 ++++++++++++++++++ test/e2e/{e2e_suite_test.go => suite_test.go} | 132 ++-- .../testdata/cisco-nxos-gnmi}/acl.txt | 32 +- .../testdata/cisco-nxos-gnmi}/banner.txt | 20 +- .../testdata/cisco-nxos-gnmi}/bgp_bgppeer.txt | 44 +- .../testdata/cisco-nxos-gnmi}/dhcprelay.txt | 42 +- .../testdata/cisco-nxos-gnmi}/dns.txt | 19 +- .../cisco-nxos-gnmi}/evpninstance.txt | 14 +- .../testdata/cisco-nxos-gnmi}/interfaces.txt | 166 +++-- .../testdata/cisco-nxos-gnmi}/isis.txt | 45 +- .../testdata/cisco-nxos-gnmi}/lldp.txt | 22 +- .../cisco-nxos-gnmi}/managementaccess.txt | 34 +- .../testdata/cisco-nxos-gnmi}/ntp.txt | 19 +- .../testdata/cisco-nxos-gnmi}/nve.txt | 16 +- .../testdata/cisco-nxos-gnmi}/ospf.txt | 16 +- .../testdata/cisco-nxos-gnmi}/pim.txt | 18 +- .../testdata/cisco-nxos-gnmi}/routedvlan.txt | 25 +- .../routingpolicy_prefixset.txt | 26 +- .../testdata/cisco-nxos-gnmi}/snmp.txt | 36 +- .../cisco-nxos-gnmi}/subinterface.txt | 26 +- test/e2e/testdata/cisco-nxos-gnmi/syslog.txt | 76 +++ .../testdata/cisco-nxos-gnmi}/vpcdomain.txt | 14 +- test/e2e/testdata/cisco-nxos-gnmi/vrf.txt | 129 ++++ .../testdata/{ => openconfig}/interface.txt | 2 +- test/e2e/testutil/cluster.go | 330 +++++++++ test/e2e/testutil/doc.go | 16 + test/e2e/testutil/envtest.go | 177 +++++ test/e2e/testutil/helpers.go | 545 +++++++++++++++ test/e2e/testutil/provider.go | 151 +++++ test/e2e/util_test.go | 299 --------- test/gnmi/Dockerfile | 19 +- test/gnmi/main.go | 16 +- test/gnmi/testserver/server.go | 66 +- test/integration/AGENTS.md | 95 --- test/integration/main_test.go | 347 ---------- test/integration/suite_test.go | 496 -------------- .../testdata/nxos/interfaceconfig.txt | 187 ------ test/integration/testdata/nxos/syslog.txt | 91 --- test/integration/testdata/nxos/vrf.txt | 42 -- 57 files changed, 3559 insertions(+), 2379 deletions(-) create mode 100644 .github/renovate.json create mode 100644 .github/workflows/checks.yaml create mode 100644 .github/workflows/codeql.yaml create mode 100644 test/e2e/AGENTS.md create mode 100644 test/e2e/cluster_suite_test.go create mode 100644 test/e2e/cluster_test.go delete mode 100644 test/e2e/e2e_test.go create mode 100644 test/e2e/envtest_suite_test.go create mode 100644 test/e2e/envtest_test.go rename test/e2e/{e2e_suite_test.go => suite_test.go} (51%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/acl.txt (64%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/banner.txt (74%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/bgp_bgppeer.txt (76%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/dhcprelay.txt (73%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/dns.txt (84%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/evpninstance.txt (84%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/interfaces.txt (64%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/isis.txt (68%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/lldp.txt (73%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/managementaccess.txt (53%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/ntp.txt (83%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/nve.txt (92%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/ospf.txt (93%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/pim.txt (91%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/routedvlan.txt (87%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/routingpolicy_prefixset.txt (88%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/snmp.txt (51%) rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/subinterface.txt (86%) create mode 100644 test/e2e/testdata/cisco-nxos-gnmi/syslog.txt rename test/{integration/testdata/nxos => e2e/testdata/cisco-nxos-gnmi}/vpcdomain.txt (95%) create mode 100644 test/e2e/testdata/cisco-nxos-gnmi/vrf.txt rename test/e2e/testdata/{ => openconfig}/interface.txt (98%) create mode 100644 test/e2e/testutil/cluster.go create mode 100644 test/e2e/testutil/doc.go create mode 100644 test/e2e/testutil/envtest.go create mode 100644 test/e2e/testutil/helpers.go create mode 100644 test/e2e/testutil/provider.go delete mode 100644 test/e2e/util_test.go delete mode 100644 test/integration/AGENTS.md delete mode 100644 test/integration/main_test.go delete mode 100644 test/integration/suite_test.go delete mode 100644 test/integration/testdata/nxos/interfaceconfig.txt delete mode 100644 test/integration/testdata/nxos/syslog.txt delete mode 100644 test/integration/testdata/nxos/vrf.txt diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 000000000..299094374 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended", + "default:pinDigestsDisabled", + "docker:pinDigests", + "mergeConfidence:all-badges" + ], + "assignees": [ + "felix-kaestner" + ], + "commitMessageAction": "Renovate: Update", + "constraints": { + "go": "1.26" + }, + "dependencyDashboardOSVVulnerabilitySummary": "all", + "osvVulnerabilityAlerts": true, + "postUpdateOptions": [ + "gomodTidy", + "gomodUpdateImportPaths" + ], + "packageRules": [ + { + "matchPackageNames": [ + "/.*/" + ], + "matchUpdateTypes": [ + "minor", + "patch" + ], + "groupName": "External dependencies" + }, + { + "matchPackageNames": [ + "/^github\\.com\\/sapcc\\/.*/" + ], + "automerge": true, + "groupName": "github.com/sapcc" + }, + { + "matchPackageNames": [ + "go", + "golang", + "actions/go-versions" + ], + "groupName": "golang", + "separateMinorPatch": true + }, + { + "matchPackageNames": [ + "go", + "golang", + "actions/go-versions" + ], + "matchUpdateTypes": [ + "minor", + "major" + ], + "dependencyDashboardApproval": true + }, + { + "matchFileNames": [ + ".github/workflows/checks.yaml", + ".github/workflows/codeql.yaml" + ], + "enabled": false + }, + { + "matchPackageNames": [ + "/^k8s.io\\//" + ], + "allowedVersions": "0.33.x" + } + ], + "prHourlyLimit": 0, + "schedule": [ + "before 8am on Friday" + ], + "semanticCommits": "disabled" +} diff --git a/.github/workflows/checks.yaml b/.github/workflows/checks.yaml new file mode 100644 index 000000000..22c6b80e7 --- /dev/null +++ b/.github/workflows/checks.yaml @@ -0,0 +1,48 @@ +################################################################################ +# This file is AUTOGENERATED with # +# Edit Makefile.maker.yaml instead. # +################################################################################ + +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company +# SPDX-License-Identifier: Apache-2.0 + +name: Checks +"on": + push: + branches: + - main + pull_request: + branches: + - '*' + workflow_dispatch: {} +permissions: + checks: write + contents: read +jobs: + checks: + name: Checks + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + check-latest: true + go-version: 1.26.4 + - name: Run golangci-lint + uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee # v9 + with: + version: v2.12.2 + - name: Delete pre-installed shellcheck + run: sudo rm -f "$(which shellcheck)" + - name: Run shellcheck + run: make run-shellcheck + - name: Dependency Licenses Review + run: make check-dependency-licenses + - name: Check for spelling errors + uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 # v1 + env: + CLICOLOR: "1" diff --git a/.github/workflows/codeql.yaml b/.github/workflows/codeql.yaml new file mode 100644 index 000000000..8db0f82e2 --- /dev/null +++ b/.github/workflows/codeql.yaml @@ -0,0 +1,46 @@ +################################################################################ +# This file is AUTOGENERATED with # +# Edit Makefile.maker.yaml instead. # +################################################################################ + +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company +# SPDX-License-Identifier: Apache-2.0 + +name: CodeQL +"on": + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: '00 07 * * 1' + workflow_dispatch: {} +permissions: + actions: read + contents: read + security-events: write +jobs: + analyze: + name: CodeQL + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + with: + persist-credentials: false + - name: Set up Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + check-latest: true + go-version: 1.26.4 + - name: Initialize CodeQL + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 + with: + languages: go + queries: security-extended + - name: Autobuild + uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 6076f5450..5d635bcab 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -25,6 +25,8 @@ builds: - amd64 - arm64 ignore: + - goos: darwin + goarch: amd64 - goos: windows goarch: arm64 ldflags: diff --git a/.license-scan-overrides.jsonl b/.license-scan-overrides.jsonl index f6e9877b5..faa62bcb5 100644 --- a/.license-scan-overrides.jsonl +++ b/.license-scan-overrides.jsonl @@ -1,7 +1,6 @@ {"name": "github.com/chzyer/logex", "licenceType": "MIT"} {"name": "github.com/grpc-ecosystem/go-grpc-middleware/v2", "licenceType": "Apache-2.0"} {"name": "github.com/hashicorp/vault/api/auth/approle", "licenceType": "MPL-2.0"} -{"name": "github.com/ironcore-dev/gnmi-test-server", "licenceType": "Apache-2.0"} {"name": "github.com/jpillora/longestcommon", "licenceType": "MIT"} {"name": "github.com/logrusorgru/aurora", "licenceType": "Unlicense"} {"name": "github.com/mattn/go-localereader", "licenceType": "MIT"} diff --git a/.typos.toml b/.typos.toml index b9215bcbd..7dd0e8bc6 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,22 +1,8 @@ -# SPDX-FileCopyrightText: 2026 SAP SE +# SPDX-FileCopyrightText: 2025 SAP SE +# # SPDX-License-Identifier: Apache-2.0 -[default] -extend-ignore-re = [ - "Cisco-IOS-XR.*" -] [default.extend-words] -ser = "ser" -otu = "otu" -# Typo in name used by Cisco NX-OS for a configurable property. -# See: https://pubhub.devnetcloud.com/media/dme-docs-10-4-3/docs/System/snmp%3ACommSecP/#configurable-properties -acess = "acess" -# See: https://github.com/openconfig/public/pull/1423 -entitites = "entitites" -mininum = "mininum" -specifc = "specifc" -# Go built-in keyword -cpy = "cpy" [files] extend-exclude = [ diff --git a/Makefile b/Makefile index a8c776423..06fcb080e 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,10 @@ CONTAINER_TOOL ?= docker # KIND_CLUSTER defines the name of the Kind cluster to be used for the tilt setup. KIND_CLUSTER ?= network +# PROVIDER defines which provider to test (openconfig, nxos, iosxr). +# Used by test-e2e to filter which testdata directory to use. +PROVIDER ?= openconfig + LOCALBIN ?= $(shell pwd)/bin $(LOCALBIN): mkdir -p $(LOCALBIN) @@ -50,6 +54,9 @@ $(LOCALBIN): install-gofumpt: FORCE @if ! hash gofumpt 2>/dev/null; then printf "\e[1;36m>> Installing gofumpt...\e[0m\n"; go install mvdan.cc/gofumpt@latest; fi +install-ginkgo: FORCE + @if ! hash ginkgo 2>/dev/null; then printf "\e[1;36m>> Installing ginkgo...\e[0m\n"; go install github.com/onsi/ginkgo/v2/ginkgo@latest; fi + install-kubebuilder: FORCE @set -eou pipefail; if ! hash kubebuilder 2>/dev/null; then printf "\e[1;36m>> Installing kubebuilder...\e[0m\n"; if command -v curl >/dev/null 2>&1; then GET="curl -sLo"; elif command -v wget >/dev/null 2>&1; then GET="wget -O"; else echo "Didn't find curl or wget to download kubebuilder"; exit 2; fi; BIN=$$(go env GOBIN); if [[ -z $$BIN ]]; then BIN=$$(go env GOPATH)/bin; fi; $$GET "$$BIN/kubebuilder" "https://go.kubebuilder.io/dl/latest/$$(go env GOOS)/$$(go env GOARCH)"; chmod +x "$$BIN/kubebuilder"; fi @@ -73,7 +80,9 @@ fmt: FORCE install-gofumpt @gofumpt -l -w $(shell git ls-files '*.go' | grep -v '^internal/provider/openconfig') # Run the e2e tests against a k8s cluster. -test-e2e: FORCE +# Use PROVIDER=nxos or PROVIDER=openconfig to filter tests. +# Uses ginkgo CLI with -procs=4 for parallel test execution. +test-e2e-kind: FORCE install-ginkgo @command -v kind >/dev/null 2>&1 || { \ echo "Kind is not installed. Please install Kind manually."; \ exit 1; \ @@ -82,8 +91,14 @@ test-e2e: FORCE echo "No Kind cluster is running. Please start a Kind cluster before running the e2e tests."; \ exit 1; \ } - @printf "\e[1;36m>> go test ./test/e2e/ -v -ginkgo.v\e[0m\n" - @KIND_CLUSTER=$(KIND_CLUSTER) go test ./test/e2e/ -v -ginkgo.v + @printf "\e[1;36m>> ginkgo -procs=4 -timeout=15m -v ./test/e2e/ (PROVIDER=$(PROVIDER))\e[0m\n" + @KIND_CLUSTER=$(KIND_CLUSTER) E2E_PROVIDER=$(PROVIDER) ginkgo -procs=4 -timeout=15m -v ./test/e2e/ + +# Run the e2e tests in envtest mode (no cluster required). +# Use PROVIDER=nxos or PROVIDER=openconfig to filter tests. +test-e2e-envtest: FORCE install-setup-envtest + @printf "\e[1;36m>> go test ./test/e2e/ -tags=envtest -v -ginkgo.v (PROVIDER=$(PROVIDER))\e[0m\n" + @KUBEBUILDER_ASSETS=$$(setup-envtest use 1.32 -p path) E2E_PROVIDER=$(PROVIDER) go test ./test/e2e/ -tags=envtest -v -ginkgo.v docker-build: FORCE @printf "\e[1;36m>> $(CONTAINER_TOOL) build --tag=$(IMG) .\e[0m\n" @@ -98,15 +113,26 @@ build-installer: FORCE generate install-kustomize @printf "\e[1;36m>> kustomize build config/default > dist/install.yaml\e[0m\n" @mkdir -p dist; kustomize build config/default > dist/install.yaml -# Deploy controller to the k8s cluster +# Deploy controller to the k8s cluster. +# Use PROVIDER to set the provider (default: openconfig). deploy: FORCE generate install-kustomize - @printf "\e[1;36m>> kustomize build config/default | kubectl apply -f -\e[0m\n" - @kustomize build config/default | kubectl apply -f - + @printf "\e[1;36m>> deploying controller-manager (PROVIDER=$(PROVIDER))\e[0m\n" + @kustomize build config/develop | sed 's/--provider=openconfig/--provider=$(PROVIDER)/' | kubectl apply -f - # Undeploy controller from the k8s cluster undeploy: FORCE install-kustomize - @printf "\e[1;36m>> kustomize build config/default | kubectl delete -f -\e[0m\n" - @kustomize build config/default | kubectl delete --ignore-not-found=true -f - + @printf "\e[1;36m>> undeploying controller-manager\e[0m\n" + @kustomize build config/develop | kubectl delete --ignore-not-found=true -f - + +# Deploy gnmi-test-server for local development (not needed for tests - they create their own) +deploy-gnmi-server: FORCE + @printf "\e[1;36m>> deploying gnmi-test-server\e[0m\n" + @kubectl apply -f config/develop/gnmi-test-server.yaml + +# Undeploy gnmi-test-server +undeploy-gnmi-server: FORCE + @printf "\e[1;36m>> undeploying gnmi-test-server\e[0m\n" + @kubectl delete --ignore-not-found=true -f config/develop/gnmi-test-server.yaml # Install CRDs into the k8s cluster deploy-crds: FORCE generate install-kustomize @@ -219,7 +245,7 @@ install-shellcheck: FORCE @set -eou pipefail; if ! hash shellcheck 2>/dev/null; then printf "\e[1;36m>> Installing shellcheck...\e[0m\n"; SHELLCHECK_ARCH=$$(uname -m); if [[ "$$SHELLCHECK_ARCH" == "arm64" ]]; then SHELLCHECK_ARCH=aarch64; fi; SHELLCHECK_OS=$$(uname -s | tr '[:upper:]' '[:lower:]'); SHELLCHECK_VERSION="stable"; if command -v curl >/dev/null 2>&1; then GET="curl -sLo-"; elif command -v wget >/dev/null 2>&1; then GET="wget -O-"; else echo "Didn't find curl or wget to download shellcheck"; exit 2; fi; $$GET "https://github.com/koalaman/shellcheck/releases/download/$$SHELLCHECK_VERSION/shellcheck-$$SHELLCHECK_VERSION.$$SHELLCHECK_OS.$$SHELLCHECK_ARCH.tar.xz" | tar -Jxf -; BIN=$$(go env GOBIN); if [[ -z $$BIN ]]; then BIN=$$(go env GOPATH)/bin; fi; install -Dm755 shellcheck-$$SHELLCHECK_VERSION/shellcheck -t "$$BIN"; rm -rf shellcheck-$$SHELLCHECK_VERSION; fi install-typos: FORCE - @set -eou pipefail; if ! hash typos 2>/dev/null; then printf "\e[1;36m>> Installing typos...\e[0m\n"; TYPOS_ARCH=$$(uname -m); if [[ "$$TYPOS_ARCH" == "arm64" ]]; then TYPOS_ARCH=aarch64; fi; if command -v curl >/dev/null 2>&1; then GET="curl $${GITHUB_TOKEN:+" -u \":$$GITHUB_TOKEN\""} -sLo-"; elif command -v wget >/dev/null 2>&1; then GET="wget $${GITHUB_TOKEN:+" --password \"$$GITHUB_TOKEN\""} -O-"; else echo "Didn't find curl or wget to download typos"; exit 2; fi; if command -v gh >/dev/null; then TYPOS_GET_RELEASE_JSON="gh api /repos/crate-ci/typos/releases"; else TYPOS_GET_RELEASE_JSON="$$GET https://api.github.com/repos/crate-ci/typos/releases"; fi; TYPOS_VERSION=$$($$TYPOS_GET_RELEASE_JSON | jq -r '.[0].name' ); if [[ $(UNAME_S) == Darwin ]]; then TYPOS_FILE="typos-$$TYPOS_VERSION-$$TYPOS_ARCH-apple-darwin.tar.gz"; elif [[ $(UNAME_S) == Linux ]]; then TYPOS_FILE="typos-$$TYPOS_VERSION-$$TYPOS_ARCH-unknown-linux-musl.tar.gz"; fi; mkdir -p typos; $$GET ""https://github.com/crate-ci/typos/releases/download/$$TYPOS_VERSION/$$TYPOS_FILE"" | tar -C typos -zxf -; BIN=$$(go env GOBIN); if [[ -z $$BIN ]]; then BIN=$$(go env GOPATH)/bin; fi; install -Dm755 typos/typos -t "$$BIN"; rm -rf typos/; fi + @set -xeou pipefail; if ! hash typos 2>/dev/null; then printf "\e[1;36m>> Installing typos...\e[0m\n"; TYPOS_ARCH=$$(uname -m); if [[ "$$TYPOS_ARCH" == "arm64" ]]; then TYPOS_ARCH=aarch64; fi; if command -v curl >/dev/null 2>&1; then GET="curl $${GITHUB_TOKEN:+" -u \":$$GITHUB_TOKEN\""} -sLo-"; elif command -v wget >/dev/null 2>&1; then GET="wget $${GITHUB_TOKEN:+" --password \"$$GITHUB_TOKEN\""} -O-"; else echo "Didn't find curl or wget to download typos"; exit 2; fi; if command -v gh >/dev/null; then TYPOS_GET_RELEASE_JSON="gh api /repos/crate-ci/typos/releases"; else TYPOS_GET_RELEASE_JSON="$$GET https://api.github.com/repos/crate-ci/typos/releases"; fi; TYPOS_VERSION=$$($$TYPOS_GET_RELEASE_JSON | jq -r '.[0].name' ); if [[ $(UNAME_S) == Darwin ]]; then TYPOS_FILE="typos-$$TYPOS_VERSION-$$TYPOS_ARCH-apple-darwin.tar.gz"; elif [[ $(UNAME_S) == Linux ]]; then TYPOS_FILE="typos-$$TYPOS_VERSION-$$TYPOS_ARCH-unknown-linux-musl.tar.gz"; fi; mkdir -p typos; $$GET ""https://github.com/crate-ci/typos/releases/download/$$TYPOS_VERSION/$$TYPOS_FILE"" | tar -C typos -zxf -; BIN=$$(go env GOBIN); if [[ -z $$BIN ]]; then BIN=$$(go env GOPATH)/bin; fi; install -Dm755 typos/typos -t "$$BIN"; rm -rf typos/; fi install-go-licence-detector: FORCE @if ! hash go-licence-detector 2>/dev/null; then printf "\e[1;36m>> Installing go-licence-detector (this may take a while)...\e[0m\n"; go install go.elastic.co/go-licence-detector@latest; fi diff --git a/Makefile.maker.yaml b/Makefile.maker.yaml index cb315d6b8..70a303d4d 100644 --- a/Makefile.maker.yaml +++ b/Makefile.maker.yaml @@ -85,6 +85,10 @@ verbatim: | # KIND_CLUSTER defines the name of the Kind cluster to be used for the tilt setup. KIND_CLUSTER ?= network + # PROVIDER defines which provider to test (openconfig, nxos, iosxr). + # Used by test-e2e to filter which testdata directory to use. + PROVIDER ?= openconfig + LOCALBIN ?= $(shell pwd)/bin $(LOCALBIN): mkdir -p $(LOCALBIN) @@ -115,7 +119,8 @@ verbatim: | @gofumpt -l -w $(shell git ls-files '*.go' | grep -v '^internal/provider/openconfig') # Run the e2e tests against a k8s cluster. - test-e2e: FORCE + # Use PROVIDER=nxos or PROVIDER=openconfig to filter tests. + test-e2e-kind: FORCE @command -v kind >/dev/null 2>&1 || { \ echo "Kind is not installed. Please install Kind manually."; \ exit 1; \ @@ -124,8 +129,14 @@ verbatim: | echo "No Kind cluster is running. Please start a Kind cluster before running the e2e tests."; \ exit 1; \ } - @printf "\e[1;36m>> go test ./test/e2e/ -v -ginkgo.v\e[0m\n" - @KIND_CLUSTER=$(KIND_CLUSTER) go test ./test/e2e/ -v -ginkgo.v + @printf "\e[1;36m>> go test ./test/e2e/ -timeout 15m -v -ginkgo.v (PROVIDER=$(PROVIDER))\e[0m\n" + @KIND_CLUSTER=$(KIND_CLUSTER) E2E_PROVIDER=$(PROVIDER) go test ./test/e2e/ -timeout 15m -v -ginkgo.v + + # Run the e2e tests in envtest mode (no cluster required). + # Use PROVIDER=nxos or PROVIDER=openconfig to filter tests. + test-e2e-envtest: FORCE install-setup-envtest + @printf "\e[1;36m>> go test ./test/e2e/ -tags=envtest -v -ginkgo.v (PROVIDER=$(PROVIDER))\e[0m\n" + @KUBEBUILDER_ASSETS=$$(setup-envtest use 1.32 -p path) E2E_PROVIDER=$(PROVIDER) go test ./test/e2e/ -tags=envtest -v -ginkgo.v docker-build: FORCE @printf "\e[1;36m>> $(CONTAINER_TOOL) build --tag=$(IMG) .\e[0m\n" @@ -140,15 +151,16 @@ verbatim: | @printf "\e[1;36m>> kustomize build config/default > dist/install.yaml\e[0m\n" @mkdir -p dist; kustomize build config/default > dist/install.yaml - # Deploy controller to the k8s cluster + # Deploy controller to the k8s cluster. + # Use PROVIDER to set the provider (default: openconfig). deploy: FORCE generate install-kustomize - @printf "\e[1;36m>> kustomize build config/default | kubectl apply -f -\e[0m\n" - @kustomize build config/default | kubectl apply -f - + @printf "\e[1;36m>> deploying controller-manager (PROVIDER=$(PROVIDER))\e[0m\n" + @kustomize build config/develop | sed 's/--provider=openconfig/--provider=$(PROVIDER)/' | kubectl apply -f - # Undeploy controller from the k8s cluster undeploy: FORCE install-kustomize - @printf "\e[1;36m>> kustomize build config/default | kubectl delete -f -\e[0m\n" - @kustomize build config/default | kubectl delete --ignore-not-found=true -f - + @printf "\e[1;36m>> undeploying controller-manager\e[0m\n" + @kustomize build config/develop | kubectl delete --ignore-not-found=true -f - # Install CRDs into the k8s cluster deploy-crds: FORCE generate install-kustomize @@ -231,10 +243,6 @@ verbatim: | @$(CONTAINER_TOOL) push $(TEST_LAB_IMG) - test-integration: FORCE install-setup-envtest - @printf "\e[1;36m>> go test ./test/integration/ -v\e[0m\n" - @KUBEBUILDER_ASSETS=$$(setup-envtest use 1.35 -p path) go test ./test/integration/ -v - ROOT_DIR := $(shell pwd) DOCS_IMG ?= ironcore-dev/network-operator-docs:latest diff --git a/config/default/kustomization.yaml b/config/default/kustomization.yaml index fd3c7529c..2a2e5cd92 100644 --- a/config/default/kustomization.yaml +++ b/config/default/kustomization.yaml @@ -24,7 +24,7 @@ resources: # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. - ../certmanager # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. -#- ../prometheus +- ../prometheus # [METRICS] Expose the controller manager metrics service. - metrics_service.yaml # [PROVISIONING] Expose the controller manager provisioning service. diff --git a/config/develop/gnmi-test-server.yaml b/config/develop/gnmi-test-server.yaml index 789b76681..9f2d4d1c8 100644 --- a/config/develop/gnmi-test-server.yaml +++ b/config/develop/gnmi-test-server.yaml @@ -33,7 +33,7 @@ spec: containers: - name: gnmi-test-server image: ghcr.io/ironcore-dev/gnmi-test-server:latest - imagePullPolicy: IfNotPresent + imagePullPolicy: Never ports: - containerPort: 9339 name: grpc diff --git a/config/develop/kustomization.yaml b/config/develop/kustomization.yaml index dbdfde4af..5ccca71e2 100644 --- a/config/develop/kustomization.yaml +++ b/config/develop/kustomization.yaml @@ -1,6 +1,5 @@ resources: - ../default -- gnmi-test-server.yaml patches: - path: manager_patch.yaml diff --git a/config/develop/manager_patch.yaml b/config/develop/manager_patch.yaml index e07e73a48..173af22e3 100644 --- a/config/develop/manager_patch.yaml +++ b/config/develop/manager_patch.yaml @@ -3,6 +3,7 @@ value: - --leader-elect=false - --health-probe-bind-address=:8081 + - --metrics-bind-address=:8443 - --provider=openconfig - --requeue-interval=30s - --max-concurrent-reconciles=5 diff --git a/internal/transport/gnmiext/client_test.go b/internal/transport/gnmiext/client_test.go index 2f4ac5672..f1b6f2c77 100644 --- a/internal/transport/gnmiext/client_test.go +++ b/internal/transport/gnmiext/client_test.go @@ -1332,7 +1332,7 @@ var _ DataElement = (*Hostname)(nil) func (*Hostname) XPath() string { return "openconfig:system/config/hostname" } -// -- State -- +// -- state/expect -- type HostnameState string diff --git a/test/e2e/AGENTS.md b/test/e2e/AGENTS.md new file mode 100644 index 000000000..86c917bf4 --- /dev/null +++ b/test/e2e/AGENTS.md @@ -0,0 +1,146 @@ +# E2E Tests + +Integration tests that validate the full reconciliation pipeline from Kubernetes CRD to gNMI JSON output. + +## Rationale + +Unit tests verify individual functions but miss the gaps between layers: CRD validation, controller logic, provider field mapping, and gNMI path construction. For example, a controller passing the wrong value to a provider. + +Two test modes via Go build tags (`//go:build envtest` vs `//go:build !envtest`): + +- **Envtest mode** — runs tests sequantially without infrastructure using envtest + in-process gnmi-test-server. Tests complete in seconds. +- **Cluster mode** — runs tests in parallel against Kind with full operator installation. Validates RBAC, webhooks, metrics. +Each reconciliation test is isolated: unique namespace + dedicated gnmi-test-server pod. + +## Commands + +```bash +make test-e2e-envtest PROVIDER=cisco-nxos-gnmi # no cluster required +make test-e2e-kind PROVIDER=cisco-nxos-gnmi # requires Kind cluster "network" +``` + +**Provider options:** `openconfig`, `cisco-nxos-gnmi` + +## Mode Comparison + +| Aspect | Envtest | Cluster | +|--------|---------|---------| +| **K8s API** | In-process (envtest) | Kind cluster | +| **gNMI server** | In-process (gnmi-test-server) | Deployed pod per test | +| **Controllers** | In-process per test | Deployed operator pod | +| **Speed** | ~10s | ~3-4min (parallel) | +| **Dependencies** | None | Docker + Kind | + +| Coverage | Envtest | Cluster | +|----------|:-------:|:-------:| +| Controller reconciliation | ✅ | ✅ | +| Status conditions | ✅ | ✅ | +| gNMI payload generation | ✅ | ✅ | +| RBAC / ServiceAccount | ❌ | ✅ | +| Webhook TLS + cert-manager | ❌ | ✅ | +| Container image build | ❌ | ✅ | +| Metrics endpoint | ❌ | ✅ | + +## Environment Variables + +| Variable | Purpose | +|----------|---------| +| `PROVIDER` / `E2E_PROVIDER` | Filter tests to specific provider | +| `PROMETHEUS_INSTALL_SKIP=true` | Skip Prometheus installation (cluster mode) | +| `CERT_MANAGER_INSTALL_SKIP=true` | Skip CertManager installation (cluster mode) | + +## Architecture + +Both modes implement `TestEnvironment` interface via `testutil/`: + +| Method | Purpose | +|--------|---------| +| `Setup()` / `Teardown()` | Initialize/cleanup K8s + gNMI server | +| `Client()` / `RESTConfig()` | K8s client for resources and managers | +| `GNMIAddress()` | Endpoint for Device CRDs | +| `GetGNMIState()` / `ClearGNMIState()` | Verify and reset gNMI state | +| `PreloadGNMIState()` | Set initial state before reconciliation | +| `IsEnvtest()` | Detect mode for conditional logic | + +### GNMI test server + +The server accumulates gNMI Set operations and exposes state via `GetState()`. + +When configured with `WithNXOSBehavior()`: +- Strips fields with value `"DME_UNSET_PROPERTY_MARKER"` when storing (the marker means "unset this field", not "store this literal string") +- Returns empty TypedValue for non-existent paths (instead of NOT_FOUND error), matching real NX-OS behavior + + +## Testdata Format + +Location: `test/e2e/testdata//.txt` — auto-discovered, txtar format. + +Each test file contains K8s YAML resources and expected gNMI JSON state: + +``` +-- state/preload -- # OPTIONAL: initial gNMI state +{"System": {"procsys-items": {"bootTime": "1700000000"}}} + +-- / -- # Resource to create +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +spec: + deviceRef: + name: device # substituted at runtime + ... + +-- / -- # Multiple resources supported +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: BGPPeer +spec: + deviceRef: + name: device + ... + +-- state/expect -- # Expected gNMI JSON (array order ignored) +{"System": {"intf-items": ...}} +``` + +### Test Flow + +1. Parse txtar file → extract resources + expected state +2. Create test namespace +3. Deploy/connect to gnmi-test-server +4. Preload gNMI state if `state/preload` section exists +5. Create Device pointing to gnmi-test-server address +6. Apply resources **sequentially**, waiting for `Configured` condition before next +7. Compare final gNMI state vs `state/expect` using semantic JSON comparison + +### Important Notes + +> **gNMI State Differences:** The JSON stored in gnmi-test-server may differ from actual provider output. For example, NXOS `DME_UNSET_PROPERTY_MARKER` values are filtered out by the test server. + +> **Resource Dependencies:** Resources are created in file order. Use ordering to handle dependencies (e.g., BGPPeer after BGP, RoutingPolicy after PrefixSet). + +## Existing Test Files + +``` +test/e2e/testdata/cisco-nxos-gnmi/ +├── acl.txt +├── banner.txt +├── bgp_bgppeer.txt +├── dhcprelay.txt +├── dns.txt +├── evpninstance.txt +├── interfaceconfig.txt +├── interfaces.txt +├── isis.txt +├── lldp.txt +├── managementaccess.txt +├── ntp.txt +├── nve.txt +├── ospf.txt +├── pim.txt +├── routedvlan.txt +├── routingpolicy_prefixset.txt +├── snmp.txt +├── subinterface.txt +├── syslog.txt +├── vpcdomain.txt +└── vrf.txt +``` diff --git a/test/e2e/cluster_suite_test.go b/test/e2e/cluster_suite_test.go new file mode 100644 index 000000000..316747e0b --- /dev/null +++ b/test/e2e/cluster_suite_test.go @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build !envtest + +package e2e + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + "github.com/ironcore-dev/network-operator/test/e2e/testutil" +) + +// SynchronizedBeforeSuite enables parallel test execution: +// - Process 1: Builds images, installs Prometheus/CertManager, deploys manager (runs first, alone) +// - All processes: Create ClusterEnvironment connection (runs after process 1 completes) +var _ = SynchronizedBeforeSuite( + // First function: runs ONLY on process 1, before other processes start + func(ctx SpecContext) []byte { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + SetDefaultEventuallyTimeout(30 * time.Second) + SetDefaultEventuallyPollingInterval(time.Second) + + By("Ensure that Prometheus is enabled") + cwd, err := testutil.GetProjectDir() + Expect(err).NotTo(HaveOccurred(), "Failed to get project directory") + + err = testutil.UncommentCode(cwd+"/config/default/kustomization.yaml", "#- ../prometheus", "#") + Expect(err).NotTo(HaveOccurred(), "Failed to enable Prometheus") + + // Build and load images to Kind (only process 1) + buildAndLoadImages(ctx) + + // Setup Prometheus and CertManager (only process 1) + setupClusterDependencies(ctx) + + // Deploy controller-manager (includes CRDs via make deploy) + By("deploying controller-manager") + tmpEnv := testutil.NewClusterEnvironment() + Expect(tmpEnv.Setup(ctx)).To(Succeed()) + Expect(tmpEnv.DeployManager(ctx)).To(Succeed()) + + return nil // No data to pass to other processes + }, + // Second function: runs on ALL processes after the first function completes + func(ctx SpecContext, _ []byte) { + SetDefaultEventuallyTimeout(30 * time.Second) + SetDefaultEventuallyPollingInterval(time.Second) + + // All processes create their own ClusterEnvironment connection + By("initializing cluster environment") + testEnv = testutil.NewClusterEnvironment() + Expect(testEnv.Setup(ctx)).To(Succeed()) + }, +) + +// SynchronizedAfterSuite enables parallel test cleanup: +// - All processes: Local cleanup (runs on all processes) +// - Process 1: Uninstall shared dependencies (runs last, alone) +var _ = SynchronizedAfterSuite( + // First function: runs on ALL processes + func(ctx SpecContext) { + // Perform local cleanup (will run only once even if called from signal handler) + performCleanup() + + // Wait for all test namespaces to be fully deleted before returning. + // This ensures DeferCleanup hooks have finished deleting resources and their + // finalizers have been processed by the controller. Without this, the second + // function (UndeployManager) may delete the CRDs while resources still exist, + // causing finalizers to be stuck forever. + if testEnv != nil { + _ = testEnv.WaitForTestNamespacesGone(ctx) + } + }, + // Second function: runs ONLY on process 1, after all other processes complete + func(ctx SpecContext) { + // Undeploy the controller-manager + tmpEnv := testutil.NewClusterEnvironment() + _ = tmpEnv.Setup(ctx) + _ = tmpEnv.UndeployManager(ctx) + + // Uninstall Prometheus and CertManager + cleanupClusterDependencies(ctx) + }, +) diff --git a/test/e2e/cluster_test.go b/test/e2e/cluster_test.go new file mode 100644 index 000000000..c4fa771e2 --- /dev/null +++ b/test/e2e/cluster_test.go @@ -0,0 +1,467 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build !envtest + +package e2e + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "golang.org/x/tools/txtar" + + "github.com/ironcore-dev/network-operator/test/e2e/testutil" +) + +// namespace where the project is deployed in +// tests create resources in separate namespaces +const namespace = "network-operator-system" + +// serviceAccountName created for the project +const serviceAccountName = "network-operator-controller-manager" + +// metricsServiceName is the name of the metrics service of the project +const metricsServiceName = "network-operator-controller-manager-metrics-service" + +// metricsRoleBindingName is the name of the RBAC that will be created to allow get the metrics data +const metricsRoleBindingName = "network-operator-metrics-binding" + +// testEnv is the cluster test environment. +var testEnv *testutil.ClusterEnvironment + +func init() { + _, _ = fmt.Fprintf(GinkgoWriter, "Starting network-operator tests in CLUSTER mode\n") +} + +// Manager Setup tests run serially on a single Ginkgo process. +// These tests deploy and verify the controller-manager before reconciliation tests run in parallel. +var _ = Describe("Manager Setup", Serial, Ordered, func() { + var controllerPodName string + + // Before running the tests, set up the environment by creating the namespace, + // enforce the restricted security policy to the namespace, installing CRDs, + // and deploying the controller. + BeforeAll(func(ctx SpecContext) { + By("creating manager namespace") + cmd := exec.CommandContext(ctx, "kubectl", "create", "ns", namespace, "--dry-run=client", "-o", "yaml") + nsYaml, err := testutil.Run(cmd, GinkgoWriter) + Expect(err).NotTo(HaveOccurred(), "Failed to generate namespace YAML") + cmd = exec.CommandContext(ctx, "kubectl", "apply", "-f", "-") + cmd.Stdin = bytes.NewBufferString(nsYaml) + _, err = testutil.Run(cmd, GinkgoWriter) + Expect(err).NotTo(HaveOccurred(), "Failed to create namespace") + + By("labeling the namespace to enforce the restricted security policy") + cmd = exec.CommandContext(ctx, "kubectl", "label", "--overwrite", "ns", namespace, "pod-security.kubernetes.io/enforce=restricted") + _, err = testutil.Run(cmd, GinkgoWriter) + Expect(err).NotTo(HaveOccurred(), "Failed to label namespace with restricted policy") + + By("installing CRDs") + cmd = exec.CommandContext(ctx, "make", "deploy-crds") + _, err = testutil.Run(cmd, GinkgoWriter) + Expect(err).NotTo(HaveOccurred(), "Failed to install CRDs") + + By("deploying the controller-manager") + cmd = exec.CommandContext(ctx, "make", "deploy") + _, err = testutil.Run(cmd, GinkgoWriter) + Expect(err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager") + }) + + // After all setup tests complete, clean up the manager. + // Note: CRDs are left installed for the parallel reconciliation tests. + AfterAll(func(ctx SpecContext) { + By("cleaning up the ClusterRoleBinding of the service account to allow access to metrics") + cmd := exec.CommandContext(ctx, "kubectl", "delete", "clusterrolebinding", metricsRoleBindingName, "--ignore-not-found") + _, err := testutil.Run(cmd, GinkgoWriter) + Expect(err).NotTo(HaveOccurred(), "Failed to delete ClusterRoleBinding") + + By("cleaning up the curl pod for metrics") + cmd = exec.CommandContext(ctx, "kubectl", "delete", "pod", "curl-metrics", "-n", namespace, "--ignore-not-found") + _, err = testutil.Run(cmd, GinkgoWriter) + Expect(err).NotTo(HaveOccurred(), "Failed to delete curl-metrics pod") + }) + + // After each test, check for failures and collect logs, events, + // and pod descriptions for debugging. + AfterEach(func(ctx SpecContext) { + if specReport := CurrentSpecReport(); specReport.Failed() { + By("Fetching controller manager pod logs") + cmd := exec.CommandContext(ctx, "kubectl", "logs", controllerPodName, "-n", namespace) + controllerLogs, err := testutil.Run(cmd, GinkgoWriter) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Controller logs:\n %s", controllerLogs) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Controller logs: %s", err) + } + + By("Fetching Kubernetes events") + cmd = exec.CommandContext(ctx, "kubectl", "get", "events", "-n", namespace, "--sort-by=.lastTimestamp") + eventsOutput, err := testutil.Run(cmd, GinkgoWriter) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Kubernetes events:\n%s", eventsOutput) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Kubernetes events: %s", err) + } + + By("Fetching curl-metrics logs") + cmd = exec.CommandContext(ctx, "kubectl", "logs", "curl-metrics", "-n", namespace) + metricsOutput, err := testutil.Run(cmd, GinkgoWriter) + if err == nil { + _, _ = fmt.Fprintf(GinkgoWriter, "Metrics logs:\n %s", metricsOutput) + } else { + _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get curl-metrics logs: %s", err) + } + + By("Fetching controller manager pod description") + cmd = exec.CommandContext(ctx, "kubectl", "describe", "pod", controllerPodName, "-n", namespace) + podDescription, err := testutil.Run(cmd, GinkgoWriter) + if err == nil { + fmt.Println("Pod description:\n", podDescription) + } else { + fmt.Println("Failed to describe controller pod") + } + } + }) + + SetDefaultEventuallyTimeout(2 * time.Minute) + SetDefaultEventuallyPollingInterval(time.Second) + + It("should run successfully", func(ctx SpecContext) { + By("validating that the controller-manager pod is running as expected") + verifyControllerUp := func(g Gomega) { + // Get the name of the controller-manager pod + cmd := exec.CommandContext( + ctx, "kubectl", "get", + "pods", "-l", "control-plane=controller-manager", + "-o", "go-template={{ range .items }}"+ + "{{ if not .metadata.deletionTimestamp }}"+ + "{{ .metadata.name }}"+ + "{{ \"\\n\" }}{{ end }}{{ end }}", + "-n", namespace, + ) + + podOutput, err := testutil.Run(cmd, GinkgoWriter) + g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve controller-manager pod information") + podNames := testutil.GetNonEmptyLines(podOutput) + g.Expect(podNames).To(HaveLen(1), "expected 1 controller pod running") + controllerPodName = podNames[0] + g.Expect(controllerPodName).To(ContainSubstring("controller-manager")) + + // Validate the pod's status + cmd = exec.CommandContext(ctx, "kubectl", "get", "pods", controllerPodName, "-o", "jsonpath={.status.phase}", "-n", namespace) + output, err := testutil.Run(cmd, GinkgoWriter) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("Running"), "Incorrect controller-manager pod status") + } + Eventually(verifyControllerUp).Should(Succeed()) + }) + + It("should ensure the metrics endpoint is serving metrics", func(ctx SpecContext) { + By("creating a ClusterRoleBinding for the service account to allow access to metrics") + // #nosec G204 + cmd := exec.CommandContext(ctx, "kubectl", "create", "clusterrolebinding", metricsRoleBindingName, "--clusterrole=network-operator-metrics-reader", fmt.Sprintf("--serviceaccount=%s:%s", namespace, serviceAccountName)) + _, err := testutil.Run(cmd, GinkgoWriter) + Expect(err).NotTo(HaveOccurred(), "Failed to create ClusterRoleBinding") + + By("validating that the metrics service is available") + cmd = exec.CommandContext(ctx, "kubectl", "get", "service", metricsServiceName, "-n", namespace) + _, err = testutil.Run(cmd, GinkgoWriter) + Expect(err).NotTo(HaveOccurred(), "Metrics service should exist") + + By("validating that the ServiceMonitor for Prometheus is applied in the namespace") + cmd = exec.CommandContext(ctx, "kubectl", "get", "ServiceMonitor", "-n", namespace) + _, err = testutil.Run(cmd, GinkgoWriter) + Expect(err).NotTo(HaveOccurred(), "ServiceMonitor should exist") + + By("getting the service account token") + token, err := serviceAccountToken(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(token).NotTo(BeEmpty()) + + By("waiting for the metrics endpoint to be ready") + verifyMetricsEndpointReady := func(g Gomega) { + kcmd := exec.CommandContext(ctx, "kubectl", "get", "endpoints", metricsServiceName, "-n", namespace) + output, kErr := testutil.Run(kcmd, GinkgoWriter) + g.Expect(kErr).NotTo(HaveOccurred()) + g.Expect(output).To(ContainSubstring("8443"), "Metrics endpoint is not ready") + } + Eventually(verifyMetricsEndpointReady).Should(Succeed()) + + By("verifying that the controller manager has started") + verifyManagerStarted := func(g Gomega) { + kcmd := exec.CommandContext(ctx, "kubectl", "logs", controllerPodName, "-n", namespace) + output, kErr := testutil.Run(kcmd, GinkgoWriter) + g.Expect(kErr).NotTo(HaveOccurred()) + g.Expect(output).To(ContainSubstring("starting manager"), "Manager not yet started") + } + Eventually(verifyManagerStarted).Should(Succeed()) + + By("creating the curl-metrics pod to access the metrics endpoint") + // #nosec G204 + cmd = exec.CommandContext(ctx, "kubectl", "run", "curl-metrics", "--restart=Never", + "--namespace", namespace, + "--image=curlimages/curl:latest", + "--overrides", + fmt.Sprintf(`{ + "spec": { + "containers": [{ + "name": "curl", + "image": "curlimages/curl:latest", + "command": ["/bin/sh", "-c"], + "args": ["curl -v -k -H 'Authorization: Bearer %s' https://%s.%s.svc.cluster.local:8443/metrics"], + "securityContext": { + "allowPrivilegeEscalation": false, + "capabilities": { + "drop": ["ALL"] + }, + "runAsNonRoot": true, + "runAsUser": 1000, + "seccompProfile": { + "type": "RuntimeDefault" + } + } + }], + "serviceAccount": "%s" + } + }`, token, metricsServiceName, namespace, serviceAccountName)) + _, err = testutil.Run(cmd, GinkgoWriter) + Expect(err).NotTo(HaveOccurred(), "Failed to create curl-metrics pod") + + By("waiting for the curl-metrics pod to complete.") + verifyCurlUp := func(g Gomega) { + cmd := exec.CommandContext(ctx, "kubectl", "get", "pods", "curl-metrics", "-o", "jsonpath={.status.phase}", "-n", namespace) + output, err := testutil.Run(cmd, GinkgoWriter) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(output).To(Equal("Succeeded"), "curl pod in wrong status") + } + Eventually(verifyCurlUp, 5*time.Minute).Should(Succeed()) + + By("getting the metrics by checking curl-metrics logs") + metricsOutput := getMetricsOutput(ctx) + Expect(metricsOutput).To(ContainSubstring("controller_runtime_webhook_panics_total")) + }) + + It("should provisioned cert-manager", func(ctx SpecContext) { + By("validating that cert-manager has the certificate Secret") + verifyCertManager := func(g Gomega) { + cmd := exec.CommandContext(ctx, "kubectl", "get", "secrets", "webhook-server-cert", "-n", namespace) + _, err := testutil.Run(cmd, GinkgoWriter) + g.Expect(err).NotTo(HaveOccurred()) + } + Eventually(verifyCertManager).Should(Succeed()) + }) + + It("should have CA injection for validating webhooks", func(ctx SpecContext) { + By("checking CA injection for validating webhooks") + verifyCAInjection := func(g Gomega) { + cmd := exec.CommandContext(ctx, "kubectl", "get", + "validatingwebhookconfigurations.admissionregistration.k8s.io", + "network-operator-validating-webhook-configuration", + "-o", "go-template={{ range .webhooks }}{{ .clientConfig.caBundle }}{{ end }}") + vwhOutput, err := testutil.Run(cmd, GinkgoWriter) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(len(vwhOutput)).To(BeNumerically(">", 10)) + } + Eventually(verifyCAInjection).Should(Succeed()) + }) + + // +kubebuilder:scaffold:e2e-webhooks-checks +}) + +// Reconciliation tests run in parallel across multiple Ginkgo processes. +// Each test creates its own namespace and gnmi-test-server instance for isolation. +var _ = Describe("Reconciliation", func() { + projectDir, err := testutil.GetProjectDir() + if err != nil { + Fail(fmt.Sprintf("Failed to get project directory: %v", err)) + } + + // Get provider filter from environment (set by Makefile) + providerFilter := os.Getenv("E2E_PROVIDER") + + testdataRoot := filepath.Join(projectDir, "test", "e2e", "testdata") + providerDirs, err := os.ReadDir(testdataRoot) + if err != nil { + Fail(fmt.Sprintf("Failed to read testdata directory: %v", err)) + } + + var testFiles []string + var providerName string + for _, providerDir := range providerDirs { + if !providerDir.IsDir() { + continue + } + providerName = providerDir.Name() + + if providerFilter != "" && providerName != providerFilter { + continue + } + + providerTestdataDir := filepath.Join(testdataRoot, providerName) + + testFiles, err = filepath.Glob(filepath.Join(providerTestdataDir, "*.txt")) + if err != nil { + Fail(fmt.Sprintf("Failed to glob testdata: %v", err)) + } + break + } + + for _, testFile := range testFiles { + testName := filepath.Base(testFile) + testName = testName[:len(testName)-4] // remove .txt + + It(fmt.Sprintf("should reconcile %s/%s", providerName, testName), func(ctx SpecContext) { + By("parsing testdata file") + a, err := txtar.ParseFile(testFile) + Expect(err).NotTo(HaveOccurred(), "Failed to parse test file: %s", testFile) + + var state, preload []byte + var resources []txtar.File + for _, f := range a.Files { + switch f.Name { + case "state/expect": + state = f.Data + case "state/preload": + preload = f.Data + default: + resources = append(resources, f) + } + } + Expect(state).NotTo(BeEmpty(), "Expected '-- state/expect --' section in testdata") + Expect(resources).NotTo(BeEmpty(), "Expected at least one resource in testdata") + + By("creating test namespace") + testNamespace := fmt.Sprintf("test-%s-%s-%s", providerName, strings.ReplaceAll(testName, "_", "-"), time.Now().Format("20060102150405")) + // Truncate to 63 chars max (K8s namespace limit) + if len(testNamespace) > 63 { + testNamespace = testNamespace[:63] + } + Expect(testEnv.CreateNamespace(ctx, testNamespace)).NotTo(HaveOccurred(), "Failed to create test namespace") + + DeferCleanup(func(ctx SpecContext) { + // Clean up test resources before deleting the gnmi-test-server pod to avoid issues with finalizers that require API access. + By("deleting test resources") + testEnv.DeleteCustomResources(ctx, testNamespace) + By("deleting test namespace") + testEnv.DeleteNamespace(ctx, testNamespace) + }) + + deviceName := fmt.Sprintf("test-device-%d", time.Now().UnixNano()) + + By("deploying a gnmi-test-server instance for this test") + gnmiAddr, err := testEnv.DeployGNMIServer(ctx, testNamespace) + Expect(err).NotTo(HaveOccurred(), "Failed to deploy gnmi-test-server") + Expect(gnmiAddr).ToNot(BeNil()) + + By("preloading gNMI state if specified") + if len(preload) > 0 { + err = testEnv.PreloadGNMIState(ctx, testNamespace, preload) + Expect(err).NotTo(HaveOccurred(), "Failed to preload gNMI state") + } + + By("creating a test device") + device := fmt.Sprintf(` +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Device +metadata: + name: %s + namespace: %s + labels: + %s: "" +spec: + endpoint: + address: "%s"`, deviceName, testNamespace, testutil.E2ETestLabel, gnmiAddr.String()) + err = testutil.Apply(ctx, device, GinkgoWriter) + Expect(err).NotTo(HaveOccurred(), "Failed to apply Device") + + By("applying resources from testdata") + _, _ = fmt.Fprintf(GinkgoWriter, "DEBUG: Found %d resources to apply\n", len(resources)) + for _, res := range resources { + _, _ = fmt.Fprintf(GinkgoWriter, "DEBUG: Applying resource: %s\n", res.Name) + err = testutil.ApplyWithPatch(ctx, string(res.Data), testNamespace, deviceName, GinkgoWriter) + Expect(err).NotTo(HaveOccurred(), "Failed to apply resource: %s", res.Name) + } + + By("waiting for resources to be configured") + for _, res := range resources { + // Extract actual kind/name from YAML since section name may differ from metadata.name + resourceID, err := testutil.ExtractResourceIdentifier(string(res.Data)) + Expect(err).NotTo(HaveOccurred(), "Failed to extract resource identifier from: %s", res.Name) + err = testutil.WaitForCondition(ctx, resourceID, testNamespace, GinkgoWriter) + Expect(err).NotTo(HaveOccurred(), "Resource not configured: %s", resourceID) + } + + By("verifying gNMI state matches expected JSON") + gnmiState, err := testEnv.GetGNMIState(ctx, testNamespace) + Expect(err).NotTo(HaveOccurred(), "Failed to get gNMI state") + + err = testutil.CompareJSON(string(gnmiState), string(state)) + Expect(err).NotTo(HaveOccurred(), "gNMI state does not match expected JSON") + }) + } +}) + +// serviceAccountToken returns a token for the specified service account in the given namespace. +// It uses the Kubernetes TokenRequest API to generate a token by directly sending a request +// and parsing the resulting token from the API response. +func serviceAccountToken(ctx context.Context) (string, error) { + // #nosec G101 + const tokenRequestRawString = `{ + "apiVersion": "authentication.k8s.io/v1", + "kind": "TokenRequest" + }` + + // Temporary file to store the token request + secretName := serviceAccountName + "-token-request" + tokenRequestFile := filepath.Join(os.TempDir(), secretName) + if err := os.WriteFile(tokenRequestFile, []byte(tokenRequestRawString), os.FileMode(0o644)); err != nil { + return "", err + } + + var out string + verifyTokenCreation := func(g Gomega) { + // Execute kubectl command to create the token + // #nosec G204 + cmd := exec.CommandContext(ctx, "kubectl", "create", "--raw", fmt.Sprintf("/api/v1/namespaces/%s/serviceaccounts/%s/token", namespace, serviceAccountName), "-f", tokenRequestFile) + output, err := cmd.CombinedOutput() + g.Expect(err).NotTo(HaveOccurred()) + + // Parse the JSON output to extract the token + var token tokenRequest + err = json.Unmarshal(output, &token) + g.Expect(err).NotTo(HaveOccurred()) + + out = token.Status.Token + } + Eventually(verifyTokenCreation).Should(Succeed()) + + return out, nil +} + +// getMetricsOutput retrieves and returns the logs from the curl pod used to access the metrics endpoint. +func getMetricsOutput(ctx context.Context) string { + By("getting the curl-metrics logs") + cmd := exec.CommandContext(ctx, "kubectl", "logs", "curl-metrics", "-n", namespace) + metricsOutput, err := testutil.Run(cmd, GinkgoWriter) + Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod") + Expect(metricsOutput).To(ContainSubstring("< HTTP/1.1 200 OK")) + return metricsOutput +} + +// tokenRequest is a simplified representation of the Kubernetes TokenRequest API response, +// containing only the token field that we need to extract. +type tokenRequest struct { + Status struct { + Token string `json:"token"` + } `json:"status"` +} diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go deleted file mode 100644 index e583ec9cb..000000000 --- a/test/e2e/e2e_test.go +++ /dev/null @@ -1,446 +0,0 @@ -// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors -// SPDX-License-Identifier: Apache-2.0 - -package e2e - -import ( - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "golang.org/x/tools/txtar" -) - -// namespace where the project is deployed in -const namespace = "network-operator-system" - -// serviceAccountName created for the project -const serviceAccountName = "network-operator-controller-manager" - -// metricsServiceName is the name of the metrics service of the project -const metricsServiceName = "network-operator-controller-manager-metrics-service" - -// metricsRoleBindingName is the name of the RBAC that will be created to allow get the metrics data -const metricsRoleBindingName = "network-operator-metrics-binding" - -var _ = Describe("Manager", Ordered, func() { - var controllerPodName string - var gnmiServerIPAddr string - - // Before running the tests, set up the environment by creating the namespace, - // enforce the restricted security policy to the namespace, installing CRDs, - // and deploying the controller. - BeforeAll(func(ctx SpecContext) { - By("deploying the gnmi-test-server") - cmd := exec.CommandContext( - ctx, "kubectl", "run", "gnmi-test-server", - "--image", serverImage, - "--image-pull-policy", "Never", - "--namespace", "default", - "--restart", "Never", - "--port", "8000", - "--port", "9339", - ) - _, err := Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to deploy the gnmi-test-server") - - cmd = exec.CommandContext( - ctx, "kubectl", "wait", "pods/gnmi-test-server", - "--for", "condition=Ready", - "--namespace", "default", - "--timeout", "1m", - ) - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred()) - - cmd = exec.CommandContext( - ctx, "kubectl", "get", "pod", "gnmi-test-server", - "--output", "jsonpath='{.status.podIP}'", - "--namespace", "default", - ) - out, err := Run(cmd) - Expect(err).NotTo(HaveOccurred()) - gnmiServerIPAddr = strings.ReplaceAll(strings.TrimSpace(out), "'", "") - - By("creating manager namespace") - cmd = exec.CommandContext(ctx, "kubectl", "create", "ns", namespace) - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to create namespace") - - By("labeling the namespace to enforce the restricted security policy") - cmd = exec.CommandContext(ctx, "kubectl", "label", "--overwrite", "ns", namespace, "pod-security.kubernetes.io/enforce=restricted") - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to label namespace with restricted policy") - - By("installing CRDs") - cmd = exec.CommandContext(ctx, "make", "deploy-crds") - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to install CRDs") - - By("deploying the controller-manager") - cmd = exec.CommandContext(ctx, "make", "deploy") - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to deploy the controller-manager") - }) - - // After all tests have been executed, clean up by undeploying the controller, uninstalling CRDs, - // and deleting the namespace. - AfterAll(func(ctx SpecContext) { - By("cleaning up the ClusterRoleBinding of the service account to allow access to metrics") - cmd := exec.CommandContext(ctx, "kubectl", "delete", "clusterrolebinding", metricsRoleBindingName) - _, err := Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to delete ClusterRoleBinding") - - By("cleaning up the curl pod for metrics") - cmd = exec.CommandContext(ctx, "kubectl", "delete", "pod", "curl-metrics", "-n", namespace) - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to delete curl-metrics pod") - - By("undeploying the controller-manager") - cmd = exec.CommandContext(ctx, "make", "undeploy") - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to undeploy the controller-manager") - - By("uninstalling CRDs") - cmd = exec.CommandContext(ctx, "make", "undeploy-crds") - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to uninstall CRDs") - - By("removing manager namespace") - cmd = exec.CommandContext(ctx, "kubectl", "delete", "ns", namespace, "--ignore-not-found") - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to delete namespace") - - By("cleaning up the gnmi-test-server pod") - cmd = exec.CommandContext(ctx, "kubectl", "delete", "pod", "gnmi-test-server", "-n", "default") - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to delete gnmi-test-server pod") - }) - - // After each test, check for failures and collect logs, events, - // and pod descriptions for debugging. - AfterEach(func(ctx SpecContext) { - if specReport := CurrentSpecReport(); specReport.Failed() { - By("Fetching controller manager pod logs") - cmd := exec.CommandContext(ctx, "kubectl", "logs", controllerPodName, "-n", namespace) - controllerLogs, err := Run(cmd) - if err == nil { - _, _ = fmt.Fprintf(GinkgoWriter, "Controller logs:\n %s", controllerLogs) - } else { - _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Controller logs: %s", err) - } - - By("Fetching Kubernetes events") - cmd = exec.CommandContext(ctx, "kubectl", "get", "events", "-n", namespace, "--sort-by=.lastTimestamp") - eventsOutput, err := Run(cmd) - if err == nil { - _, _ = fmt.Fprintf(GinkgoWriter, "Kubernetes events:\n%s", eventsOutput) - } else { - _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get Kubernetes events: %s", err) - } - - By("Fetching curl-metrics logs") - cmd = exec.CommandContext(ctx, "kubectl", "logs", "curl-metrics", "-n", namespace) - metricsOutput, err := Run(cmd) - if err == nil { - _, _ = fmt.Fprintf(GinkgoWriter, "Metrics logs:\n %s", metricsOutput) - } else { - _, _ = fmt.Fprintf(GinkgoWriter, "Failed to get curl-metrics logs: %s", err) - } - - By("Fetching controller manager pod description") - cmd = exec.CommandContext(ctx, "kubectl", "describe", "pod", controllerPodName, "-n", namespace) - podDescription, err := Run(cmd) - if err == nil { - fmt.Println("Pod description:\n", podDescription) - } else { - fmt.Println("Failed to describe controller pod") - } - } - }) - - SetDefaultEventuallyTimeout(2 * time.Minute) - SetDefaultEventuallyPollingInterval(time.Second) - - Context("Manager", func() { - It("should run successfully", func(ctx SpecContext) { - By("validating that the controller-manager pod is running as expected") - verifyControllerUp := func(g Gomega) { - // Get the name of the controller-manager pod - cmd := exec.CommandContext( - ctx, "kubectl", "get", - "pods", "-l", "control-plane=controller-manager", - "-o", "go-template={{ range .items }}"+ - "{{ if not .metadata.deletionTimestamp }}"+ - "{{ .metadata.name }}"+ - "{{ \"\\n\" }}{{ end }}{{ end }}", - "-n", namespace, - ) - - podOutput, err := Run(cmd) - g.Expect(err).NotTo(HaveOccurred(), "Failed to retrieve controller-manager pod information") - podNames := GetNonEmptyLines(podOutput) - g.Expect(podNames).To(HaveLen(1), "expected 1 controller pod running") - controllerPodName = podNames[0] - g.Expect(controllerPodName).To(ContainSubstring("controller-manager")) - - // Validate the pod's status - cmd = exec.CommandContext(ctx, "kubectl", "get", "pods", controllerPodName, "-o", "jsonpath={.status.phase}", "-n", namespace) - output, err := Run(cmd) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(output).To(Equal("Running"), "Incorrect controller-manager pod status") - } - Eventually(verifyControllerUp).Should(Succeed()) - }) - - It("should ensure the metrics endpoint is serving metrics", func(ctx SpecContext) { - By("creating a ClusterRoleBinding for the service account to allow access to metrics") - // #nosec G204 - cmd := exec.CommandContext(ctx, "kubectl", "create", "clusterrolebinding", metricsRoleBindingName, "--clusterrole=network-operator-metrics-reader", fmt.Sprintf("--serviceaccount=%s:%s", namespace, serviceAccountName)) - _, err := Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to create ClusterRoleBinding") - - By("validating that the metrics service is available") - cmd = exec.CommandContext(ctx, "kubectl", "get", "service", metricsServiceName, "-n", namespace) - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Metrics service should exist") - - By("validating that the ServiceMonitor for Prometheus is applied in the namespace") - cmd = exec.CommandContext(ctx, "kubectl", "get", "ServiceMonitor", "-n", namespace) - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "ServiceMonitor should exist") - - By("getting the service account token") - token, err := serviceAccountToken(ctx) - Expect(err).NotTo(HaveOccurred()) - Expect(token).NotTo(BeEmpty()) - - By("waiting for the metrics endpoint to be ready") - verifyMetricsEndpointReady := func(g Gomega) { - kcmd := exec.CommandContext(ctx, "kubectl", "get", "endpoints", metricsServiceName, "-n", namespace) - output, kErr := Run(kcmd) - g.Expect(kErr).NotTo(HaveOccurred()) - g.Expect(output).To(ContainSubstring("8443"), "Metrics endpoint is not ready") - } - Eventually(verifyMetricsEndpointReady).Should(Succeed()) - - By("verifying that the controller manager is serving the metrics server") - verifyMetricsServerStarted := func(g Gomega) { - kcmd := exec.CommandContext(ctx, "kubectl", "logs", controllerPodName, "-n", namespace) - output, kErr := Run(kcmd) - g.Expect(kErr).NotTo(HaveOccurred()) - g.Expect(output).To(ContainSubstring("controller-runtime.metrics\tServing metrics server"), "Metrics server not yet started") - } - Eventually(verifyMetricsServerStarted).Should(Succeed()) - - By("creating the curl-metrics pod to access the metrics endpoint") - // #nosec G204 - cmd = exec.CommandContext(ctx, "kubectl", "run", "curl-metrics", "--restart=Never", - "--namespace", namespace, - "--image=curlimages/curl:latest", - "--overrides", - fmt.Sprintf(`{ - "spec": { - "containers": [{ - "name": "curl", - "image": "curlimages/curl:latest", - "command": ["/bin/sh", "-c"], - "args": ["curl -v -k -H 'Authorization: Bearer %s' https://%s.%s.svc.cluster.local:8443/metrics"], - "securityContext": { - "allowPrivilegeEscalation": false, - "capabilities": { - "drop": ["ALL"] - }, - "runAsNonRoot": true, - "runAsUser": 1000, - "seccompProfile": { - "type": "RuntimeDefault" - } - } - }], - "serviceAccount": "%s" - } - }`, token, metricsServiceName, namespace, serviceAccountName)) - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to create curl-metrics pod") - - By("waiting for the curl-metrics pod to complete.") - verifyCurlUp := func(g Gomega) { - cmd := exec.CommandContext(ctx, "kubectl", "get", "pods", "curl-metrics", "-o", "jsonpath={.status.phase}", "-n", namespace) - output, err := Run(cmd) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(output).To(Equal("Succeeded"), "curl pod in wrong status") - } - Eventually(verifyCurlUp, 5*time.Minute).Should(Succeed()) - - By("getting the metrics by checking curl-metrics logs") - metricsOutput := getMetricsOutput(ctx) - Expect(metricsOutput).To(ContainSubstring("controller_runtime_webhook_panics_total")) - }) - - It("should provisioned cert-manager", func(ctx SpecContext) { - By("validating that cert-manager has the certificate Secret") - verifyCertManager := func(g Gomega) { - cmd := exec.CommandContext(ctx, "kubectl", "get", "secrets", "webhook-server-cert", "-n", namespace) - _, err := Run(cmd) - g.Expect(err).NotTo(HaveOccurred()) - } - Eventually(verifyCertManager).Should(Succeed()) - }) - - It("should have CA injection for validating webhooks", func(ctx SpecContext) { - By("checking CA injection for validating webhooks") - verifyCAInjection := func(g Gomega) { - cmd := exec.CommandContext(ctx, "kubectl", "get", - "validatingwebhookconfigurations.admissionregistration.k8s.io", - "network-operator-validating-webhook-configuration", - "-o", "go-template={{ range .webhooks }}{{ .clientConfig.caBundle }}{{ end }}") - vwhOutput, err := Run(cmd) - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(len(vwhOutput)).To(BeNumerically(">", 10)) - } - Eventually(verifyCAInjection).Should(Succeed()) - }) - - // +kubebuilder:scaffold:e2e-webhooks-checks - - // TODO: Customize the e2e test suite with scenarios specific to your project. - // Consider applying sample/CR(s) and check their status and/or verifying - // the reconciliation by using the metrics, i.e.: - // metricsOutput := getMetricsOutput() - // Expect(metricsOutput).To(ContainSubstring( - // fmt.Sprintf(`controller_runtime_reconcile_total{controller="%s",result="success"} 1`, - // strings.ToLower(), - // )) - - DescribeTable( - "Should reconcile the api objects", - func(ctx SpecContext, file string) { - device := ` -apiVersion: networking.metal.ironcore.dev/v1alpha1 -kind: Device -metadata: - name: device - namespace: default -spec: - endpoint: - address: "%s"` - err := Apply(ctx, fmt.Sprintf(device, gnmiServerIPAddr+":9339")) - Expect(err).NotTo(HaveOccurred(), "Failed to apply Device") - - dir, err := GetProjectDir() - Expect(err).NotTo(HaveOccurred(), "Failed to get project directory") - - a, err := txtar.ParseFile(filepath.Join(dir, "test", "e2e", "testdata", file)) - Expect(err).NotTo(HaveOccurred(), "Failed to parse test file") - Expect(a.Files).To(HaveLen(2), "Expected 2 files in the test archive") - - err = Apply(ctx, string(a.Files[0].Data)) - Expect(err).NotTo(HaveOccurred(), "Failed to apply Interface") - - // #nosec G204 - cmd := exec.CommandContext( - ctx, "kubectl", "wait", a.Files[0].Name, - "--for", "condition=Configured", - "--namespace", "default", - "--timeout", "5m", - ) - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred()) - - cmd = exec.CommandContext( - ctx, "kubectl", "exec", "gnmi-test-server", - "--namespace", "default", - "--", - "wget", "-qO-", "http://localhost:8000/v1/state", - ) - got, err := Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to execute command on gnmi-test-server") - - err = CompareJSON(got, string(a.Files[1].Data)) - Expect(err).NotTo(HaveOccurred(), "State output does not match expected JSON") - - // #nosec G204 - cmd = exec.CommandContext(ctx, "kubectl", "delete", a.Files[0].Name) - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to delete object") - - cmd = exec.CommandContext(ctx, "kubectl", "delete", "devices/device") - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to delete object") - - cmd = exec.CommandContext( - ctx, "kubectl", "exec", "gnmi-test-server", - "--namespace", "default", - "--", - "wget", "-qO-", "--header='X-HTTP-Method-Override: DELETE'", "http://localhost:8000/v1/state", - ) - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to execute command on gnmi-test-server") - }, - Entry("Loopback Interface", "interface.txt"), - ) - }) -}) - -// serviceAccountToken returns a token for the specified service account in the given namespace. -// It uses the Kubernetes TokenRequest API to generate a token by directly sending a request -// and parsing the resulting token from the API response. -func serviceAccountToken(ctx context.Context) (string, error) { - // #nosec G101 - const tokenRequestRawString = `{ - "apiVersion": "authentication.k8s.io/v1", - "kind": "TokenRequest" - }` - - // Temporary file to store the token request - secretName := serviceAccountName + "-token-request" - tokenRequestFile := filepath.Join(os.TempDir(), secretName) - if err := os.WriteFile(tokenRequestFile, []byte(tokenRequestRawString), os.FileMode(0o644)); err != nil { - return "", err - } - - var out string - verifyTokenCreation := func(g Gomega) { - // Execute kubectl command to create the token - // #nosec G204 - cmd := exec.CommandContext(ctx, "kubectl", "create", "--raw", fmt.Sprintf("/api/v1/namespaces/%s/serviceaccounts/%s/token", namespace, serviceAccountName), "-f", tokenRequestFile) - output, err := cmd.CombinedOutput() - g.Expect(err).NotTo(HaveOccurred()) - - // Parse the JSON output to extract the token - var token tokenRequest - err = json.Unmarshal(output, &token) - g.Expect(err).NotTo(HaveOccurred()) - - out = token.Status.Token - } - Eventually(verifyTokenCreation).Should(Succeed()) - - return out, nil -} - -// getMetricsOutput retrieves and returns the logs from the curl pod used to access the metrics endpoint. -func getMetricsOutput(ctx context.Context) string { - By("getting the curl-metrics logs") - cmd := exec.CommandContext(ctx, "kubectl", "logs", "curl-metrics", "-n", namespace) - metricsOutput, err := Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to retrieve logs from curl pod") - Expect(metricsOutput).To(ContainSubstring("< HTTP/1.1 200 OK")) - return metricsOutput -} - -// tokenRequest is a simplified representation of the Kubernetes TokenRequest API response, -// containing only the token field that we need to extract. -type tokenRequest struct { - Status struct { - Token string `json:"token"` - } `json:"status"` -} diff --git a/test/e2e/envtest_suite_test.go b/test/e2e/envtest_suite_test.go new file mode 100644 index 000000000..81138d619 --- /dev/null +++ b/test/e2e/envtest_suite_test.go @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build envtest + +package e2e + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" +) + +// BeforeSuite initializes the envtest environment. +// Envtest runs in-process, so no special parallel handling is needed. +var _ = BeforeSuite(func(ctx SpecContext) { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + SetDefaultEventuallyTimeout(30 * time.Second) + SetDefaultEventuallyPollingInterval(time.Second) + + initTestEnv(ctx) +}) + +// AfterSuite cleans up the envtest environment. +var _ = AfterSuite(func(ctx SpecContext) { + // Perform cleanup (will run only once even if called from signal handler) + performCleanup() + + // Run mode-specific cleanup + cleanupTestEnv(ctx) +}) diff --git a/test/e2e/envtest_test.go b/test/e2e/envtest_test.go new file mode 100644 index 000000000..e56e7f12e --- /dev/null +++ b/test/e2e/envtest_test.go @@ -0,0 +1,626 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build envtest + +package e2e + +import ( + "context" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "golang.org/x/tools/txtar" + + corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/events" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/cache" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" + + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + nx "github.com/ironcore-dev/network-operator/internal/controller/cisco/nx" + "github.com/ironcore-dev/network-operator/internal/controller/core" + "github.com/ironcore-dev/network-operator/internal/resourcelock" + "github.com/ironcore-dev/network-operator/test/e2e/testutil" +) + +// reconcileTestNamespacePrefix is used with GenerateName to create unique test namespaces. +// This isolates tests from other resources in the cluster (e.g., from the deployed operator). +const reconcileTestNamespacePrefix = "reconcile-test-" + +// testEnv is the envtest test environment. +var testEnv *testutil.EnvtestEnvironment + +func init() { + _, _ = fmt.Fprintf(GinkgoWriter, "Starting network-operator tests in ENVTEST mode\n") +} + +// initTestEnv initializes the envtest environment. +func initTestEnv(ctx SpecContext) { + By("initializing envtest environment") + testEnv = testutil.NewEnvtestEnvironment() + Expect(testEnv.Setup(ctx)).To(Succeed()) +} + +// cleanupTestEnv performs envtest-specific cleanup (none needed). +func cleanupTestEnv(_ SpecContext) { + // No additional cleanup needed for envtest +} + +// ============================================================================ +// Provider test helpers +// ============================================================================ + +// ProviderTestContext holds the context for a provider-specific test run. +type ProviderTestContext struct { + Provider testutil.ProviderType + Manager ctrl.Manager + Locker *resourcelock.ResourceLocker + Namespace string + CancelFunc context.CancelFunc +} + +// SetupProviderTest creates a new manager with controllers for the given provider. +// The manager only watches the specified namespace to avoid conflicts with other controllers. +// Call the returned cleanup function in AfterEach/AfterAll. +func SetupProviderTest(providerCfg testutil.ProviderConfig, k8sClient client.Client, restCfg *rest.Config, namespace string) *ProviderTestContext { + GinkgoHelper() + + providerCtx, providerCancel := context.WithCancel(context.Background()) //nolint:gosec // cancel stored in ProviderTestContext + + mgr, err := ctrl.NewManager(restCfg, ctrl.Options{ + Scheme: k8sClient.Scheme(), + Logger: GinkgoLogr, + Cache: cache.Options{ + DefaultNamespaces: map[string]cache.Config{ + namespace: {}, + }, + }, + }) + Expect(err).ToNot(HaveOccurred()) + + // Ignore events during tests + recorder := events.NewFakeRecorder(0) + go func() { + for range recorder.Events { //nolint:revive // intentionally drain events + } + }() + + locker, err := resourcelock.NewResourceLocker(mgr.GetClient(), namespace, 15*time.Second, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + err = mgr.Add(locker) + Expect(err).NotTo(HaveOccurred()) + + providerFunc := providerCfg.NewProvider + + // Register all controllers + registerControllers(providerCtx, mgr, recorder, providerFunc, locker) + + go func() { + defer GinkgoRecover() + err = mgr.Start(providerCtx) + if providerCtx.Err() == nil { + Expect(err).ToNot(HaveOccurred(), "failed to run manager") + } + }() + + return &ProviderTestContext{ + Provider: providerCfg.Name, + Manager: mgr, + Locker: locker, + Namespace: namespace, + CancelFunc: providerCancel, + } +} + +// TeardownProviderTest stops the manager for a provider test. +func TeardownProviderTest(ptc *ProviderTestContext) { + if ptc != nil && ptc.CancelFunc != nil { + ptc.CancelFunc() + } +} + +// registerControllers registers all controllers with the manager. +func registerControllers(ctx context.Context, mgr ctrl.Manager, recorder *events.FakeRecorder, providerFunc testutil.ProviderFactory, locker *resourcelock.ResourceLocker) { + var err error + + err = (&core.PrefixSetReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.RoutingPolicyReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.InterfaceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.VLANReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.VRFReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.NTPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.DNSReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.LLDPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.BannerReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.OSPFReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.PIMReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.NetworkVirtualizationEdgeReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.EVPNInstanceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + // NX-OS specific controllers + err = (&nx.VPCDomainReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.BGPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.BGPPeerReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.SyslogReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.SNMPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.ManagementAccessReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.AccessControlListReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.DHCPRelayReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.ISISReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFunc, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) +} + +// CreateTestDevice creates a Device pointing to the gNMI server with a generated name. +func CreateTestDevice(ctx context.Context, c client.Client, gnmiAddr, namespace string) (*v1alpha1.Device, error) { + device := &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-device-", + Namespace: namespace, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: gnmiAddr, + }, + }, + } + if err := c.Create(ctx, device); err != nil { + return nil, err + } + + // Set the device status to Running so that dependent resources can reconcile + device.Status.Phase = v1alpha1.DevicePhaseRunning + if err := c.Status().Update(ctx, device); err != nil { + return nil, err + } + + return device, nil +} + +// ============================================================================ +// Reconciliation tests +// ============================================================================ + +var _ = Describe("gNMI requests tests", func() { + // Resolve provider during tree construction so we can generate individual It nodes + projectDir, err := testutil.GetProjectDir() + if err != nil { + Fail(fmt.Sprintf("Failed to get project directory: %v", err)) + } + + provider := os.Getenv("E2E_PROVIDER") + providerNames := make([]string, len(testutil.SupportedProviders)) + for i, cfg := range testutil.SupportedProviders { + providerNames[i] = string(cfg.Name) + } + + // If provider is invalid, create a failing test with clear message + if provider == "" { + It("requires E2E_PROVIDER to be set", func() { + Fail(fmt.Sprintf("E2E_PROVIDER not set. Please set E2E_PROVIDER to one of: %s", strings.Join(providerNames, ", "))) + }) + return + } + + providerIdx := slices.IndexFunc(testutil.SupportedProviders, func(cfg testutil.ProviderConfig) bool { + return string(cfg.Name) == provider + }) + if providerIdx < 0 { + It("requires valid E2E_PROVIDER", func() { + Fail(fmt.Sprintf("E2E_PROVIDER=%q is not a supported provider. Valid values: %s", provider, strings.Join(providerNames, ", "))) + }) + return + } + + providerCfg := testutil.SupportedProviders[providerIdx] + testdataDir := filepath.Join(projectDir, "test", "e2e", "testdata", string(providerCfg.Name)) + + if _, err := os.Stat(testdataDir); os.IsNotExist(err) { + It("requires testdata directory", func() { + Fail(fmt.Sprintf("Testdata directory does not exist for provider %q: %s", provider, testdataDir)) + }) + return + } + + // Discover test files during tree construction + testFiles, err := filepath.Glob(filepath.Join(testdataDir, "*.txt")) + if err != nil || len(testFiles) == 0 { + It("requires test files", func() { + if err != nil { + Fail(fmt.Sprintf("Failed to glob testdata: %v", err)) + } + Fail(fmt.Sprintf("No test files found in %s", testdataDir)) + }) + return + } + + Describe(fmt.Sprintf("Provider: %s", providerCfg.Name), Ordered, func() { + var ptc *ProviderTestContext + var device *v1alpha1.Device + var testNamespace string + + BeforeAll(func(ctx SpecContext) { + By("creating dedicated test namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: reconcileTestNamespacePrefix, + }, + } + Expect(testEnv.Client().Create(ctx, ns)).To(Succeed()) + testNamespace = ns.Name + + By(fmt.Sprintf("setting up %s provider", providerCfg.Name)) + ptc = SetupProviderTest(providerCfg, testEnv.Client(), testEnv.RESTConfig(), testNamespace) + }) + + AfterAll(func(ctx SpecContext) { + By(fmt.Sprintf("tearing down %s provider manager", providerCfg.Name)) + TeardownProviderTest(ptc) + + By("deleting test namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: testNamespace, + }, + } + _ = testEnv.Client().Delete(ctx, ns) + }) + + AfterEach(func(ctx SpecContext) { + By("cleaning up resources") + cleanupAllResources(testEnv.Client(), testNamespace) + + if device == nil { + return + } + + By("deleting test device") + Expect(client.IgnoreNotFound(testEnv.Client().Delete(ctx, device))).To(Succeed()) + Eventually(func(g Gomega) { + err := testEnv.Client().Get(ctx, client.ObjectKeyFromObject(device), &v1alpha1.Device{}) + g.Expect(client.IgnoreNotFound(err)).To(Succeed()) + g.Expect(err).To(HaveOccurred(), "Device should be deleted") + }).Should(Succeed()) + device = nil + + By("clearing gNMI state for next test") + Expect(testEnv.ClearGNMIState(ctx)).To(Succeed()) + }) + + // Generate individual It nodes for each test file + for _, testFile := range testFiles { + testFile := testFile // capture for closure + testName := filepath.Base(testFile) + testName = testName[:len(testName)-4] // remove .txt + + It("should reconcile "+testName, func(ctx SpecContext) { + By("parsing testdata file") + a, err := txtar.ParseFile(testFile) + Expect(err).NotTo(HaveOccurred(), "Failed to parse test file: %s", testFile) + Expect(len(a.Files)).To(BeNumerically(">=", 2), "Expected at least 2 files (resource(s) and state)") + + var state, preload []byte + var resources []txtar.File + for _, f := range a.Files { + switch f.Name { + case "state/expect": + state = f.Data + case "state/preload": + preload = f.Data + default: + resources = append(resources, f) + } + } + Expect(state).NotTo(BeEmpty(), "Expected '-- state/expect --' section in testdata") + Expect(resources).NotTo(BeEmpty(), "Expected at least one resource in testdata") + + // Preload gNMI state BEFORE creating Device (e.g., bootTime for Device controller) + if len(preload) > 0 { + By("preloading gNMI state") + Expect(testEnv.PreloadGNMIState(ctx, preload)).To(Succeed(), "Failed to preload gNMI state") + } + + By("creating test device") + device, err = testutil.CreateTestDevice(ctx, testEnv.Client(), testEnv.GNMIAddress(), testNamespace) + Expect(err).NotTo(HaveOccurred()) + + By(fmt.Sprintf("creating %d resource(s) from testdata", len(resources))) + for _, res := range resources { + obj := createResourceFromTxtar(ctx, testEnv.Client(), res, device.Name, testNamespace) + waitForResource(ctx, testEnv.Client(), obj) + } + + By("verifying gNMI state matches expected JSON") + gnmiState, err := testEnv.GetGNMIState(ctx) + Expect(err).NotTo(HaveOccurred()) + + err = testutil.CompareJSON(string(gnmiState), string(state)) + Expect(err).NotTo(HaveOccurred(), "gNMI state does not match expected JSON") + }) + } + }) +}) + +// createResourceFromTxtar creates a K8s resource from txtar file data. +// The file name format is "kind/name" (e.g., "prefixset/my-prefixset"). +// It substitutes "device" in deviceRef.name with the actual device name. +func createResourceFromTxtar(ctx SpecContext, c client.Client, res txtar.File, deviceName, namespace string) client.Object { + obj := &unstructured.Unstructured{} + Expect(yaml.Unmarshal(res.Data, obj)).To(Succeed(), "Failed to unmarshal %s", res.Name) + + // Set the namespace + obj.SetNamespace(namespace) + + // Update deviceRef.name to use the actual device name + if spec, ok := obj.Object["spec"].(map[string]any); ok { + if deviceRef, ok := spec["deviceRef"].(map[string]any); ok { + deviceRef["name"] = deviceName + } + } + // Also update the device label + labels := obj.GetLabels() + if labels != nil { + if _, ok := labels[v1alpha1.DeviceLabel]; ok { + labels[v1alpha1.DeviceLabel] = deviceName + obj.SetLabels(labels) + } + } + + Expect(c.Create(ctx, obj)).To(Succeed(), "Failed to create %s", res.Name) + return obj +} + +// waitForResource waits for a resource to be in Ready=True or Configured=True. +// If Configured condition exists, it checks it, otherwise it falls back to Ready condition. +// Skips config-only --controller-less-- resources that don't have status conditions (e.g., InterfaceConfig). +func waitForResource(ctx SpecContext, c client.Client, obj client.Object) { + key := client.ObjectKeyFromObject(obj) + gvk := obj.GetObjectKind().GroupVersionKind() + + // Add as needed. + switch gvk.Kind { + case "InterfaceConfig", "LLDPConfig", "BGPConfig", "NVEConfig", "ManagementAccessConfig": + return + } + + Eventually(func(g Gomega) { + r := &unstructured.Unstructured{} + r.SetGroupVersionKind(gvk) + g.Expect(c.Get(ctx, key, r)).To(Succeed()) + + conditions, err := testutil.ExtractConditions(r) + g.Expect(err).NotTo(HaveOccurred()) + + conditionToCheck := string(v1alpha1.ReadyCondition) + if apimeta.FindStatusCondition(conditions, string(v1alpha1.ConfiguredCondition)) != nil { + conditionToCheck = string(v1alpha1.ConfiguredCondition) + } + + g.Expect(apimeta.IsStatusConditionTrue(conditions, conditionToCheck)).To(BeTrue()) + }).Should(Succeed()) +} + +// cleanupAllResources deletes all test resources and lets the controller handle finalizer cleanup. +// Uses a background context with timeout to ensure cleanup completes even on interrupt. +func cleanupAllResources(c client.Client, namespace string) { + // Use background context with timeout - cleanup must complete even on Ctrl+C + cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + for _, gvk := range slices.Backward(testutil.ResourceRegistry) { + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(schema.GroupVersionKind{ + Group: gvk.Group, + Version: gvk.Version, + Kind: gvk.Kind + "List", + }) + + if err := c.List(cleanupCtx, list, client.InNamespace(namespace)); err != nil { + if apimeta.IsNoMatchError(err) { + continue // CRD not installed, skip + } + Expect(err).NotTo(HaveOccurred(), "Failed to list %s", gvk.Kind) + } + + // Delete all resources - controller will handle finalizer removal + for _, item := range list.Items { + Expect(client.IgnoreNotFound(c.Delete(cleanupCtx, &item))).To(Succeed()) + } + } +} diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/suite_test.go similarity index 51% rename from test/e2e/e2e_suite_test.go rename to test/e2e/suite_test.go index 3702bb802..2b5a71afa 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/suite_test.go @@ -4,13 +4,20 @@ package e2e import ( + "context" "fmt" "os" "os/exec" + "os/signal" + "sync" + "syscall" "testing" + "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + + "github.com/ironcore-dev/network-operator/test/e2e/testutil" ) var ( @@ -34,76 +41,115 @@ const image = "ghcr.io/ironcore-dev/network-operator:latest" // with the gNMI test server. const serverImage = "ghcr.io/ironcore-dev/gnmi-test-server:latest" -// TestE2E runs the end-to-end (e2e) test suite for the project. These tests execute in an isolated, -// temporary environment to validate project changes with the purposed to be used in CI jobs. -// The default setup requires Kind, builds/loads the Manager Docker image locally, and installs -// CertManager and Prometheus Operator. -func TestE2E(t *testing.T) { - RegisterFailHandler(Fail) - _, _ = fmt.Fprintf(GinkgoWriter, "Starting network-operator integration test suite\n") - RunSpecs(t, "e2e suite") -} +var ( + cleanupOnce sync.Once + cleanupCtx context.Context + cleanupDone = make(chan struct{}) +) -var _ = BeforeSuite(func(ctx SpecContext) { - By("Ensure that Prometheus is enabled") - cwd, err := GetProjectDir() - Expect(err).NotTo(HaveOccurred(), "Failed to get project directory") +// TestE2E runs the end-to-end (e2e) test suite for the project. +// +// Build with -tags=envtest to run in envtest mode (fast, in-process controllers). +// Build without tags to run in cluster mode (requires Kind cluster). +func TestE2E(t *testing.T) { + // Setup signal handler to ensure cleanup on interrupt + ctx, cancel := context.WithCancel(context.Background()) + cleanupCtx = ctx - err = UncommentCode(cwd+"/config/default/kustomization.yaml", "#- ../prometheus", "#") - Expect(err).NotTo(HaveOccurred(), "Failed to enable Prometheus") + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) - By("building the manager(Operator) image") - cmd := exec.CommandContext(ctx, "make", "docker-build", "IMG="+image) - _, err = Run(cmd) - ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the manager(Operator) image") + go func() { + <-sigChan + fmt.Fprintf(os.Stderr, "\n\nReceived interrupt signal, cleaning up test environment...\n") + performCleanup() + cancel() + close(cleanupDone) + os.Exit(1) + }() - By("loading the manager(Operator) image on Kind") - err = LoadImageToKindClusterWithName(ctx, image) - ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load the manager(Operator) image into Kind") + RegisterFailHandler(Fail) + RunSpecs(t, "e2e suite") +} - By("building the gnmi-test-server image") - cmd = exec.CommandContext(ctx, "make", "docker-build-test-gnmi-server", "TEST_SERVER_IMG="+serverImage) - _, err = Run(cmd) - ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the gnmi-test-server image") +// performCleanup ensures testEnv.Teardown is called exactly once +func performCleanup() { + cleanupOnce.Do(func() { + if testEnv != nil { + fmt.Fprintf(os.Stderr, "Tearing down test environment...\n") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + if err := testEnv.Teardown(ctx); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to teardown test environment: %v\n", err) + } + } + }) +} - By("loading the gnmi-test-server image on Kind") - err = LoadImageToKindClusterWithName(ctx, serverImage) - ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load the gnmi-test-server image into Kind") +// BeforeSuite and AfterSuite are defined in mode-specific files: +// - envtest_test.go (build tag: envtest) - simple BeforeSuite/AfterSuite +// - cluster_suite_test.go (build tag: !envtest) - SynchronizedBeforeSuite/AfterSuite for parallel execution - // The tests-e2e are intended to run on a temporary cluster that is created and destroyed for testing. - // To prevent errors when tests run in environments with Prometheus or CertManager already installed, - // we check for their presence before execution. - // Setup Prometheus and CertManager before the suite if not skipped and if not already installed +// setupClusterDependencies installs Prometheus and CertManager if needed. +// Called by cluster_suite_test.go. +func setupClusterDependencies(ctx SpecContext) { if !skipPrometheusInstall { By("checking if prometheus is installed already") - isPrometheusOperatorAlreadyInstalled = IsPrometheusCRDsInstalled(ctx) + isPrometheusOperatorAlreadyInstalled = testutil.IsPrometheusCRDsInstalled(ctx, GinkgoWriter) if !isPrometheusOperatorAlreadyInstalled { _, _ = fmt.Fprintf(GinkgoWriter, "Installing Prometheus Operator...\n") - Expect(InstallPrometheusOperator(ctx)).To(Succeed(), "Failed to install Prometheus Operator") + Expect(testutil.InstallPrometheusOperator(ctx, GinkgoWriter)).To(Succeed(), "Failed to install Prometheus Operator") } else { _, _ = fmt.Fprintf(GinkgoWriter, "WARNING: Prometheus Operator is already installed. Skipping installation...\n") } } if !skipCertManagerInstall { By("checking if cert manager is installed already") - isCertManagerAlreadyInstalled = IsCertManagerCRDsInstalled(ctx) + isCertManagerAlreadyInstalled = testutil.IsCertManagerCRDsInstalled(ctx, GinkgoWriter) if !isCertManagerAlreadyInstalled { _, _ = fmt.Fprintf(GinkgoWriter, "Installing CertManager...\n") - Expect(InstallCertManager(ctx)).To(Succeed(), "Failed to install CertManager") + Expect(testutil.InstallCertManager(ctx, GinkgoWriter)).To(Succeed(), "Failed to install CertManager") + // Fresh install - need to wait for webhook to be ready (can take up to 90s) + By("waiting for cert-manager webhook to be ready (fresh install)") + Expect(testutil.WaitForCertManagerWebhook(ctx, GinkgoWriter)).To(Succeed(), "Cert-manager webhook not ready") } else { _, _ = fmt.Fprintf(GinkgoWriter, "WARNING: CertManager is already installed. Skipping installation...\n") + // Already installed - webhook should be ready, but verify quickly } } -}) +} -var _ = AfterSuite(func(ctx SpecContext) { - // Teardown Prometheus and CertManager after the suite if not skipped and if they were not already installed +// cleanupClusterDependencies uninstalls Prometheus and CertManager if we installed them. +// Called by cluster_suite_test.go. +func cleanupClusterDependencies(ctx SpecContext) { if !skipPrometheusInstall && !isPrometheusOperatorAlreadyInstalled { _, _ = fmt.Fprintf(GinkgoWriter, "Uninstalling Prometheus Operator...\n") - UninstallPrometheusOperator(ctx) + testutil.UninstallPrometheusOperator(ctx, GinkgoWriter) } if !skipCertManagerInstall && !isCertManagerAlreadyInstalled { _, _ = fmt.Fprintf(GinkgoWriter, "Uninstalling CertManager...\n") - UninstallCertManager(ctx) + testutil.UninstallCertManager(ctx, GinkgoWriter) } -}) +} + +// buildAndLoadImages builds and loads Docker images to Kind. +// Called by cluster_suite_test.go. +func buildAndLoadImages(ctx SpecContext) { + By("building the manager(Operator) image") + cmd := exec.CommandContext(ctx, "make", "docker-build", "IMG="+image) + _, err := testutil.Run(cmd, GinkgoWriter) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the manager(Operator) image") + + By("loading the manager(Operator) image on Kind") + err = testutil.LoadImageToKindClusterWithName(ctx, image, GinkgoWriter) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load the manager(Operator) image into Kind") + + By("building the gnmi-test-server image") + cmd = exec.CommandContext(ctx, "make", "docker-build-test-gnmi-server", "TEST_SERVER_IMG="+serverImage) + _, err = testutil.Run(cmd, GinkgoWriter) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the gnmi-test-server image") + + By("loading the gnmi-test-server image on Kind") + err = testutil.LoadImageToKindClusterWithName(ctx, serverImage, GinkgoWriter) + ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to load the gnmi-test-server image into Kind") +} diff --git a/test/integration/testdata/nxos/acl.txt b/test/e2e/testdata/cisco-nxos-gnmi/acl.txt similarity index 64% rename from test/integration/testdata/nxos/acl.txt rename to test/e2e/testdata/cisco-nxos-gnmi/acl.txt index 34b7befb4..95e5a8841 100644 --- a/test/integration/testdata/nxos/acl.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/acl.txt @@ -1,22 +1,8 @@ # NX-OS Access Control List Integration Test # # Tests ACL configuration with permit/deny entries. -# -# K8s metadata.name (test-acl) differs from spec.name (BLOCK-EXTERNAL) to verify -# the provider uses spec.name for gNMI paths. -# -# Expected gNMI state mapping: -# -# acl-items/ipv4-items/name-items: AccessControlList with IPv4 entries -# ACL-list[name='BLOCK-EXTERNAL']: spec.name -> ACL name -# seq-items/ACE-list: spec.entries -> ACE entries -# - seqNum: 10/20 entry.sequence -> sequence number -# - action: permit/deny entry.action -> forwarding action -# - protocol: 0 entry.protocol=IP -> protocol number (0=IP) -# - srcPrefix/srcPrefixLength: entry.sourceAddress -> source IP/mask -# - dstPrefix: entry.destinationAddress -> destination IP -# -# Note: description field is not mapped to gNMI remarks in current provider. + + -- accesscontrollist/test-acl -- apiVersion: networking.metal.ironcore.dev/v1alpha1 @@ -44,7 +30,16 @@ spec: destinationAddress: 0.0.0.0/0 description: "Deny all" --- state -- +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} + +-- state/expect -- { "System": { "acl-items": { @@ -76,6 +71,9 @@ spec: ] } } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/banner.txt b/test/e2e/testdata/cisco-nxos-gnmi/banner.txt similarity index 74% rename from test/integration/testdata/nxos/banner.txt rename to test/e2e/testdata/cisco-nxos-gnmi/banner.txt index 0b3c7d704..239703a56 100644 --- a/test/integration/testdata/nxos/banner.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/banner.txt @@ -1,9 +1,14 @@ # NX-OS Banner Integration Test -# -# Tests login banner configuration. -# -# Expected gNMI paths: -# userext-items/preloginbanner-items - Pre-login banner message + + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} -- banner/prelogin -- apiVersion: networking.metal.ironcore.dev/v1alpha1 @@ -20,7 +25,7 @@ spec: message: inline: "Authorized users only. All activity is monitored." --- state -- +-- state/expect -- { "System": { "userext-items": { @@ -28,6 +33,9 @@ spec: "delimiter": "^", "message": "Authorized users only. All activity is monitored." } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/bgp_bgppeer.txt b/test/e2e/testdata/cisco-nxos-gnmi/bgp_bgppeer.txt similarity index 76% rename from test/integration/testdata/nxos/bgp_bgppeer.txt rename to test/e2e/testdata/cisco-nxos-gnmi/bgp_bgppeer.txt index 5872b502b..990761e8f 100644 --- a/test/integration/testdata/nxos/bgp_bgppeer.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/bgp_bgppeer.txt @@ -1,40 +1,19 @@ # NX-OS BGP and BGPPeer Integration Test # -# Tests BGP instance with L2VPN EVPN address family and a BGP peer (neighbor). -# Uses loopback interface as update-source (common for EVPN peering). -# Includes NX-OS specific BGPConfig for advertise-pip setting. -# # Resource dependency chain: # loopback-vtep -> Loopback interface (spec.name: lo0) for BGP router-id/update-source # evpn-settings -> BGPConfig (NX-OS provider config) for advertise-pip # evpn-bgp -> BGP instance with L2VPN EVPN, references BGPConfig # spine1 -> BGPPeer referencing BGP instance and loopback for localAddress -# -# Expected gNMI state mapping: -# -# fm-items/bgp-items: BGP exists -> Feature "bgp" enabled -# fm-items/evpn-items: L2VPN EVPN enabled -> Feature "evpn" enabled -# -# bgp-items/inst-items: BGP instance configuration -# - asn: 65000 spec.asNumber -> AS number -# - adminSt: enabled BGP active -# dom-items/Dom-list[name='default']: BGP VRF domain -# - rtrId: 10.0.0.1 spec.routerId -> router ID -# - af-items/DomAf-list: spec.addressFamilies -# - type: l2vpn-evpn L2VPN EVPN address family -# - advPip: enabled BGPConfig.addressFamilies.l2vpnEvpn.advertisePIP -# - retainRttAll: enabled routeTargetPolicy.retainAll=true -# - peer-items/Peer-list: BGPPeer configuration -# - addr: 10.0.0.2 spec.address -> peer address -# - asn: 65000 spec.asNumber -> peer AS -# - srcIf: lo0 localAddress.interfaceRef -> update-source -# - af-items/PeerAf-list: peer address families -# - sendComExt/sendComStd sendCommunity=Both -> extended+standard -# - ctrl: rr-client routeReflectorClient=true -# -# intf-items/lb-items: Loopback interface -# ipv4-items: Loopback IPv4 address -# icmpv4-items: ICMP config for loopback + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} -- interface/loopback-vtep -- apiVersion: networking.metal.ironcore.dev/v1alpha1 @@ -113,7 +92,7 @@ spec: sendCommunity: Both routeReflectorClient: true --- state -- +-- state/expect -- { "System": { "bgp-items": { @@ -239,6 +218,9 @@ spec: ] } } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/dhcprelay.txt b/test/e2e/testdata/cisco-nxos-gnmi/dhcprelay.txt similarity index 73% rename from test/integration/testdata/nxos/dhcprelay.txt rename to test/e2e/testdata/cisco-nxos-gnmi/dhcprelay.txt index 60ab62b72..232f45866 100644 --- a/test/integration/testdata/nxos/dhcprelay.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/dhcprelay.txt @@ -1,36 +1,19 @@ # NX-OS DHCP Relay Integration Test # -# Tests DHCP relay configuration with server addresses and interface references. -# DHCPRelay requires at least one interface to relay DHCP messages. -# # Resource dependency chain: # vlan100 -> VLAN (id: 100, name: DHCP-VLAN) # svi100 -> RoutedVLAN Interface referencing vlan100 (spec.name: Vlan100) # relay-config -> DHCPRelay with server and interface reference -# -# Expected gNMI state mapping: -# -# bd-items/bd-items/BD-list: VLAN resource -# - fabEncap: vlan-100 spec.id -> VLAN encapsulation -# - name: DHCP-VLAN spec.name -> VLAN name -# - adminSt/BdState: active spec.adminState=Up -> active state -# -# fm-items/dhcp-items: DHCPRelay exists -> Feature "dhcp" enabled -# fm-items/ifvlan-items: RoutedVLAN exists -> Feature "ifvlan" enabled -# -# dhcp-items/inst-items/relayif-items: DHCPRelay configuration -# RelayIf-list[id='vlan100']: interfaceRefs -> relay interface -# RelayAddr-list[address]: servers -> DHCP server addresses -# -# intf-items/svi-items/If-list: RoutedVLAN Interface (SVI) -# - id: vlan100 spec.name -> interface ID -# - vlanId: 100 vlanRef -> VLAN ID -# - rtvrfMbr-items.tDn: default VRF membership -# -# ipv4-items: L3 interface with IPv4 address -# - addr: 192.168.100.1/24 ipv4.addresses -> IP address -# -# icmpv4-items: ICMP settings for L3 interface + + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} -- vlan/vlan100 -- apiVersion: networking.metal.ironcore.dev/v1alpha1 @@ -82,7 +65,7 @@ spec: interfaceRefs: - name: svi100 --- state -- +-- state/expect -- { "System": { "bd-items": { @@ -187,6 +170,9 @@ spec: ] } } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/dns.txt b/test/e2e/testdata/cisco-nxos-gnmi/dns.txt similarity index 84% rename from test/integration/testdata/nxos/dns.txt rename to test/e2e/testdata/cisco-nxos-gnmi/dns.txt index 932c98fc2..68c640c3c 100644 --- a/test/integration/testdata/nxos/dns.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/dns.txt @@ -1,9 +1,13 @@ # NX-OS DNS Integration Test -# -# Tests DNS server and domain configuration. -# -# Expected gNMI paths: -# dns-items - DNS configuration with servers and domain + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} -- dns/config -- apiVersion: networking.metal.ironcore.dev/v1alpha1 @@ -22,7 +26,7 @@ spec: - address: 10.10.10.10 vrfName: management --- state -- +-- state/expect -- { "System": { "dns-items": { @@ -51,6 +55,9 @@ spec: } ] } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/evpninstance.txt b/test/e2e/testdata/cisco-nxos-gnmi/evpninstance.txt similarity index 84% rename from test/integration/testdata/nxos/evpninstance.txt rename to test/e2e/testdata/cisco-nxos-gnmi/evpninstance.txt index 8313658d2..c54ba30f8 100644 --- a/test/integration/testdata/nxos/evpninstance.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/evpninstance.txt @@ -3,6 +3,15 @@ # Tests EVPN Instance (L3VNI) for IP-VRF in VXLAN fabric. # Using Routed type which doesn't require a VLAN dependency. +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} + -- evpninstance/l3vni -- apiVersion: networking.metal.ironcore.dev/v1alpha1 kind: EVPNInstance @@ -18,7 +27,7 @@ spec: type: Routed routeDistinguisher: "auto" --- state -- +-- state/expect -- { "System": { "eps-items": { @@ -47,6 +56,9 @@ spec: "vnsegment-items": { "adminSt": "enabled" } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/interfaces.txt b/test/e2e/testdata/cisco-nxos-gnmi/interfaces.txt similarity index 64% rename from test/integration/testdata/nxos/interfaces.txt rename to test/e2e/testdata/cisco-nxos-gnmi/interfaces.txt index d42957028..d2c520759 100644 --- a/test/integration/testdata/nxos/interfaces.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/interfaces.txt @@ -1,41 +1,47 @@ # Integration test for Interface resources # -# Tests three interface types with dependencies: -# loopback-vtep -> Loopback with IPv4 address (spec.name: lo0) -# uplink-spine1 -> Physical L3 with unnumbered IPv4 + BFD (spec.name: eth1/1) -# host1-pc -> Aggregate L2 trunk with LACP, vPC (spec.name: po10) -# -# K8s metadata.name differs from spec.name to verify the provider uses spec.name -# for gNMI paths, not the K8s object name. -# -# Resources must be created in order due to dependencies. -# -# Expected gNMI state mapping (see internal/provider/cisco/nxos/provider.go): -# -# fm-items/bfd-items: uplink-spine1 has bfd.enabled=true -> Feature "bfd" enabled -# fm-items/lacp-items: host1-pc is Aggregate -> Feature "lacp" enabled -# -# bfd-items/inst-items: uplink-spine1.bfd config -> BFD interface settings -# -# icmpv4-items: L3 interfaces (lo0, eth1/1) get ICMP config -# - lo0: "port-unreachable,redirect" (loopback default) -# - eth1/1: "port-unreachable" (physical default) -# -# intf-items/lb-items: loopback-vtep is Loopback -> LbRtdIf-list[id=lo0] -# intf-items/phys-items: uplink-spine1 is Physical -> PhysIf-list[id=eth1/1] -# intf-items/aggr-items: host1-pc is Aggregate -> AggrIf-list[id=po10] -# - rsmbrIfs-items: memberInterfaceRefs -> member list -# - pcMode: aggregation.controlProtocol.mode=Active -> "active" -# -# ipv4-items: L3 interfaces with IPv4 config -# - lo0: ipv4.addresses -> Addr-list -# - eth1/1: ipv4.unnumbered -> unnumbered ref -# -# stp-items: host1-pc is L2 Aggregate (no IPv4) -> SpanningTree config -# Defaults: mode=default, bpdufilter=default, bpduguard=default -# -# vpc-items: host1-pc.aggregation.multichassis.id=2 -> VPCIf with id=2 -# References the port-channel via tDn +# Tests interface types and NX-OS specific InterfaceConfig: +# loopback-vtep -> Loopback with IPv4 address +# uplink-spine1 -> Physical L3 with unnumbered IPv4 + BFD +# edge-port -> Physical L2 with InterfaceConfig (STP edge, BufferBoost disabled) +# host-pc -> Aggregate L2 with vPC, LACP, InterfaceConfig (STP network, LACP options) + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} + +-- interfaceconfig/edge-port-config -- +apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 +kind: InterfaceConfig +metadata: + name: interface-nxconfig-edge + namespace: default +spec: + bufferBoost: + enabled: false + spanningTree: + portType: Edge + bpduGuard: true + +-- interfaceconfig/host-pc-config -- +apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 +kind: InterfaceConfig +metadata: + name: interface-nxconfig-po + namespace: default +spec: + lacp: + vpcConvergence: true + suspendIndividual: false + spanningTree: + portType: Network + bpduFilter: true + -- interface/loopback-vtep -- apiVersion: networking.metal.ironcore.dev/v1alpha1 kind: Interface @@ -54,6 +60,7 @@ spec: ipv4: addresses: - 10.0.0.10/32 + -- interface/uplink-spine1 -- apiVersion: networking.metal.ironcore.dev/v1alpha1 kind: Interface @@ -79,35 +86,68 @@ spec: desiredMinimumTxInterval: 300ms requiredMinimumReceive: 300ms detectionMultiplier: 3 --- interface/host1-pc -- + +-- interface/edge-port -- apiVersion: networking.metal.ironcore.dev/v1alpha1 kind: Interface metadata: + name: edge-port + namespace: default labels: - networking.metal.ironcore.dev/device-name: device - name: host1-pc + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: eth1/2 + type: Physical + adminState: Up + mtu: 1500 + description: "Edge port with STP and BufferBoost config" + providerConfigRef: + apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 + kind: InterfaceConfig + name: interface-nxconfig-edge + switchport: + mode: Trunk + nativeVlan: 1 + allowedVlans: + - 10 + +-- interface/host-pc -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: host-pc namespace: default + labels: + networking.metal.ironcore.dev/device: device spec: deviceRef: name: device name: po10 - description: vPC to Host1 - adminState: Up type: Aggregate + adminState: Up mtu: 1500 + description: "vPC to Host with STP and LACP config" + providerConfigRef: + apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 + kind: InterfaceConfig + name: interface-nxconfig-po switchport: mode: Trunk nativeVlan: 1 - allowedVlans: [10] + allowedVlans: + - 10 aggregation: controlProtocol: mode: Active memberInterfaceRefs: - - name: uplink-spine1 + - name: edge-port multichassis: enabled: true id: 2 --- state -- + +-- state/expect -- { "System": { "bfd-items": { @@ -167,9 +207,9 @@ spec: "aggrExtd-items": { "bufferBoost": "enable" }, - "descr": "vPC to Host1", + "descr": "vPC to Host with STP and LACP config", "id": "po10", - "lacpVpcConvergence": "disable", + "lacpVpcConvergence": "enable", "layer": "Layer2", "medium": "broadcast", "mode": "trunk", @@ -179,11 +219,11 @@ spec: "rsmbrIfs-items": { "RsMbrIfs-list": [ { - "tDn": "/System/intf-items/phys-items/PhysIf-list[id='eth1/1']" + "tDn": "/System/intf-items/phys-items/PhysIf-list[id='eth1/2']" } ] }, - "suspIndividual": "enable", + "suspIndividual": "disable", "trunkVlans": "10", "userCfgdFlags": "admin_layer,admin_mtu,admin_state" } @@ -222,6 +262,23 @@ spec: }, "trunkVlans": "1-4094", "userCfgdFlags": "admin_layer,admin_mtu,admin_state" + }, + { + "accessVlan": "vlan-1", + "adminSt": "up", + "descr": "Edge port with STP and BufferBoost config", + "FECMode": "auto", + "id": "eth1/2", + "layer": "Layer2", + "medium": "broadcast", + "mode": "trunk", + "mtu": 1500, + "nativeVlan": "vlan-1", + "trunkVlans": "10", + "userCfgdFlags": "admin_layer,admin_mtu,admin_state", + "physExtd-items": { + "bufferBoost": "disable" + } } ] } @@ -263,10 +320,16 @@ spec: "if-items": { "If-list": [ { + "id": "eth1/2", + "mode": "edge", "bpdufilter": "default", - "bpduguard": "default", + "bpduguard": "enable" + }, + { "id": "po10", - "mode": "default" + "mode": "network", + "bpdufilter": "enable", + "bpduguard": "default" } ] } @@ -287,6 +350,9 @@ spec: } } } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/isis.txt b/test/e2e/testdata/cisco-nxos-gnmi/isis.txt similarity index 68% rename from test/integration/testdata/nxos/isis.txt rename to test/e2e/testdata/cisco-nxos-gnmi/isis.txt index 57673b133..36a8361d8 100644 --- a/test/integration/testdata/nxos/isis.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/isis.txt @@ -1,39 +1,17 @@ # NX-OS ISIS Integration Test # -# Tests IS-IS routing protocol configuration with interface references. -# ISIS requires at least one interface to participate in the IS-IS domain. -# # Resource dependency chain: # ethernet1 -> Physical Interface (spec.name: Ethernet1/1 -> eth1/1) # fabric-isis -> ISIS instance referencing ethernet1 -# -# K8s metadata.name (fabric-isis) differs from spec.instance (FABRIC) to verify -# the provider uses spec.instance for gNMI paths. -# -# Expected gNMI state mapping: -# -# fm-items/isis-items: ISIS exists -> Feature "isis" enabled -# -# intf-items/phys-items/PhysIf-list: Physical interface -# - id: eth1/1 spec.name normalized (Ethernet1/1 -> eth1/1) -# - layer: Layer3 L3 interface (has IPv4 config) -# - adminSt: up spec.adminState=Up -# -# ipv4-items: L3 interface with IPv4 address -# - addr: 10.0.1.1/30 ipv4.addresses -> IP address -# -# isis-items/inst-items/Inst-list: ISIS instance configuration -# - name: FABRIC spec.instance -> IS-IS instance name -# - adminSt: enabled spec.adminState=Up -> enabled -# dom-items/Dom-list: IS-IS domain (default VRF) -# - net: 49.0001... spec.networkEntityTitle -> NET -# - isType: l2 spec.type=Level2 -> level type -# - af-items/DomAf-list: spec.addressFamilies -# - type: v4 IPv4Unicast -> v4 -# - if-items/If-list: spec.interfaceRefs -> participating interfaces -# - id: eth1/1 interface reference -# - v4enable: true IPv4 enabled on interface -# - networkTypeP2P: on point-to-point network type + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} -- interface/ethernet1 -- apiVersion: networking.metal.ironcore.dev/v1alpha1 @@ -73,7 +51,7 @@ spec: interfaceRefs: - name: ethernet1 --- state -- +-- state/expect -- { "System": { "fm-items": { @@ -172,6 +150,9 @@ spec: } ] } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/lldp.txt b/test/e2e/testdata/cisco-nxos-gnmi/lldp.txt similarity index 73% rename from test/integration/testdata/nxos/lldp.txt rename to test/e2e/testdata/cisco-nxos-gnmi/lldp.txt index 7965746f2..f3527bad2 100644 --- a/test/integration/testdata/nxos/lldp.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/lldp.txt @@ -1,11 +1,14 @@ # NX-OS LLDP Integration Test -# -# Tests LLDP (Link Layer Discovery Protocol) configuration with provider-specific -# LLDPConfig for NX-OS specific settings (holdTime, initDelay). -# -# Expected gNMI paths: -# fm-items/lldp-items - LLDP feature manager -# lldp-items/inst-items - LLDP instance configuration + + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} -- lldpconfig/lldp-settings -- apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 @@ -34,7 +37,7 @@ spec: kind: LLDPConfig name: lldp-settings --- state -- +-- state/expect -- { "System": { "fm-items": { @@ -47,6 +50,9 @@ spec: "holdTime": 180, "initDelayTime": 5 } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/managementaccess.txt b/test/e2e/testdata/cisco-nxos-gnmi/managementaccess.txt similarity index 53% rename from test/integration/testdata/nxos/managementaccess.txt rename to test/e2e/testdata/cisco-nxos-gnmi/managementaccess.txt index 5a646574d..f4932744c 100644 --- a/test/integration/testdata/nxos/managementaccess.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/managementaccess.txt @@ -1,24 +1,13 @@ # NX-OS ManagementAccess Integration Test -# -# Tests management access configuration for gRPC and SSH. -# -# Expected gNMI state mapping: -# -# fm-items/grpc-items: gRPC enabled -> Feature "grpc" enabled -# fm-items/ssh-items: SSH enabled -> Feature "ssh" enabled -# -# grpc-items: gRPC service configuration -# - port: 9339 spec.grpc.port -> gRPC port -# - useVrf: default spec.grpc.vrfName -> VRF for gRPC -# gnmi-items: gNMI-specific settings -# - maxCalls: 8 spec.grpc.gnmi.maxConcurrentCall -# - keepAliveTimeout: 600 spec.grpc.gnmi.keepAliveTimeout (seconds) -# -# terml-items/ln-items: Terminal line settings (SSH) -# cons-items: Console settings (defaults) -# vty-items: VTY (SSH) settings -# - execTmeout-items.timeout: 10 spec.ssh.timeout (minutes) -# - ssLmt-items.sesLmt: 32 spec.ssh.sessionLimit + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} -- managementaccess/config -- apiVersion: networking.metal.ironcore.dev/v1alpha1 @@ -43,7 +32,7 @@ spec: timeout: 10m sessionLimit: 32 --- state -- +-- state/expect -- { "System": { "fm-items": { @@ -78,6 +67,9 @@ spec: } } } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/ntp.txt b/test/e2e/testdata/cisco-nxos-gnmi/ntp.txt similarity index 83% rename from test/integration/testdata/nxos/ntp.txt rename to test/e2e/testdata/cisco-nxos-gnmi/ntp.txt index 95488d90e..f0cbbbcc2 100644 --- a/test/integration/testdata/nxos/ntp.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/ntp.txt @@ -1,9 +1,13 @@ # NX-OS NTP Integration Test -# -# Tests NTP server configuration. -# -# Expected gNMI paths: -# time-items - NTP configuration with servers + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} -- ntp/config -- apiVersion: networking.metal.ironcore.dev/v1alpha1 @@ -23,7 +27,7 @@ spec: prefer: true vrfName: management --- state -- +-- state/expect -- { "System": { "fm-items": { @@ -50,6 +54,9 @@ spec: "srcIf-items": { "srcIf": "mgmt0" } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/nve.txt b/test/e2e/testdata/cisco-nxos-gnmi/nve.txt similarity index 92% rename from test/integration/testdata/nxos/nve.txt rename to test/e2e/testdata/cisco-nxos-gnmi/nve.txt index 04ffc9c63..1eb311208 100644 --- a/test/integration/testdata/nxos/nve.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/nve.txt @@ -1,6 +1,15 @@ # NX-OS NVE (Network Virtualization Edge) Integration Test # -# NVE requires a loopback interface as source. We create the loopback first. +# NVE requires a loopback interface as source. + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} -- interface/lo-nve -- apiVersion: networking.metal.ironcore.dev/v1alpha1 @@ -36,7 +45,7 @@ spec: name: lo-nve hostReachability: BGP --- state -- +-- state/expect -- { "System": { "eps-items": { @@ -127,6 +136,9 @@ spec: ] } } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/ospf.txt b/test/e2e/testdata/cisco-nxos-gnmi/ospf.txt similarity index 93% rename from test/integration/testdata/nxos/ospf.txt rename to test/e2e/testdata/cisco-nxos-gnmi/ospf.txt index fe284cc84..305bad3f9 100644 --- a/test/integration/testdata/nxos/ospf.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/ospf.txt @@ -1,6 +1,15 @@ # NX-OS OSPF Integration Test # -# OSPF requires at least one interface. We create a loopback first. +# OSPF requires at least one interface. + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} -- interface/lo-ospf -- apiVersion: networking.metal.ironcore.dev/v1alpha1 @@ -38,7 +47,7 @@ spec: - name: lo-ospf area: "0.0.0.0" --- state -- +-- state/expect -- { "System": { "fm-items": { @@ -142,6 +151,9 @@ spec: } ] } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/pim.txt b/test/e2e/testdata/cisco-nxos-gnmi/pim.txt similarity index 91% rename from test/integration/testdata/nxos/pim.txt rename to test/e2e/testdata/cisco-nxos-gnmi/pim.txt index de227701a..a1e1e39cf 100644 --- a/test/integration/testdata/nxos/pim.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/pim.txt @@ -1,6 +1,15 @@ # NX-OS PIM Integration Test # -# PIM requires at least one interface. We create a loopback first. +# PIM requires at least one interface. + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} -- interface/lo-pim -- apiVersion: networking.metal.ironcore.dev/v1alpha1 @@ -34,9 +43,9 @@ spec: adminState: Up interfaceRefs: - name: lo-pim - sparse: true + mode: Sparse --- state -- +-- state/expect -- { "System": { "fm-items": { @@ -125,6 +134,9 @@ spec: ] } } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/routedvlan.txt b/test/e2e/testdata/cisco-nxos-gnmi/routedvlan.txt similarity index 87% rename from test/integration/testdata/nxos/routedvlan.txt rename to test/e2e/testdata/cisco-nxos-gnmi/routedvlan.txt index 1391c363c..7568c3628 100644 --- a/test/integration/testdata/nxos/routedvlan.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/routedvlan.txt @@ -1,15 +1,13 @@ # NX-OS RoutedVLAN (SVI) Integration Test -# -# Tests L3 VLAN interface (SVI - Switched Virtual Interface). -# RoutedVLAN interfaces provide L3 routing for a VLAN. -# -# Test topology: -# VLAN 10 (L2 VLAN) -# vlan10 (RoutedVLAN/SVI with IPv4 address) -# -# Expected gNMI paths: -# intf-items/svi-items/If-list[id=vlan10] - SVI interface -# ipv4-items/.../If-list[id=vlan10] - IPv4 address on SVI + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} -- vlan/vlan10 -- apiVersion: networking.metal.ironcore.dev/v1alpha1 @@ -47,7 +45,7 @@ spec: addresses: - 10.10.0.1/24 --- state -- +-- state/expect -- { "System": { "bd-items": { @@ -130,6 +128,9 @@ spec: ] } } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/routingpolicy_prefixset.txt b/test/e2e/testdata/cisco-nxos-gnmi/routingpolicy_prefixset.txt similarity index 88% rename from test/integration/testdata/nxos/routingpolicy_prefixset.txt rename to test/e2e/testdata/cisco-nxos-gnmi/routingpolicy_prefixset.txt index 3443e7301..dc3a070a7 100644 --- a/test/integration/testdata/nxos/routingpolicy_prefixset.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/routingpolicy_prefixset.txt @@ -1,14 +1,14 @@ # RoutingPolicy with PrefixSet reference -# -# Tests that: -# - PrefixSet uses spec.name (not metadata.name) for gNMI path -# - RoutingPolicy route-map references PrefixSet via tDn using spec.name -# - IPv4 and IPv6 prefix sets go to different gNMI paths (pfxlistv4 vs pfxlistv6) -# -# K8s names vs spec.names: -# test-prefixset-v4 -> IPV4-NETWORKS (pfxlistv4-items) -# test-prefixset-v6 -> IPV6-NETWORKS (pfxlistv6-items) -# rm-import -> RM-IMPORT (rtmap-items) + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} + -- prefixset/test-prefixset-v4 -- apiVersion: networking.metal.ironcore.dev/v1alpha1 kind: PrefixSet @@ -66,7 +66,8 @@ spec: name: test-prefixset-v6 actions: routeDisposition: AcceptRoute --- state -- + +-- state/expect -- { "System": { "rpm-items": { @@ -145,6 +146,9 @@ spec: } ] } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/snmp.txt b/test/e2e/testdata/cisco-nxos-gnmi/snmp.txt similarity index 51% rename from test/integration/testdata/nxos/snmp.txt rename to test/e2e/testdata/cisco-nxos-gnmi/snmp.txt index 146d9ce80..8e8515bff 100644 --- a/test/integration/testdata/nxos/snmp.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/snmp.txt @@ -1,26 +1,13 @@ # NX-OS SNMP Integration Test -# -# Tests SNMP configuration with contact, location, source interface, hosts, and communities. -# -# Expected gNMI state mapping: -# -# snmp-items/inst-items: SNMP instance configuration -# sysinfo-items: System information -# - sysContact spec.contact -> contact string -# - sysLocation spec.location -> location string -# globals-items: Global settings -# - srcInterfaceTraps-items spec.sourceInterfaceName -> trap source interface -# - srcInterfaceInforms-items spec.sourceInterfaceName -> inform source interface -# host-items/Host-list: spec.hosts -> SNMP notification hosts -# - hostName: 10.0.0.100 host.address -> destination address -# - notifType: traps host.type (default=Traps) -# - version: v2c default SNMP version -# - udpPortID: 162 default SNMP trap port -# community-items/CommSecP-list: spec.communities -> SNMP communities -# - name: public community.name -> community string -# - grpName: network-operator community.group -> access group -# - commAcess: unspecified default access level (typo in NX-OS model) -# traps-items: Trap configuration (empty = defaults) + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} -- snmp/config -- apiVersion: networking.metal.ironcore.dev/v1alpha1 @@ -42,7 +29,7 @@ spec: - name: "public" group: "network-operator" --- state -- +-- state/expect -- { "System": { "snmp-items": { @@ -81,6 +68,9 @@ spec: }, "traps-items": {} } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/integration/testdata/nxos/subinterface.txt b/test/e2e/testdata/cisco-nxos-gnmi/subinterface.txt similarity index 86% rename from test/integration/testdata/nxos/subinterface.txt rename to test/e2e/testdata/cisco-nxos-gnmi/subinterface.txt index 66eba89f8..5235e7d77 100644 --- a/test/integration/testdata/nxos/subinterface.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/subinterface.txt @@ -1,16 +1,13 @@ # NX-OS Subinterface Integration Test -# -# Tests L3 subinterface with 802.1q encapsulation. -# Subinterfaces require a parent physical interface and VLAN tag. -# -# Test topology: -# eth1/3 (Physical L3 parent) -# eth1/3.100 (Subinterface with VLAN tag 100) -# -# Expected gNMI paths: -# intf-items/phys-items/PhysIf-list[id=eth1/3] - parent interface (L3) -# intf-items/encrtd-items/EncRtdIf-list[id=eth1/3.100] - subinterface -# ipv4-items/.../If-list[id=eth1/3.100] - IPv4 address on subinterface + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} -- interface/parent-eth -- apiVersion: networking.metal.ironcore.dev/v1alpha1 @@ -54,7 +51,7 @@ spec: addresses: - 10.100.0.1/24 --- state -- +-- state/expect -- { "System": { "intf-items": { @@ -137,6 +134,9 @@ spec: ] } } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/e2e/testdata/cisco-nxos-gnmi/syslog.txt b/test/e2e/testdata/cisco-nxos-gnmi/syslog.txt new file mode 100644 index 000000000..f490780bd --- /dev/null +++ b/test/e2e/testdata/cisco-nxos-gnmi/syslog.txt @@ -0,0 +1,76 @@ +# NX-OS Syslog Integration Test + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} + +-- syslog/logging -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Syslog +metadata: + name: logging + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + servers: + - address: "10.0.0.100" + severity: Warning + vrfName: management + facilities: + - name: Local7 + severity: Info + +-- state/expect -- +{ + "System": { + "logging-items": { + "loglevel-items": { + "facility-items": { + "Facility-list": [ + { + "facilityName": "Local7", + "severityLevel": "information" + } + ] + } + } + }, + "syslog-items": { + "logginghistory-items": { + "level": "information", + "size": 500 + }, + "originid-items": { + "idtype": "string", + "idvalue": "logging" + }, + "rdst-items": { + "RemoteDest-list": [ + { + "forwardingFacility": "local7", + "host": "10.0.0.100", + "port": 514, + "severity": "warnings", + "transport": "udp", + "vrfName": "management" + } + ] + }, + "source-items": { + "adminState": "enabled", + "ifName": "mgmt0" + } + }, + "procsys-items": { + "bootTime": "1700000000" + } + } +} diff --git a/test/integration/testdata/nxos/vpcdomain.txt b/test/e2e/testdata/cisco-nxos-gnmi/vpcdomain.txt similarity index 95% rename from test/integration/testdata/nxos/vpcdomain.txt rename to test/e2e/testdata/cisco-nxos-gnmi/vpcdomain.txt index 18614ae3f..22738179d 100644 --- a/test/integration/testdata/nxos/vpcdomain.txt +++ b/test/e2e/testdata/cisco-nxos-gnmi/vpcdomain.txt @@ -7,6 +7,15 @@ # Using this payload on a clean device will fail as features must be already enabled. +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} + -- interface/eth1 -- apiVersion: networking.metal.ironcore.dev/v1alpha1 kind: Interface @@ -66,7 +75,7 @@ spec: source: 10.0.0.1 vrfName: management --- state -- +-- state/expect -- { "System": { "fm-items": { @@ -174,6 +183,9 @@ spec: "sysPrio": 32667 } } + }, + "procsys-items": { + "bootTime": "1700000000" } } } diff --git a/test/e2e/testdata/cisco-nxos-gnmi/vrf.txt b/test/e2e/testdata/cisco-nxos-gnmi/vrf.txt new file mode 100644 index 000000000..5bec2e965 --- /dev/null +++ b/test/e2e/testdata/cisco-nxos-gnmi/vrf.txt @@ -0,0 +1,129 @@ +# NX-OS VRF Integration Test +# +# Tests VRF creation with L3 interface membership: +# tenant1 -> VRF with description +# vrf-uplink -> Physical L3 interface assigned to the VRF + +-- state/preload -- +{ + "System": { + "procsys-items": { + "bootTime": "1700000000" + } + } +} + +-- vrf/tenant1 -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: VRF +metadata: + name: k8s-vrf-tenant1 + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: TENANT-1 + description: "Tenant 1 VRF" + +-- interface/vrf-uplink -- +apiVersion: networking.metal.ironcore.dev/v1alpha1 +kind: Interface +metadata: + name: vrf-uplink + namespace: default + labels: + networking.metal.ironcore.dev/device: device +spec: + deviceRef: + name: device + name: eth1/10 + type: Physical + adminState: Up + mtu: 9000 + description: "Uplink in TENANT-1 VRF" + vrfRef: + name: k8s-vrf-tenant1 + ipv4: + addresses: + - 10.1.1.1/30 + +-- state/expect -- +{ + "System": { + "inst-items": { + "Inst-list": [ + { + "name": "TENANT-1", + "descr": "Tenant 1 VRF", + "l3vni": false, + "dom-items": { + "Dom-list": [ + { + "name": "TENANT-1" + } + ] + } + } + ] + }, + "intf-items": { + "phys-items": { + "PhysIf-list": [ + { + "FECMode": "auto", + "accessVlan": "unknown", + "adminSt": "up", + "descr": "Uplink in TENANT-1 VRF", + "id": "eth1/10", + "layer": "Layer3", + "medium": "broadcast", + "mode": "access", + "mtu": 9000, + "nativeVlan": "unknown", + "physExtd-items": { + "bufferBoost": "enable" + }, + "rtvrfMbr-items": { + "tDn": "/System/inst-items/Inst-list[name='TENANT-1']" + }, + "trunkVlans": "1-4094", + "userCfgdFlags": "admin_layer,admin_mtu,admin_state" + } + ] + } + }, + "ipv4-items": { + "inst-items": { + "dom-items": { + "Dom-list": [ + { + "name": "TENANT-1", + "if-items": { + "If-list": [ + { + "id": "eth1/10", + "addr-items": { + "Addr-list": [ + { + "addr": "10.1.1.1/30", + "pref": 0, + "tag": 0, + "type": "primary" + } + ] + } + } + ] + } + } + ] + } + } + }, + "procsys-items": { + "bootTime": "1700000000" + } + } +} diff --git a/test/e2e/testdata/interface.txt b/test/e2e/testdata/openconfig/interface.txt similarity index 98% rename from test/e2e/testdata/interface.txt rename to test/e2e/testdata/openconfig/interface.txt index 9b23c942d..f6055ec7e 100644 --- a/test/e2e/testdata/interface.txt +++ b/test/e2e/testdata/openconfig/interface.txt @@ -18,7 +18,7 @@ spec: ipv4: addresses: - 10.0.0.10/32 --- state -- +-- state/expect -- { "interfaces": { "interface": [ diff --git a/test/e2e/testutil/cluster.go b/test/e2e/testutil/cluster.go new file mode 100644 index 000000000..f9d680118 --- /dev/null +++ b/test/e2e/testutil/cluster.go @@ -0,0 +1,330 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import ( + "bytes" + "context" + "fmt" + "net/netip" + "os" + "os/exec" + "strings" + + corev1 "k8s.io/api/core/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +var ( + // gnmiPort is the port on which the gnmi-test-server listens for gNMI requests. + gnmiPort uint16 = 9339 + // serverImage is the container image for the gnmi-test-server. + // This must match the image built by the Makefile. + serverImage = "ghcr.io/ironcore-dev/gnmi-test-server:latest" +) + +// ClusterEnvironment enables end-to-end tests to run against a real Kubernetes cluster (e.g., Kind). +// TODO: use native library instead of kubectl (follow up) +type ClusterEnvironment struct { + restConfig *rest.Config + k8sClient client.Client +} + +// NewClusterEnvironment creates a new cluster-based test environment. +func NewClusterEnvironment() *ClusterEnvironment { + return &ClusterEnvironment{} +} + +// Setup connects to the existing cluster (CRDs should already be installed). +func (c *ClusterEnvironment) Setup(ctx context.Context) error { + // Register schemes + if err := corev1.AddToScheme(scheme.Scheme); err != nil { + return err + } + if err := v1alpha1.AddToScheme(scheme.Scheme); err != nil { + return err + } + if err := nxv1alpha1.AddToScheme(scheme.Scheme); err != nil { + return err + } + + // Get REST config from kubeconfig + var err error + c.restConfig = ctrl.GetConfigOrDie() + + c.k8sClient, err = client.New(c.restConfig, client.Options{Scheme: scheme.Scheme}) + if err != nil { + return err + } + + return nil +} + +// InstallCRDs installs CRDs into the cluster. Should only be called once (from process 1). +// Uses server-side apply to handle existing CRDs gracefully. +func (c *ClusterEnvironment) InstallCRDs(ctx context.Context) error { + if err := c.runKubectl(ctx, "apply", "-k", "config/crd", "--server-side", "--force-conflicts"); err != nil { + return fmt.Errorf("failed to install CRDs: %w", err) + } + return nil +} + +// DeployManager deploys the controller-manager and waits for it to be ready. +// Should only be called once (from process 1). +// Respects E2E_PROVIDER env var for provider selection. +func (c *ClusterEnvironment) DeployManager(ctx context.Context) error { + dir, _ := GetProjectDir() + env := os.Environ() + if provider := os.Getenv("E2E_PROVIDER"); provider != "" { + env = append(env, "PROVIDER="+provider) + } + + // First deploy CRDs explicitly (make deploy also does this, but let's be sure) + cmd := exec.CommandContext(ctx, "make", "deploy-crds") + cmd.Dir = dir + cmd.Env = env + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to deploy CRDs: %s: %w", string(output), err) + } + + // Then deploy the manager + cmd = exec.CommandContext(ctx, "make", "deploy") + cmd.Dir = dir + cmd.Env = env + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to deploy manager: %s: %w", string(output), err) + } + + if err := c.runKubectl(ctx, "wait", "deployment/network-operator-controller-manager", + "-n", "network-operator-system", + "--for", "condition=Available", + "--timeout", "2m"); err != nil { + return fmt.Errorf("manager not ready: %w", err) + } + return nil +} + +// UndeployManager undeploys the controller-manager. +func (c *ClusterEnvironment) UndeployManager(ctx context.Context) error { + cmd := exec.CommandContext(ctx, "make", "undeploy") + dir, _ := GetProjectDir() + cmd.Dir = dir + if output, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("failed to undeploy manager: %s: %w", string(output), err) + } + return nil +} + +// Teardown cleans up CRDs. +func (c *ClusterEnvironment) Teardown(ctx context.Context) error { + _ = c.runKubectl(ctx, "delete", "-k", "config/crd", "--ignore-not-found") + return nil +} + +// Client returns the Kubernetes client. +func (c *ClusterEnvironment) Client() client.Client { + return c.k8sClient +} + +// RESTConfig returns the REST config. +func (c *ClusterEnvironment) RESTConfig() *rest.Config { + return c.restConfig +} + +// DeployGNMIServer deploys the gnmi-test-server pod and returns its gNMI address. +func (c *ClusterEnvironment) DeployGNMIServer(ctx context.Context, namespace string) (netip.AddrPort, error) { + if err := c.runKubectl(ctx, + "run", "gnmi-test-server", + "--image", serverImage, + "--image-pull-policy", "Never", + "--namespace", namespace, + "--restart", "Never", + "--port", "8000", + "--port", fmt.Sprintf("%d", gnmiPort), + ); err != nil { + return netip.AddrPort{}, fmt.Errorf("failed to deploy gnmi-test-server: %w", err) + } + + if err := c.runKubectl(ctx, + "wait", "pods/gnmi-test-server", + "--for", "condition=Ready", + "--namespace", namespace, + "--timeout", "1m", + ); err != nil { + return netip.AddrPort{}, fmt.Errorf("gnmi-test-server pod not ready: %w", err) + } + + out, err := c.runKubectlOutput(ctx, + "get", "pod", "gnmi-test-server", + "--output", "jsonpath={.status.podIP}", + "--namespace", namespace, + ) + if err != nil { + return netip.AddrPort{}, fmt.Errorf("failed to get gnmi-test-server IP: %w", err) + } + var s netip.Addr + if s, err = netip.ParseAddr(strings.TrimSpace(out)); err != nil { + return netip.AddrPort{}, fmt.Errorf("invalid IP address from gnmi-test-server pod: %w", err) + } + + return netip.AddrPortFrom(s, gnmiPort), nil +} + +// GetGNMIState fetches state via kubectl exec. +func (c *ClusterEnvironment) GetGNMIState(ctx context.Context, namespace string) ([]byte, error) { + out, err := c.runKubectlOutput(ctx, + "exec", "gnmi-test-server", + "--namespace", namespace, + "--", + "wget", "-qO-", "http://localhost:8000/v1/state", + ) + if err != nil { + return nil, fmt.Errorf("failed to get gNMI state: %w", err) + } + return []byte(out), nil +} + +// ClearGNMIState clears state. +func (c *ClusterEnvironment) ClearGNMIState(ctx context.Context, namespace string) error { + _, err := c.runKubectlOutput(ctx, + "exec", "gnmi-test-server", + "--namespace", namespace, + "--", + "wget", "-qO-", "--post-data=", "http://localhost:8000/v1/clear", + ) + return err +} + +// PreloadGNMIState preloads nested JSON into the gnmi-test-server state. +// This allows tests to set up paths like System/procsys-items/bootTime +// before the Device controller reconciles. +func (c *ClusterEnvironment) PreloadGNMIState(ctx context.Context, namespace string, jsonData []byte) error { + _, err := c.runKubectlOutput(ctx, + "exec", "gnmi-test-server", + "--namespace", namespace, + "--", + "wget", "-qO-", "--post-data="+string(jsonData), "http://localhost:8000/v1/state", + ) + return err +} + +// runKubectl runs a kubectl command and returns an error if it fails. +func (c *ClusterEnvironment) runKubectl(ctx context.Context, args ...string) error { + cmd := exec.CommandContext(ctx, "kubectl", args...) + dir, _ := GetProjectDir() + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s: %w", string(output), err) + } + return nil +} + +// runKubectlOutput runs a kubectl command and returns its output. +func (c *ClusterEnvironment) runKubectlOutput(ctx context.Context, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, "kubectl", args...) + dir, _ := GetProjectDir() + cmd.Dir = dir + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("%s: %w", stderr.String(), err) + } + return stdout.String(), nil +} + +// DeleteNamespace deletes the given namespace. +func (c *ClusterEnvironment) DeleteNamespace(ctx context.Context, namespace string) error { + if err := c.runKubectl(ctx, "delete", "namespace", namespace, "--ignore-not-found"); err != nil { + return fmt.Errorf("failed to delete namespace %s: %w", namespace, err) + } + return nil +} + +// E2ETestLabel is the label key applied to resources created by e2e tests for cleanup tracking. +const E2ETestLabel = "networking.metal.ironcore.dev/e2e-test" + +// CreateNamespace creates a new namespace with the given name and labels it for cleanup tracking. +func (c *ClusterEnvironment) CreateNamespace(ctx context.Context, namespace string) error { + if err := c.runKubectl(ctx, "create", "namespace", namespace); err != nil { + return fmt.Errorf("failed to create namespace %s: %w", namespace, err) + } + // Label the namespace for cleanup tracking across parallel test processes + if err := c.runKubectl(ctx, "label", "namespace", namespace, E2ETestLabel+"="); err != nil { + return fmt.Errorf("failed to label namespace %s: %w", namespace, err) + } + return nil +} + +// WaitForTestNamespacesGone waits for all labeled test namespaces to be fully deleted. +// This ensures DeferCleanup hooks have completed before the manager is undeployed. +// Without this, the manager (and CRDs) may be deleted while resources with finalizers still exist, +// leaving them stuck forever because the controller can no longer process the finalizers. +func (c *ClusterEnvironment) WaitForTestNamespacesGone(ctx context.Context) error { + // Get all namespaces with our e2e test label + out, err := c.runKubectlOutput(ctx, + "get", "namespaces", + "-l", E2ETestLabel, + "-o", "jsonpath={.items[*].metadata.name}", + ) + if err != nil { + return fmt.Errorf("failed to list test namespaces: %w", err) + } + + namespaces := strings.Fields(out) + if len(namespaces) == 0 { + return nil + } + + // Wait for each test namespace to be deleted (with timeout) + for _, ns := range namespaces { + _ = c.runKubectl(ctx, "wait", "namespace", ns, + "--for=delete", + "--timeout=120s") + } + + return nil +} + +// DeleteCustomResources deletes all resources of the CRD kinds defined in ResourceRegistry in the given namespace. +// Resources are deleted in reverse order so that dependents (e.g., BGP) are deleted before their +// config resources (e.g., BGPConfig), allowing finalizers to complete successfully. +// Device is deleted last after all other resources are gone. +func (c *ClusterEnvironment) DeleteCustomResources(ctx context.Context, namespace string) error { + // Delete in reverse order: dependents before their dependencies + for i := len(ResourceRegistry) - 1; i >= 0; i-- { + gvk := ResourceRegistry[i] + _ = c.runKubectl(ctx, "delete", + ResourcePluralName(gvk), "--all", + "--namespace", namespace, + "--ignore-not-found") + } + + // Wait for all resources to be fully gone (finalizers completed) + for i := len(ResourceRegistry) - 1; i >= 0; i-- { + gvk := ResourceRegistry[i] + _ = c.runKubectl(ctx, "wait", + ResourcePluralName(gvk), + "--for=delete", "--all", + "--namespace", namespace, + "--timeout=60s") + } + + // Delete Device LAST - after all other resources and their finalizers are done + _ = c.runKubectl(ctx, "delete", "devices", "--all", + "--namespace", namespace, + "--ignore-not-found") + _ = c.runKubectl(ctx, "wait", "devices", + "--for=delete", "--all", + "--namespace", namespace, + "--timeout=60s") + + return nil +} diff --git a/test/e2e/testutil/doc.go b/test/e2e/testutil/doc.go new file mode 100644 index 000000000..9098b9793 --- /dev/null +++ b/test/e2e/testutil/doc.go @@ -0,0 +1,16 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +// Package testutil provides test infrastructure for e2e tests. +// +// It supports two test modes selected by build tags: +// +// - Envtest (build tag: envtest): Uses controller-runtime's envtest.Environment +// with an in-process gNMI test server. Fast (~10s) but doesn't test deployment. +// +// - Cluster (default): Uses a real Kubernetes cluster (typically Kind) with a +// deployed operator and gnmi-test-server pod. Slower (~2-5min) but tests full stack. +// +// The concrete types ClusterEnvironment and EnvtestEnvironment provide the same +// methods, allowing test logic to work with either mode via build tag selection. +package testutil diff --git a/test/e2e/testutil/envtest.go b/test/e2e/testutil/envtest.go new file mode 100644 index 000000000..8d015079e --- /dev/null +++ b/test/e2e/testutil/envtest.go @@ -0,0 +1,177 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import ( + "context" + "os" + "path/filepath" + "slices" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + + gnmitestserver "github.com/ironcore-dev/gnmi-test-server/testserver" +) + +// EnvtestEnvironment implements TestEnvironment using envtest and an in-process gNMI server. +type EnvtestEnvironment struct { + testEnv *envtest.Environment + restConfig *rest.Config + k8sClient client.Client + gnmiServer *gnmitestserver.Server + gnmiAddr string + cancel context.CancelFunc +} + +// NewEnvtestEnvironment creates a new envtest-based test environment. +func NewEnvtestEnvironment() *EnvtestEnvironment { + return &EnvtestEnvironment{} +} + +// Setup initializes envtest and starts the in-process gNMI server. +func (e *EnvtestEnvironment) Setup(ctx context.Context) error { + ctx, e.cancel = context.WithCancel(ctx) + + // Start in-process gNMI test server with NX-OS behavior + var err error + e.gnmiServer, e.gnmiAddr, _, err = gnmitestserver.NewTestServer(ctx, gnmitestserver.WithNXOSBehavior()) + if err != nil { + return err + } + + // Register schemes + if err := corev1.AddToScheme(scheme.Scheme); err != nil { + return err + } + if err := v1alpha1.AddToScheme(scheme.Scheme); err != nil { + return err + } + if err := nxv1alpha1.AddToScheme(scheme.Scheme); err != nil { + return err + } + + // Start envtest + e.testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + // Detect test binary directory for IDEs + if dir := detectTestBinaryDir(); dir != "" { + e.testEnv.BinaryAssetsDirectory = dir + } + + e.restConfig, err = e.testEnv.Start() + if err != nil { + return err + } + + e.k8sClient, err = client.New(e.restConfig, client.Options{Scheme: scheme.Scheme}) + if err != nil { + return err + } + + // Wait for default namespace to be ready + for { + var ns corev1.Namespace + if err := e.k8sClient.Get(ctx, client.ObjectKey{Name: metav1.NamespaceDefault}, &ns); err == nil { + break + } + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + } + + return nil +} + +// Teardown stops envtest and the gNMI server. +func (e *EnvtestEnvironment) Teardown(_ context.Context) error { + if e.cancel != nil { + e.cancel() + } + + if e.gnmiServer != nil { + if err := e.gnmiServer.Close(); err != nil { + return err + } + } + + if e.testEnv != nil { + if err := e.testEnv.Stop(); err != nil { + return err + } + } + + return nil +} + +// Client returns the Kubernetes client. +func (e *EnvtestEnvironment) Client() client.Client { + return e.k8sClient +} + +// RESTConfig returns the REST config. +func (e *EnvtestEnvironment) RESTConfig() *rest.Config { + return e.restConfig +} + +// GNMIAddress returns the in-process gNMI server address. +func (e *EnvtestEnvironment) GNMIAddress() string { + return e.gnmiAddr +} + +// GetGNMIState fetches state directly from the in-process server. +func (e *EnvtestEnvironment) GetGNMIState(_ context.Context) ([]byte, error) { + return e.gnmiServer.GetState() +} + +// ClearGNMIState clears state directly on the in-process server. +func (e *EnvtestEnvironment) ClearGNMIState(_ context.Context) error { + e.gnmiServer.ClearState() + return nil +} + +// PreloadGNMIState replaces the in-process gNMI server state with the given JSON. +// This resets the server to a clean state for test isolation. +func (e *EnvtestEnvironment) PreloadGNMIState(_ context.Context, jsonData []byte) error { + if len(jsonData) == 0 { + return nil + } + // Replace entire state by directly setting Buf (thread-safe with Lock) + e.gnmiServer.State.Lock() + e.gnmiServer.State.Buf = jsonData + e.gnmiServer.State.Unlock() + return nil +} + +// IsEnvtest returns true for envtest mode. +func (e *EnvtestEnvironment) IsEnvtest() bool { + return true +} + +// detectTestBinaryDir locates the first directory in the k8s binary path. +func detectTestBinaryDir() string { + basePath := filepath.Join("..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + return "" + } + idx := slices.IndexFunc(entries, func(e os.DirEntry) bool { + return e.IsDir() + }) + if idx >= 0 { + return filepath.Join(basePath, entries[idx].Name()) + } + return "" +} diff --git a/test/e2e/testutil/helpers.go b/test/e2e/testutil/helpers.go new file mode 100644 index 000000000..82f4a2f0d --- /dev/null +++ b/test/e2e/testutil/helpers.go @@ -0,0 +1,545 @@ +// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "os" + "os/exec" + "reflect" + "sort" + "strings" + "time" + + "sigs.k8s.io/yaml" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +const ( + prometheusURL = "https://github.com/prometheus-operator/prometheus-operator/releases/download/v0.82.2/bundle.yaml" + certmanagerURL = "https://github.com/cert-manager/cert-manager/releases/download/v1.17.2/cert-manager.yaml" +) + +// warnError writes a warning to the provided writer. +func warnError(w io.Writer, err error) { + _, _ = fmt.Fprintf(w, "warning: %v\n", err) +} + +// Run executes the provided command within this context. +// It writes the command to the provided writer for logging. +func Run(cmd *exec.Cmd, w io.Writer) (string, error) { + dir, err := GetProjectDir() + if err != nil { + return "", fmt.Errorf("failed to get project directory: %w", err) + } + + cmd.Dir = dir + if err = os.Chdir(cmd.Dir); err != nil { + _, _ = fmt.Fprintf(w, "chdir dir: %s\n", err) + } + + command := strings.Join(cmd.Args, " ") + // #nosec G705 + _, _ = fmt.Fprintf(w, "running: %s\n", command) + + output, err := cmd.CombinedOutput() + if err != nil { + return string(output), fmt.Errorf("%s failed with error: (%w) %s", command, err, string(output)) + } + + return string(output), nil +} + +// Apply takes a raw YAML resource and applies it to the cluster by +// creating a temporary file and running 'kubectl apply -f'. +func Apply(ctx context.Context, resource string, w io.Writer) error { + file, err := os.CreateTemp("", "resource-*.yaml") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + // #nosec G703 + defer func() { _ = os.Remove(file.Name()) }() + if _, err = file.WriteString(resource); err != nil { + return fmt.Errorf("failed to write to temp file: %w", err) + } + if err = file.Close(); err != nil { + return fmt.Errorf("failed to close temp file: %w", err) + } + // #nosec G204 G702 + cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", file.Name()) + if _, err = Run(cmd, w); err != nil { + return fmt.Errorf("failed to apply resource: %w", err) + } + return nil +} + +// ExtractResourceIdentifier parses YAML and returns "kind/name" for use with kubectl wait. +// The kind is lowercased to match kubectl's resource type format. +func ExtractResourceIdentifier(resourceYAML string) (string, error) { + var obj unstructured.Unstructured + if err := yaml.Unmarshal([]byte(resourceYAML), &obj); err != nil { + return "", fmt.Errorf("failed to unmarshal YAML: %w", err) + } + + kind := strings.ToLower(obj.GetKind()) + name := obj.GetName() + if kind == "" || name == "" { + return "", fmt.Errorf("YAML missing kind or metadata.name") + } + + return kind + "/" + name, nil +} + +// CompareJSON compares two JSON strings and returns an error if they are not equal. +// For comparison, it unmarshals both into interface{} and uses reflect.DeepEqual +// after sorting any arrays and removing empty arrays/objects to ignore ordering +// and cleanup artifacts. +func CompareJSON(got, want string) error { + var gotObj, wantObj any + if err := json.Unmarshal([]byte(got), &gotObj); err != nil { + return fmt.Errorf("failed to unmarshal got JSON: %w", err) + } + if err := json.Unmarshal([]byte(want), &wantObj); err != nil { + return fmt.Errorf("failed to unmarshal want JSON: %w", err) + } + + // Normalize both objects (sort arrays, remove empty containers) + gotObj = normalizeJSON(gotObj) + wantObj = normalizeJSON(wantObj) + + if !reflect.DeepEqual(gotObj, wantObj) { + // For error message, show original compacted JSON (not normalized) + // so empty objects show as {} not null + var gotBuf, wantBuf bytes.Buffer + _ = json.Compact(&gotBuf, []byte(got)) + _ = json.Compact(&wantBuf, []byte(want)) + return fmt.Errorf("JSON mismatch:\ngot: %s\nwant: %s", gotBuf.String(), wantBuf.String()) + } + return nil +} + +// normalizeJSON recursively sorts arrays and removes empty arrays/objects +// to make comparison order-independent and ignore cleanup artifacts. +func normalizeJSON(v any) any { + switch val := v.(type) { + case map[string]any: + result := make(map[string]any) + for k, v := range val { + normalized := normalizeJSON(v) + // Skip empty maps and empty arrays + if !isEmpty(normalized) { + result[k] = normalized + } + } + if len(result) == 0 { + return nil + } + return result + case []any: + var result []any + for _, elem := range val { + normalized := normalizeJSON(elem) + if !isEmpty(normalized) { + result = append(result, normalized) + } + } + if len(result) == 0 { + return nil + } + // Sort the array by JSON representation + sort.Slice(result, func(i, j int) bool { + bi, _ := json.Marshal(result[i]) + bj, _ := json.Marshal(result[j]) + return string(bi) < string(bj) + }) + return result + default: + return v + } +} + +// isEmpty checks if a value is an empty map, empty array, or nil. +func isEmpty(v any) bool { + if v == nil { + return true + } + switch val := v.(type) { + case map[string]any: + return len(val) == 0 + case []any: + return len(val) == 0 + } + return false +} + +// InstallPrometheusOperator installs the prometheus Operator to be used to export the enabled metrics. +func InstallPrometheusOperator(ctx context.Context, w io.Writer) error { + cmd := exec.CommandContext(ctx, "kubectl", "create", "-f", prometheusURL) + _, err := Run(cmd, w) + return err +} + +// UninstallPrometheusOperator uninstalls the prometheus +func UninstallPrometheusOperator(ctx context.Context, w io.Writer) { + cmd := exec.CommandContext(ctx, "kubectl", "delete", "-f", prometheusURL) + if _, err := Run(cmd, w); err != nil { + warnError(w, err) + } +} + +// IsPrometheusCRDsInstalled checks if any Prometheus CRDs are installed +// by verifying the existence of key CRDs related to Prometheus. +func IsPrometheusCRDsInstalled(ctx context.Context, w io.Writer) bool { + // List of common Prometheus CRDs + prometheusCRDs := []string{ + "prometheuses.monitoring.coreos.com", + "prometheusrules.monitoring.coreos.com", + "prometheusagents.monitoring.coreos.com", + } + + cmd := exec.CommandContext(ctx, "kubectl", "get", "crds", "-o", "custom-columns=NAME:.metadata.name") + output, err := Run(cmd, w) + if err != nil { + return false + } + crdList := GetNonEmptyLines(output) + for _, crd := range prometheusCRDs { + for _, line := range crdList { + if strings.Contains(line, crd) { + return true + } + } + } + + return false +} + +// InstallCertManager installs the cert manager bundle. +func InstallCertManager(ctx context.Context, w io.Writer) error { + cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", certmanagerURL) + if _, err := Run(cmd, w); err != nil { + return err + } + // Wait for cert-manager-webhook to be ready, which can take time if cert-manager + // was re-installed after uninstalling on a cluster. + cmd = exec.CommandContext( + ctx, "kubectl", "wait", "deployment.apps/cert-manager-webhook", + "--for", "condition=Available", + "--namespace", "cert-manager", + "--timeout", "5m", + ) + if _, err := Run(cmd, w); err != nil { + return err + } + + // Wait for webhook to be fully operational (TLS cert ready) + // The deployment being Available doesn't mean the webhook TLS is ready + cmd = exec.CommandContext( + ctx, "kubectl", "wait", "certificate/cert-manager-webhook-ca", + "--for", "condition=Ready", + "--namespace", "cert-manager", + "--timeout", "2m", + ) + _, _ = Run(cmd, w) // Ignore error - cert may not exist in older versions + + // Give the webhook a moment to pick up the cert + time.Sleep(5 * time.Second) + return nil +} + +// UninstallCertManager uninstalls the cert manager +func UninstallCertManager(ctx context.Context, w io.Writer) { + cmd := exec.CommandContext(ctx, "kubectl", "delete", "-f", certmanagerURL) + if _, err := Run(cmd, w); err != nil { + warnError(w, err) + } +} + +// WaitForCertManagerWebhook waits for the cert-manager webhook to be fully operational. +// This should be called before deploying resources that use cert-manager certificates. +func WaitForCertManagerWebhook(ctx context.Context, w io.Writer) error { + // Wait for deployment to be available + cmd := exec.CommandContext( + ctx, "kubectl", "wait", "deployment.apps/cert-manager-webhook", + "--for", "condition=Available", + "--namespace", "cert-manager", + "--timeout", "2m", + ) + if _, err := Run(cmd, w); err != nil { + return err + } + + // Wait for the CA injector to inject the CA bundle into the webhook + cmd = exec.CommandContext( + ctx, "kubectl", "wait", "deployment.apps/cert-manager-cainjector", + "--for", "condition=Available", + "--namespace", "cert-manager", + "--timeout", "2m", + ) + if _, err := Run(cmd, w); err != nil { + return err + } + + // Wait for the cainjector to inject the CA bundle into the webhook configuration + // This is what actually makes the webhook work - the API server needs the CA to verify the webhook's TLS cert + cmd = exec.CommandContext( + ctx, "kubectl", "wait", "validatingwebhookconfiguration/cert-manager-webhook", + "--for", "jsonpath={.webhooks[0].clientConfig.caBundle}", + "--timeout", "2m", + ) + if _, err := Run(cmd, w); err != nil { + return fmt.Errorf("cert-manager webhook CA bundle not injected: %w", err) + } + + return nil +} + +// IsCertManagerCRDsInstalled checks if any Cert Manager CRDs are installed +// by verifying the existence of key CRDs related to Cert Manager. +func IsCertManagerCRDsInstalled(ctx context.Context, w io.Writer) bool { + // List of common Cert Manager CRDs + certManagerCRDs := []string{ + "certificates.cert-manager.io", + "issuers.cert-manager.io", + "clusterissuers.cert-manager.io", + "certificaterequests.cert-manager.io", + "orders.acme.cert-manager.io", + "challenges.acme.cert-manager.io", + } + + // Execute the kubectl command to get all CRDs + cmd := exec.CommandContext(ctx, "kubectl", "get", "crds") + output, err := Run(cmd, w) + if err != nil { + return false + } + + // Check if any of the Cert Manager CRDs are present + crdList := GetNonEmptyLines(output) + for _, crd := range certManagerCRDs { + for _, line := range crdList { + if strings.Contains(line, crd) { + return true + } + } + } + + return false +} + +// LoadImageToKindClusterWithName loads a local docker image to the kind cluster +func LoadImageToKindClusterWithName(ctx context.Context, name string, w io.Writer) error { + cluster := "kind" + if v, ok := os.LookupEnv("KIND_CLUSTER"); ok { + cluster = v + } + // See: https://kind.sigs.k8s.io/docs/user/rootless/#creating-a-kind-cluster-with-rootless-nerdctl + prov, ok := os.LookupEnv("KIND_EXPERIMENTAL_PROVIDER") + if ok && prov != "docker" { + // If kind is configured to not use the docker runtime (e.g. when using podman or nerctl), + // we need to create a temp file to store the image archive and load it as a tarball. + // See: https://github.com/kubernetes-sigs/kind/issues/2760 + file, err := os.CreateTemp("", "operator-image-") + if err != nil { + return fmt.Errorf("failed to create temp file: %w", err) + } + _ = file.Close() + // #nosec G703 + defer func() { _ = os.Remove(file.Name()) }() + + // https://github.com/containerd/nerdctl/blob/main/docs/command-reference.md#whale-nerdctl-save + // https://docs.podman.io/en/v5.3.0/markdown/podman-save.1.html + // #nosec G702 + cmd := exec.CommandContext(ctx, prov, "save", name, "--output", file.Name()) + if _, err = Run(cmd, w); err != nil { + return fmt.Errorf("failed to save image: %w", err) + } + + cmd = exec.CommandContext(ctx, "kind", "load", "image-archive", file.Name(), "--name", cluster) //nolint:gosec + _, err = Run(cmd, w) + return err + } + cmd := exec.CommandContext(ctx, "kind", "load", "docker-image", name, "--name", cluster) + _, err := Run(cmd, w) + return err +} + +// GetNonEmptyLines converts given command output string into individual objects +// according to line breakers, and ignores the empty elements in it. +func GetNonEmptyLines(output string) []string { + var res []string + for element := range strings.SplitSeq(output, "\n") { + if element != "" { + res = append(res, element) + } + } + return res +} + +// GetProjectDir will return the directory where the project is +func GetProjectDir() (string, error) { + wd, err := os.Getwd() + if err != nil { + return wd, err + } + wd = strings.ReplaceAll(wd, "/test/e2e", "") + return wd, nil +} + +// PatchResourceYAML takes a raw YAML resource and patches its namespace and deviceRef. +// This allows txtar test files to have placeholder values that get replaced at runtime. +// It returns the patched YAML string ready for kubectl apply. +func PatchResourceYAML(resourceYAML, namespace, deviceName string) (string, error) { + // Parse YAML into unstructured map + var obj map[string]any + if err := yaml.Unmarshal([]byte(resourceYAML), &obj); err != nil { + return "", fmt.Errorf("failed to unmarshal YAML: %w", err) + } + + // Patch metadata.namespace + metadata, ok := obj["metadata"].(map[string]any) + if !ok { + metadata = make(map[string]any) + obj["metadata"] = metadata + } + metadata["namespace"] = namespace + + // Ensure labels map exists and add e2e test label + labels, ok := metadata["labels"].(map[string]any) + if !ok { + labels = make(map[string]any) + metadata["labels"] = labels + } + labels[E2ETestLabel] = "" + + // Patch spec.deviceRef.name if it exists + if spec, ok := obj["spec"].(map[string]any); ok { + if deviceRef, ok := spec["deviceRef"].(map[string]any); ok { + deviceRef["name"] = deviceName + } + } + + // Patch the device label if it exists + if _, hasDeviceLabel := labels["networking.metal.ironcore.dev/device"]; hasDeviceLabel { + labels["networking.metal.ironcore.dev/device"] = deviceName + } + + // Marshal back to YAML + out, err := yaml.Marshal(obj) + if err != nil { + return "", fmt.Errorf("failed to marshal patched YAML: %w", err) + } + return string(out), nil +} + +// ApplyWithPatch applies a YAML resource after patching its namespace and deviceRef. +// This is the cluster-mode equivalent of envtest's createResourceFromTxtar. +func ApplyWithPatch(ctx context.Context, resourceYAML, namespace, deviceName string, w io.Writer) error { + patched, err := PatchResourceYAML(resourceYAML, namespace, deviceName) + if err != nil { + return err + } + return Apply(ctx, patched, w) +} + +// WaitForCondition waits for a resource to have a condition set to True. +// It tries "Configured" first, falls back to "Ready" if Configured doesn't exist. +// Skips config-only resources that don't have status conditions. +func WaitForCondition(ctx context.Context, resourceName, namespace string, w io.Writer) error { + // Config-only resources don't have status conditions - skip them + // resourceName format is "kind/name" e.g. "bgpconfig/evpn-settings" + kind := strings.Split(resourceName, "/")[0] + switch strings.ToLower(kind) { + case "interfaceconfig", "lldpconfig", "bgpconfig", "nveconfig", "managementaccessconfig": + return nil // No conditions to wait for + } + + // Try Configured first using jsonpath (more reliable than --for condition=X with multiple conditions) + cmd := exec.CommandContext(ctx, "kubectl", "wait", resourceName, + "--for", `jsonpath={.status.conditions[?(@.type=="Configured")].status}=True`, + "--namespace", namespace, + "--timeout", "10s", + ) + if _, err := Run(cmd, w); err == nil { + return nil + } + + // Fallback to Ready using jsonpath (condition=Ready doesn't work reliably with custom resources) + cmd = exec.CommandContext(ctx, "kubectl", "wait", resourceName, + "--for", `jsonpath={.status.conditions[?(@.type=="Ready")].status}=True`, + "--namespace", namespace, + "--timeout", "2m", + ) + _, err := Run(cmd, w) + return err +} + +// UncommentCode searches for target in the file and remove the comment prefix +// of the target content. The target content may span multiple lines. +func UncommentCode(filename, target, prefix string) error { + content, err := os.ReadFile(filename) + if err != nil { + return err + } + + before, after, ok := bytes.Cut(content, []byte(target)) + if !ok { + if bytes.Contains(content, []byte(target)[len(prefix):]) { + return nil // already uncommented + } + + return fmt.Errorf("unable to find the code %s to be uncomment", target) + } + + out := new(bytes.Buffer) + if _, err = out.Write(before); err != nil { + return err + } + + scanner := bufio.NewScanner(bytes.NewBufferString(target)) + if !scanner.Scan() { + return nil + } + for { + _, err = out.WriteString(strings.TrimPrefix(scanner.Text(), prefix)) + if err != nil { + return err + } + // Avoid writing a newline in case the previous line was the last in target. + if !scanner.Scan() { + break + } + if _, err = out.WriteString("\n"); err != nil { + return err + } + } + + if _, err = out.Write(after); err != nil { + return err + } + + return os.WriteFile(filename, out.Bytes(), 0o644) +} + +// ExtractConditions extracts status conditions from an unstructured object +// into a typed []metav1.Condition slice for use with apimeta helpers. +func ExtractConditions(obj *unstructured.Unstructured) ([]metav1.Condition, error) { + raw, _, err := unstructured.NestedSlice(obj.Object, "status", "conditions") + if err != nil { + return nil, err + } + data, err := json.Marshal(raw) + if err != nil { + return nil, err + } + var conditions []metav1.Condition + return conditions, json.Unmarshal(data, &conditions) +} diff --git a/test/e2e/testutil/provider.go b/test/e2e/testutil/provider.go new file mode 100644 index 000000000..2528eea9d --- /dev/null +++ b/test/e2e/testutil/provider.go @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package testutil + +import ( + "context" + "time" + + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/client" + + nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/provider/cisco/iosxr" + "github.com/ironcore-dev/network-operator/internal/provider/cisco/nxos" +) + +// ProviderType represents the network device provider to test against. +type ProviderType string + +// ProviderFactory creates a new provider instance. +type ProviderFactory = func() provider.Provider + +// Provider names must match the registered provider names in internal/provider/*/provider.go +const ( + ProviderNXOS ProviderType = "cisco-nxos-gnmi" + ProviderIOSXR ProviderType = "cisco-iosxr-gnmi" +) + +// ProviderConfig holds the configuration for a provider test. +type ProviderConfig struct { + Name ProviderType + NewProvider ProviderFactory +} + +// SupportedProviders lists all providers to test. +var SupportedProviders = []ProviderConfig{ + {Name: ProviderNXOS, NewProvider: func() provider.Provider { return nxos.NewProvider() }}, + {Name: ProviderIOSXR, NewProvider: func() provider.Provider { return iosxr.NewProvider() }}, +} + +// ResourceRegistry maps txtar file prefixes to GVKs for cleanup ordering. +// Resources are deleted in reverse order (last in array = deleted first). +// Config resources must be at the START so they're deleted LAST. +// Device is NOT included - it must remain until all finalizers complete. +var ResourceRegistry = []schema.GroupVersionKind{ + // Config resources - deleted after main resources + nxv1alpha1.GroupVersion.WithKind("InterfaceConfig"), + nxv1alpha1.GroupVersion.WithKind("LLDPConfig"), + nxv1alpha1.GroupVersion.WithKind("BGPConfig"), + nxv1alpha1.GroupVersion.WithKind("VPCDomain"), + // Main resources - deleted FIRST (have finalizers that need Device and configs) + v1alpha1.GroupVersion.WithKind("Interface"), + v1alpha1.GroupVersion.WithKind("VLAN"), + v1alpha1.GroupVersion.WithKind("VRF"), + v1alpha1.GroupVersion.WithKind("NTP"), + v1alpha1.GroupVersion.WithKind("DNS"), + v1alpha1.GroupVersion.WithKind("LLDP"), + v1alpha1.GroupVersion.WithKind("Banner"), + v1alpha1.GroupVersion.WithKind("OSPF"), + v1alpha1.GroupVersion.WithKind("PIM"), + v1alpha1.GroupVersion.WithKind("NetworkVirtualizationEdge"), + v1alpha1.GroupVersion.WithKind("EVPNInstance"), + v1alpha1.GroupVersion.WithKind("RoutingPolicy"), + v1alpha1.GroupVersion.WithKind("PrefixSet"), + v1alpha1.GroupVersion.WithKind("BGP"), + v1alpha1.GroupVersion.WithKind("BGPPeer"), + v1alpha1.GroupVersion.WithKind("Syslog"), + v1alpha1.GroupVersion.WithKind("SNMP"), + v1alpha1.GroupVersion.WithKind("ManagementAccess"), + v1alpha1.GroupVersion.WithKind("AccessControlList"), + v1alpha1.GroupVersion.WithKind("DHCPRelay"), + v1alpha1.GroupVersion.WithKind("ISIS"), +} + +// ResourcePluralName returns the plural resource name for a GVK. +// These must match the CRD spec.names.plural values (from `kubectl api-resources`). +// We can't use meta.UnsafeGuessKindToResource because CRDs define their own plurals +// which don't always follow standard Kubernetes pluralization rules. +func ResourcePluralName(gvk schema.GroupVersionKind) string { + plurals := map[string]string{ + "Interface": "interfaces", + "VLAN": "vlans", + "VRF": "vrfs", + "NTP": "ntp", + "DNS": "dns", + "LLDP": "lldps", + "Banner": "banners", + "OSPF": "ospf", + "PIM": "pim", + "NetworkVirtualizationEdge": "networkvirtualizationedges", + "EVPNInstance": "evpninstances", + "InterfaceConfig": "interfaceconfigs", + "LLDPConfig": "lldpconfigs", + "VPCDomain": "vpcdomains", + "BGPConfig": "bgpconfigs", + "RoutingPolicy": "routingpolicies", + "PrefixSet": "prefixsets", + "BGP": "bgp", + "BGPPeer": "bgppeers", + "Syslog": "syslogs", + "SNMP": "snmp", + "ManagementAccess": "managementaccesses", + "AccessControlList": "accesscontrollists", + "DHCPRelay": "dhcprelays", + "ISIS": "isis", + "Device": "devices", + } + if plural, ok := plurals[gvk.Kind]; ok { + return plural + } + // Fallback to standard pluralization + plural, _ := meta.UnsafeGuessKindToResource(gvk) + return plural.Resource +} + +// CreateTestDevice creates a Device pointing to the gNMI server with a generated name. +func CreateTestDevice(ctx context.Context, c client.Client, gnmiAddr, namespace string) (*v1alpha1.Device, error) { + device := &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-device-", + Namespace: namespace, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: gnmiAddr, + }, + }, + } + if err := c.Create(ctx, device); err != nil { + return nil, err + } + + // Set the device status to Running so that dependent resources can reconcile + device.Status.Phase = v1alpha1.DevicePhaseRunning + if err := c.Status().Update(ctx, device); err != nil { + return nil, err + } + + return device, nil +} + +// CleanupTimeout is the timeout for cleanup operations. +const CleanupTimeout = 30 * time.Second + +// CleanupInterval is the polling interval for cleanup operations. +const CleanupInterval = 100 * time.Millisecond diff --git a/test/e2e/util_test.go b/test/e2e/util_test.go deleted file mode 100644 index 1281d18db..000000000 --- a/test/e2e/util_test.go +++ /dev/null @@ -1,299 +0,0 @@ -// SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors -// SPDX-License-Identifier: Apache-2.0 - -package e2e - -import ( - "bufio" - "bytes" - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "strings" - - . "github.com/onsi/ginkgo/v2" -) - -const ( - prometheusURL = "https://github.com/prometheus-operator/prometheus-operator/releases/download/v0.82.2/bundle.yaml" - certmanagerURL = "https://github.com/cert-manager/cert-manager/releases/download/v1.17.2/cert-manager.yaml" -) - -func warnError(err error) { - _, _ = fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) -} - -// Run executes the provided command within this context -func Run(cmd *exec.Cmd) (string, error) { - dir, err := GetProjectDir() - if err != nil { - return "", fmt.Errorf("failed to get project directory: %w", err) - } - - cmd.Dir = dir - if err = os.Chdir(cmd.Dir); err != nil { - _, _ = fmt.Fprintf(GinkgoWriter, "chdir dir: %s\n", err) - } - - command := strings.Join(cmd.Args, " ") - // #nosec G705 - _, _ = fmt.Fprintf(GinkgoWriter, "running: %s\n", command) - - output, err := cmd.CombinedOutput() - if err != nil { - return string(output), fmt.Errorf("%s failed with error: (%w) %s", command, err, string(output)) - } - - return string(output), nil -} - -// Apply takes a raw YAML resource and applies it to the cluster by -// creating a temporary file and running 'kubectl apply -f'. -func Apply(ctx context.Context, resource string) error { - file, err := os.CreateTemp("", "resource-*.yaml") - if err != nil { - return fmt.Errorf("failed to create temp file: %w", err) - } - // #nosec G703 - defer func() { _ = os.Remove(file.Name()) }() - if _, err = file.WriteString(resource); err != nil { - return fmt.Errorf("failed to write to temp file: %w", err) - } - if err = file.Close(); err != nil { - return fmt.Errorf("failed to close temp file: %w", err) - } - // #nosec G204 G702 - cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", file.Name()) - if _, err = Run(cmd); err != nil { - return fmt.Errorf("failed to apply resource: %w", err) - } - return nil -} - -// CompareJSON compares two JSON strings and returns an error if they are not equal. -// For comparison, it will compact the JSON strings to remove any whitespace differences. -// If the JSON strings are equal, it returns nil. -func CompareJSON(got, want string) error { - var gotBuf, wantBuf bytes.Buffer - if err := json.Compact(&gotBuf, []byte(got)); err != nil { - return fmt.Errorf("failed to compact got JSON: %w", err) - } - if err := json.Compact(&wantBuf, []byte(want)); err != nil { - return fmt.Errorf("failed to compact want JSON: %w", err) - } - - if !bytes.Equal(gotBuf.Bytes(), wantBuf.Bytes()) { - return fmt.Errorf("JSON mismatch:\ngot: %s\nwant: %s", gotBuf.String(), wantBuf.String()) - } - return nil -} - -// InstallPrometheusOperator installs the prometheus Operator to be used to export the enabled metrics. -func InstallPrometheusOperator(ctx context.Context) error { - cmd := exec.CommandContext(ctx, "kubectl", "create", "-f", prometheusURL) - _, err := Run(cmd) - return err -} - -// UninstallPrometheusOperator uninstalls the prometheus -func UninstallPrometheusOperator(ctx context.Context) { - cmd := exec.CommandContext(ctx, "kubectl", "delete", "-f", prometheusURL) - if _, err := Run(cmd); err != nil { - warnError(err) - } -} - -// IsPrometheusCRDsInstalled checks if any Prometheus CRDs are installed -// by verifying the existence of key CRDs related to Prometheus. -func IsPrometheusCRDsInstalled(ctx context.Context) bool { - // List of common Prometheus CRDs - prometheusCRDs := []string{ - "prometheuses.monitoring.coreos.com", - "prometheusrules.monitoring.coreos.com", - "prometheusagents.monitoring.coreos.com", - } - - cmd := exec.CommandContext(ctx, "kubectl", "get", "crds", "-o", "custom-columns=NAME:.metadata.name") - output, err := Run(cmd) - if err != nil { - return false - } - crdList := GetNonEmptyLines(output) - for _, crd := range prometheusCRDs { - for _, line := range crdList { - if strings.Contains(line, crd) { - return true - } - } - } - - return false -} - -// InstallCertManager installs the cert manager bundle. -func InstallCertManager(ctx context.Context) error { - cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", certmanagerURL) - if _, err := Run(cmd); err != nil { - return err - } - // Wait for cert-manager-webhook to be ready, which can take time if cert-manager - // was re-installed after uninstalling on a cluster. - cmd = exec.CommandContext( - ctx, "kubectl", "wait", "deployment.apps/cert-manager-webhook", - "--for", "condition=Available", - "--namespace", "cert-manager", - "--timeout", "5m", - ) - - _, err := Run(cmd) - return err -} - -// UninstallCertManager uninstalls the cert manager -func UninstallCertManager(ctx context.Context) { - cmd := exec.CommandContext(ctx, "kubectl", "delete", "-f", certmanagerURL) - if _, err := Run(cmd); err != nil { - warnError(err) - } -} - -// IsCertManagerCRDsInstalled checks if any Cert Manager CRDs are installed -// by verifying the existence of key CRDs related to Cert Manager. -func IsCertManagerCRDsInstalled(ctx context.Context) bool { - // List of common Cert Manager CRDs - certManagerCRDs := []string{ - "certificates.cert-manager.io", - "issuers.cert-manager.io", - "clusterissuers.cert-manager.io", - "certificaterequests.cert-manager.io", - "orders.acme.cert-manager.io", - "challenges.acme.cert-manager.io", - } - - // Execute the kubectl command to get all CRDs - cmd := exec.CommandContext(ctx, "kubectl", "get", "crds") - output, err := Run(cmd) - if err != nil { - return false - } - - // Check if any of the Cert Manager CRDs are present - crdList := GetNonEmptyLines(output) - for _, crd := range certManagerCRDs { - for _, line := range crdList { - if strings.Contains(line, crd) { - return true - } - } - } - - return false -} - -// LoadImageToKindClusterWithName loads a local docker image to the kind cluster -func LoadImageToKindClusterWithName(ctx context.Context, name string) error { - cluster := "kind" - if v, ok := os.LookupEnv("KIND_CLUSTER"); ok { - cluster = v - } - // See: https://kind.sigs.k8s.io/docs/user/rootless/#creating-a-kind-cluster-with-rootless-nerdctl - prov, ok := os.LookupEnv("KIND_EXPERIMENTAL_PROVIDER") - if ok && prov != "docker" { - // If kind is configured to not use the docker runtime (e.g. when using podman or nerctl), - // we need to create a temp file to store the image archive and load it as a tarball. - // See: https://github.com/kubernetes-sigs/kind/issues/2760 - file, err := os.CreateTemp("", "operator-image-") - if err != nil { - return fmt.Errorf("failed to create temp file: %w", err) - } - _ = file.Close() - // #nosec G703 - defer func() { _ = os.Remove(file.Name()) }() - - // https://github.com/containerd/nerdctl/blob/main/docs/command-reference.md#whale-nerdctl-save - // https://docs.podman.io/en/v5.3.0/markdown/podman-save.1.html - // #nosec G702 - cmd := exec.CommandContext(ctx, prov, "save", name, "--output", file.Name()) - if _, err = Run(cmd); err != nil { - return fmt.Errorf("failed to save image: %w", err) - } - - cmd = exec.CommandContext(ctx, "kind", "load", "image-archive", file.Name(), "--name", cluster) //nolint:gosec - _, err = Run(cmd) - return err - } - cmd := exec.CommandContext(ctx, "kind", "load", "docker-image", name, "--name", cluster) - _, err := Run(cmd) - return err -} - -// GetNonEmptyLines converts given command output string into individual objects -// according to line breakers, and ignores the empty elements in it. -func GetNonEmptyLines(output string) []string { - var res []string - for element := range strings.SplitSeq(output, "\n") { - if element != "" { - res = append(res, element) - } - } - return res -} - -// GetProjectDir will return the directory where the project is -func GetProjectDir() (string, error) { - wd, err := os.Getwd() - if err != nil { - return wd, err - } - wd = strings.ReplaceAll(wd, "/test/e2e", "") - return wd, nil -} - -// UncommentCode searches for target in the file and remove the comment prefix -// of the target content. The target content may span multiple lines. -func UncommentCode(filename, target, prefix string) error { - content, err := os.ReadFile(filename) - if err != nil { - return err - } - - before, after, ok := bytes.Cut(content, []byte(target)) - if !ok { - if bytes.Contains(content, []byte(target)[len(prefix):]) { - return nil // already uncommented - } - - return fmt.Errorf("unable to find the code %s to be uncomment", target) - } - - out := new(bytes.Buffer) - if _, err = out.Write(before); err != nil { - return err - } - - scanner := bufio.NewScanner(bytes.NewBufferString(target)) - if !scanner.Scan() { - return nil - } - for { - _, err = out.WriteString(strings.TrimPrefix(scanner.Text(), prefix)) - if err != nil { - return err - } - // Avoid writing a newline in case the previous line was the last in target. - if !scanner.Scan() { - break - } - if _, err = out.WriteString("\n"); err != nil { - return err - } - } - - if _, err = out.Write(after); err != nil { - return err - } - - return os.WriteFile(filename, out.Bytes(), 0o644) -} diff --git a/test/gnmi/Dockerfile b/test/gnmi/Dockerfile index d80e16ac4..bdc864cc6 100644 --- a/test/gnmi/Dockerfile +++ b/test/gnmi/Dockerfile @@ -12,17 +12,14 @@ ARG TARGETARCH WORKDIR /workspace -# Install dependencies -RUN --mount=type=cache,target=/go/pkg/mod \ - --mount=type=bind,source=go.mod,target=go.mod \ - --mount=type=bind,source=go.sum,target=go.sum \ - go mod download -x +# Copy source files +COPY go.mod go.sum ./ +RUN go mod download -x + +COPY . . # Build the application into a static executable while removing the symbol table and debugging information -RUN --mount=type=bind,target=. \ - --mount=type=cache,target=/go/pkg/mod \ - --mount=type=cache,target=/root/.cache/go-build \ - CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-s -w" -o /usr/bin/server ./main.go +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -ldflags="-s -w" -o /usr/bin/server ./main.go FROM alpine:${ALPINE_VERSION} @@ -39,5 +36,5 @@ USER 65532:65532 # Switch into workspace WORKDIR / -# Start the server application -CMD ["/server", "--port=9339", "--http-port=8000"] +# Start the server application with NX-OS behavior enabled +CMD ["/server", "--port=9339", "--http-port=8000", "--nxos"] diff --git a/test/gnmi/main.go b/test/gnmi/main.go index 37e59e21b..eb5408639 100644 --- a/test/gnmi/main.go +++ b/test/gnmi/main.go @@ -18,19 +18,25 @@ func main() { // Parse command line flags port := flag.Int("port", 9339, "The gRPC server port") httpPort := flag.Int("http-port", 8000, "The HTTP server port") + nxos := flag.Bool("nxos", false, "Enable NX-OS behavior (strip DME markers)") flag.Parse() ctx, cancel := context.WithCancel(context.Background()) defer cancel() - // Start the server using the reusable NewTestServer function - // Bind to 0.0.0.0 to accept connections from other pods in the cluster - server, grpcAddr, httpAddr, err := testserver.NewTestServer( - ctx, + // Build server options + opts := []testserver.ServerOption{ testserver.WithGRPCPort(*port), testserver.WithHTTPPort(*httpPort), testserver.WithBindAddress("0.0.0.0"), - ) + } + if *nxos { + opts = append(opts, testserver.WithNXOSBehavior()) + } + + // Start the server using the reusable NewTestServer function + // Bind to 0.0.0.0 to accept connections from other pods in the cluster + server, grpcAddr, httpAddr, err := testserver.NewTestServer(ctx, opts...) if err != nil { log.Fatalf("Failed to start server: %v", err) } diff --git a/test/gnmi/testserver/server.go b/test/gnmi/testserver/server.go index 8578f476d..c8a7aa734 100644 --- a/test/gnmi/testserver/server.go +++ b/test/gnmi/testserver/server.go @@ -146,6 +146,7 @@ func NewTestServer(ctx context.Context, opts ...ServerOption) (*Server, string, // Setup HTTP server mux := http.NewServeMux() mux.HandleFunc("/v1/state", server.handleState) + mux.HandleFunc("/v1/clear", server.handleClear) server.httpServer = &http.Server{Handler: mux} // Start HTTP server in a goroutine @@ -347,8 +348,16 @@ func (s *Server) Subscribe(stream grpc.BidiStreamingServer[gpb.SubscribeRequest, } // handleState handles HTTP requests to the /v1/state endpoint +// GET: returns current state as JSON +// POST: preloads nested JSON into state +// DELETE: clears all state +// Supports X-HTTP-Method-Override header for clients that can't send DELETE. func (s *Server) handleState(w http.ResponseWriter, r *http.Request) { - switch r.Method { + method := r.Method + if override := r.Header.Get("X-HTTP-Method-Override"); override != "" { + method = override + } + switch method { case http.MethodGet: state, err := s.GetState() if err != nil { @@ -360,6 +369,25 @@ func (s *Server) handleState(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) w.Write(state) + case http.MethodPost: + body, err := io.ReadAll(r.Body) + if err != nil { + log.Printf("Failed to read body: %v", err) + http.Error(w, "Failed to read body", http.StatusBadRequest) + return + } + if len(body) == 0 { + w.WriteHeader(http.StatusNoContent) + return + } + if !gjson.ValidBytes(body) { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + // Use Set with empty path to merge JSON into root of state + s.State.Set(&gpb.Path{}, body) + log.Printf("Merged state from JSON") + w.WriteHeader(http.StatusNoContent) case http.MethodDelete: s.ClearState() w.WriteHeader(http.StatusNoContent) @@ -368,6 +396,31 @@ func (s *Server) handleState(w http.ResponseWriter, r *http.Request) { } } +// handleClear handles POST /v1/clear to clear all state. +func (s *Server) handleClear(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + s.ClearState() + w.WriteHeader(http.StatusNoContent) +} + +// mergeJSON merges src JSON into dst JSON at the root level. +// Keys in src overwrite keys in dst. +func mergeJSON(dst, src []byte) []byte { + srcParsed := gjson.ParseBytes(src) + if !srcParsed.IsObject() { + return src + } + result := dst + srcParsed.ForEach(func(key, value gjson.Result) bool { + result, _ = sjson.SetRawBytes(result, key.String(), []byte(value.Raw)) + return true + }) + return result +} + // State represents a JSON body that can be manipulated using [sjson] syntax. type State struct { sync.RWMutex @@ -468,6 +521,17 @@ func (s *State) Set(path *gpb.Path, raw []byte) { raw = s.stripMarkerFields(raw) elems := path.GetElem() + + // Handle empty path - merge raw into state at root level + if len(elems) == 0 { + if len(s.Buf) == 0 { + s.Buf = raw + } else { + s.Buf = mergeJSON(s.Buf, raw) + } + return + } + var sb strings.Builder for i, elem := range elems { diff --git a/test/integration/AGENTS.md b/test/integration/AGENTS.md deleted file mode 100644 index 15f9fce7e..000000000 --- a/test/integration/AGENTS.md +++ /dev/null @@ -1,95 +0,0 @@ -# Integration Tests - -Tests CRD → gNMI JSON flow using envtest + in-process gNMI server. - -## Architecture - -``` -envtest (in-process K8s API) - ↓ -Real Controllers (PrefixSet, RoutingPolicy, Interface) - ↓ -Provider (NX-OS, IOS-XR, etc.) - ↓ -In-process gNMI Test Server (accumulates state as JSON) -``` - -## Multi-Provider Testing - -Tests run for all providers that have testdata. Each provider gets its own -`Describe` block with isolated controller manager. - -``` -testdata/ -├── nxos/ # Cisco NX-OS provider tests -│ ├── interfaces.txt -│ └── routingpolicy_prefixset.txt -└── iosxr/ # Cisco IOS-XR provider tests (when added) - └── ... -``` - -To add tests for a new provider: -1. Create `testdata//` directory -2. Add txtar test files with CRD YAML + expected gNMI JSON -3. The provider will be automatically discovered and tested - -## Testdata Format (txtar) - -``` --- kind/name -- -apiVersion: networking.metal.ironcore.dev/v1alpha1 -kind: PrefixSet -metadata: - name: my-prefixset # K8s object name (can differ from spec.name) - namespace: default -spec: - deviceRef: - name: device # substituted at runtime with generated device name - name: MY-PREFIXSET # Device/gNMI name (used in expected state) - ... --- state -- -{ - "System": { ... expected gNMI JSON ... } -} -``` - -- Resources are created in order listed -- `deviceRef.name: device` is auto-replaced with the test's unique device name -- K8s `metadata.name` should differ from `spec.name` to verify correct field usage -- Comment expected state to explain why each gNMI path is present - -## Resource Dependencies - -Some resources depend on others (e.g., BGPPeer requires BGP, NVE requires Interface). -**Pack dependent resources into a single test file** rather than relying on test ordering. - -Examples: -- `bgp_bgppeer.txt` - Interface (loopback) + BGPConfig + BGP + BGPPeer -- `nve.txt` - Interface (loopback) + NVE -- `ospf.txt` - Interface + OSPF -- `evpninstance.txt` - VRF + EVPNInstance - -This ensures tests are self-contained and don't depend on alphabetical execution order. - -## Adding New Tests - -1. Create `testdata//.txt` with resource YAML + expected JSON state -2. Tests are auto-discovered from `*.txt` files in the provider directory -3. If new resource type, add GVK to `resourceRegistry` in `main_test.go` - -## Key Behaviors - -- **Condition check**: Interface uses `ConfiguredCondition`, others use `ReadyCondition` -- **JSON comparison**: Semantic (key order independent) -- **Cleanup**: Finalizers removed in two passes to avoid controller conflicts -- **Sequential creation**: Resources created and waited-for in order (handles dependencies) - -## Run - -```bash -# Run all provider tests -KUBEBUILDER_ASSETS=$(setup-envtest use 1.35 -p path) go test ./test/integration/... - -# Verbose output -KUBEBUILDER_ASSETS=$(setup-envtest use 1.35 -p path) go test ./test/integration/... -v -``` diff --git a/test/integration/main_test.go b/test/integration/main_test.go deleted file mode 100644 index c057a212a..000000000 --- a/test/integration/main_test.go +++ /dev/null @@ -1,347 +0,0 @@ -// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors -// SPDX-License-Identifier: Apache-2.0 - -package integration - -import ( - "bytes" - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "golang.org/x/tools/txtar" - - apierrors "k8s.io/apimachinery/pkg/api/errors" - "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/apimachinery/pkg/runtime/schema" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/yaml" - - nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" - "github.com/ironcore-dev/network-operator/api/core/v1alpha1" -) - -// resourceRegistry maps txtar file prefixes to GVKs for cleanup ordering. -// Resources are deleted in reverse order (last registered first). -var resourceRegistry = []schema.GroupVersionKind{ - v1alpha1.GroupVersion.WithKind("Interface"), - v1alpha1.GroupVersion.WithKind("VLAN"), - v1alpha1.GroupVersion.WithKind("VRF"), - v1alpha1.GroupVersion.WithKind("NTP"), - v1alpha1.GroupVersion.WithKind("DNS"), - v1alpha1.GroupVersion.WithKind("LLDP"), - v1alpha1.GroupVersion.WithKind("Banner"), - v1alpha1.GroupVersion.WithKind("OSPF"), - v1alpha1.GroupVersion.WithKind("PIM"), - v1alpha1.GroupVersion.WithKind("NetworkVirtualizationEdge"), - v1alpha1.GroupVersion.WithKind("EVPNInstance"), - nxv1alpha1.GroupVersion.WithKind("InterfaceConfig"), - nxv1alpha1.GroupVersion.WithKind("LLDPConfig"), - nxv1alpha1.GroupVersion.WithKind("VPCDomain"), - nxv1alpha1.GroupVersion.WithKind("BGPConfig"), - v1alpha1.GroupVersion.WithKind("RoutingPolicy"), - v1alpha1.GroupVersion.WithKind("PrefixSet"), - v1alpha1.GroupVersion.WithKind("BGP"), - v1alpha1.GroupVersion.WithKind("BGPPeer"), - v1alpha1.GroupVersion.WithKind("Syslog"), - v1alpha1.GroupVersion.WithKind("SNMP"), - v1alpha1.GroupVersion.WithKind("ManagementAccess"), - v1alpha1.GroupVersion.WithKind("AccessControlList"), - v1alpha1.GroupVersion.WithKind("DHCPRelay"), - v1alpha1.GroupVersion.WithKind("ISIS"), - // Add new resource types here as needed -} - -var _ = Describe("Integration", func() { - for _, providerCfg := range SupportedProviders { - // Skip providers that don't have testdata - testdataDir := filepath.Join("testdata", string(providerCfg.Name)) - if _, err := os.Stat(testdataDir); os.IsNotExist(err) { - continue - } - - Describe(fmt.Sprintf("Provider: %s", providerCfg.Name), Ordered, func() { - var ptc *ProviderTestContext - var device *v1alpha1.Device - var deviceName string - - BeforeAll(func() { - By(fmt.Sprintf("setting up %s provider", providerCfg.Name)) - ptc = SetupProviderTest(providerCfg) - }) - - AfterAll(func() { - By(fmt.Sprintf("tearing down %s provider", providerCfg.Name)) - TeardownProviderTest(ptc) - }) - - BeforeEach(func() { - By("creating a test device with unique name") - var err error - device, err = CreateTestDevice(ctx, k8sClient, "") - Expect(err).NotTo(HaveOccurred()) - deviceName = device.Name - }) - - AfterEach(func() { - By("cleaning up resources") - cleanupAllResources() - - // Delete the device and wait for it to be gone - if device != nil { - Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, device))).To(Succeed()) - Eventually(func(g Gomega) { - err := k8sClient.Get(ctx, client.ObjectKeyFromObject(device), &v1alpha1.Device{}) - g.Expect(client.IgnoreNotFound(err)).To(Succeed()) - g.Expect(err).To(HaveOccurred(), "Device should be deleted") - }).Should(Succeed()) - } - - // Clear gNMI state AFTER all resources are deleted and controllers have settled. - By("clearing gNMI state for next test") - time.Sleep(100 * time.Millisecond) - ClearGNMIState() - }) - - // Discover test files for this provider - testFiles, err := filepath.Glob(filepath.Join(testdataDir, "*.txt")) - if err != nil { - Fail(fmt.Sprintf("Failed to glob testdata: %v", err)) - } - - for _, testFile := range testFiles { - testName := filepath.Base(testFile) - testName = testName[:len(testName)-4] // remove .txt - - It("should reconcile "+testName, func(ctx SpecContext) { - By("parsing testdata file") - a, err := txtar.ParseFile(testFile) - Expect(err).NotTo(HaveOccurred(), "Failed to parse test file: %s", testFile) - Expect(len(a.Files)).To(BeNumerically(">=", 2), "Expected at least 2 files (resource(s) and state)") - - // Separate resources from state - var stateData []byte - var resources []txtar.File - for _, f := range a.Files { - if f.Name == "state" { - stateData = f.Data - } else { - resources = append(resources, f) - } - } - Expect(stateData).NotTo(BeEmpty(), "Expected '-- state --' section in testdata") - Expect(resources).NotTo(BeEmpty(), "Expected at least one resource in testdata") - - By(fmt.Sprintf("creating %d resource(s) from testdata", len(resources))) - for _, res := range resources { - obj := createResource(ctx, res, deviceName) - // Wait for each resource to be Ready before creating the next one. - waitForReady(ctx, obj) - } - - By("verifying gNMI state matches expected JSON") - state, err := GetGNMIState() - Expect(err).NotTo(HaveOccurred()) - - err = CompareJSON(string(state), string(stateData)) - Expect(err).NotTo(HaveOccurred(), "gNMI state does not match expected JSON") - }) - } - }) - } -}) - -// createResource creates a K8s resource from txtar file data. -// The file name format is "kind/name" (e.g., "prefixset/my-prefixset"). -// It substitutes "device" in deviceRef.name with the actual device name. -func createResource(ctx SpecContext, res txtar.File, deviceName string) client.Object { - obj := &unstructured.Unstructured{} - Expect(yaml.Unmarshal(res.Data, obj)).To(Succeed(), "Failed to unmarshal %s", res.Name) - - // Update deviceRef.name to use the actual device name - if spec, ok := obj.Object["spec"].(map[string]any); ok { - if deviceRef, ok := spec["deviceRef"].(map[string]any); ok { - deviceRef["name"] = deviceName - } - } - // Also update the device label - labels := obj.GetLabels() - if labels != nil { - if _, ok := labels[v1alpha1.DeviceLabel]; ok { - labels[v1alpha1.DeviceLabel] = deviceName - obj.SetLabels(labels) - } - } - - Expect(k8sClient.Create(ctx, obj)).To(Succeed(), "Failed to create %s", res.Name) - return obj -} - -// waitForReady waits for a resource to have Configured=True condition. -// Skips resources that don't have status conditions (e.g., InterfaceConfig). -func waitForReady(ctx SpecContext, obj client.Object) { - key := client.ObjectKeyFromObject(obj) - gvk := obj.GetObjectKind().GroupVersionKind() - - // Config-only resources don't have status conditions. - // They are just referenced by other resources, not reconciled independently. - switch gvk.Kind { - case "InterfaceConfig", "LLDPConfig", "BGPConfig", "NVEConfig", "ManagementAccessConfig": - return - } - - Eventually(func(g Gomega) { - // Fetch fresh copy - fresh := &unstructured.Unstructured{} - fresh.SetGroupVersionKind(gvk) - g.Expect(k8sClient.Get(ctx, key, fresh)).To(Succeed()) - - // Extract conditions from status - conditions, found, err := unstructured.NestedSlice(fresh.Object, "status", "conditions") - g.Expect(err).NotTo(HaveOccurred()) - g.Expect(found).To(BeTrue(), "Conditions should be set for %s/%s", gvk.Kind, key.Name) - g.Expect(conditions).NotTo(BeEmpty(), "Conditions should not be empty for %s/%s", gvk.Kind, key.Name) - - // Check for Configured=True (or Ready=True for resources without sub-conditions) - for _, c := range conditions { - cond, ok := c.(map[string]any) - if !ok { - continue - } - // Interface has sub-conditions; check Configured instead of Ready - if cond["type"] == v1alpha1.ConfiguredCondition { - g.Expect(cond["status"]).To(Equal(string(metav1.ConditionTrue)), - "%s/%s should be Configured, got reason: %v, message: %v", - gvk.Kind, key.Name, cond["reason"], cond["message"]) - return - } - } - // Fallback: check Ready for resources without ConfiguredCondition (e.g., PrefixSet) - for _, c := range conditions { - cond, ok := c.(map[string]any) - if !ok { - continue - } - if cond["type"] == v1alpha1.ReadyCondition { - g.Expect(cond["status"]).To(Equal(string(metav1.ConditionTrue)), - "%s/%s should be Ready, got reason: %v, message: %v", - gvk.Kind, key.Name, cond["reason"], cond["message"]) - } - } - }).Should(Succeed()) -} - -// cleanupAllResources deletes all test resources in the correct order. -// It forcibly removes finalizers if deletion is blocked. -func cleanupAllResources() { - for _, gvk := range resourceRegistry { - list := &unstructured.UnstructuredList{} - list.SetGroupVersionKind(schema.GroupVersionKind{ - Group: gvk.Group, - Version: gvk.Version, - Kind: gvk.Kind + "List", - }) - - if err := k8sClient.List(ctx, list, client.InNamespace(metav1.NamespaceDefault)); err != nil { - if meta.IsNoMatchError(err) { - continue // CRD not installed, skip - } - Expect(err).NotTo(HaveOccurred(), "Failed to list %s", gvk.Kind) - } - - // First pass: remove ALL finalizers to prevent controller re-reconciliation - for i := range list.Items { - item := &list.Items[i] - if len(item.GetFinalizers()) > 0 { - item.SetFinalizers(nil) - err := k8sClient.Update(ctx, item) - if apierrors.IsConflict(err) { - fresh := &unstructured.Unstructured{} - fresh.SetGroupVersionKind(item.GroupVersionKind()) - Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(item), fresh)).To(Succeed()) - fresh.SetFinalizers(nil) - Expect(k8sClient.Update(ctx, fresh)).To(Succeed()) - } else { - Expect(err).NotTo(HaveOccurred()) - } - } - } - - // Second pass: delete all resources - for i := range list.Items { - item := &list.Items[i] - Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, item))).To(Succeed()) - } - - // Wait for deletion - Eventually(func(g Gomega) { - freshList := &unstructured.UnstructuredList{} - freshList.SetGroupVersionKind(list.GetObjectKind().GroupVersionKind()) - g.Expect(k8sClient.List(ctx, freshList, client.InNamespace(metav1.NamespaceDefault))).To(Succeed()) - g.Expect(freshList.Items).To(BeEmpty(), "%s resources should be deleted", gvk.Kind) - }).Should(Succeed()) - } -} - -// CompareJSON compares two JSON strings semantically (ignoring key order and array order). -func CompareJSON(got, want string) error { - var gotData, wantData any - if err := json.Unmarshal([]byte(got), &gotData); err != nil { - return fmt.Errorf("failed to parse got JSON: %w", err) - } - if err := json.Unmarshal([]byte(want), &wantData); err != nil { - return fmt.Errorf("failed to parse want JSON: %w", err) - } - - // Normalize both by sorting arrays recursively - gotNorm := normalizeJSON(gotData) - wantNorm := normalizeJSON(wantData) - - gotBytes, err := json.Marshal(gotNorm) - if err != nil { - return fmt.Errorf("failed to normalize got JSON: %w", err) - } - wantBytes, err := json.Marshal(wantNorm) - if err != nil { - return fmt.Errorf("failed to normalize want JSON: %w", err) - } - - if !bytes.Equal(gotBytes, wantBytes) { - return fmt.Errorf("JSON mismatch:\ngot: %s\nwant: %s", string(gotBytes), string(wantBytes)) - } - return nil -} - -// normalizeJSON recursively sorts arrays and returns a normalized structure. -func normalizeJSON(v any) any { - switch val := v.(type) { - case map[string]any: - result := make(map[string]any, len(val)) - for k, v := range val { - result[k] = normalizeJSON(v) - } - return result - case []any: - // Normalize each element first - normalized := make([]any, len(val)) - for i, elem := range val { - normalized[i] = normalizeJSON(elem) - } - // Sort the array by JSON representation - sort.Slice(normalized, func(i, j int) bool { - bi, _ := json.Marshal(normalized[i]) //nolint:errcheck // comparison only - bj, _ := json.Marshal(normalized[j]) //nolint:errcheck // comparison only - return string(bi) < string(bj) - }) - return normalized - default: - return v - } -} diff --git a/test/integration/suite_test.go b/test/integration/suite_test.go deleted file mode 100644 index b02a028a0..000000000 --- a/test/integration/suite_test.go +++ /dev/null @@ -1,496 +0,0 @@ -// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors -// SPDX-License-Identifier: Apache-2.0 - -package integration - -import ( - "context" - "crypto/tls" - "errors" - "os" - "path/filepath" - "slices" - "testing" - "time" - - . "github.com/onsi/ginkgo/v2" - . "github.com/onsi/gomega" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials" - - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes/scheme" - "k8s.io/client-go/rest" - "k8s.io/client-go/tools/events" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/client" - "sigs.k8s.io/controller-runtime/pkg/envtest" - logf "sigs.k8s.io/controller-runtime/pkg/log" - "sigs.k8s.io/controller-runtime/pkg/log/zap" - - nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" - "github.com/ironcore-dev/network-operator/api/core/v1alpha1" - nx "github.com/ironcore-dev/network-operator/internal/controller/cisco/nx" - "github.com/ironcore-dev/network-operator/internal/controller/core" - "github.com/ironcore-dev/network-operator/internal/provider" - "github.com/ironcore-dev/network-operator/internal/provider/cisco/iosxr" - "github.com/ironcore-dev/network-operator/internal/provider/cisco/nxos" - "github.com/ironcore-dev/network-operator/internal/resourcelock" - - gnmitestserver "github.com/ironcore-dev/gnmi-test-server/testserver" -) - -// ProviderType represents the network device provider to test against. -type ProviderType string - -// ProviderFactory creates a new provider instance. -type ProviderFactory = func() provider.Provider - -const ( - ProviderNXOS ProviderType = "nxos" - ProviderIOSXR ProviderType = "iosxr" -) - -// ProviderConfig holds the configuration for a provider test. -type ProviderConfig struct { - Name ProviderType - NewProvider ProviderFactory -} - -// SupportedProviders lists all providers to test. -var SupportedProviders = []ProviderConfig{ - {Name: ProviderNXOS, NewProvider: func() provider.Provider { return nxos.NewProvider() }}, - {Name: ProviderIOSXR, NewProvider: func() provider.Provider { return iosxr.NewProvider() }}, -} - -var ( - ctx context.Context - cancel context.CancelFunc - testEnv *envtest.Environment - k8sClient client.Client - restCfg *rest.Config - - // gNMI test server - gnmiServer *gnmitestserver.Server - gnmiAddr string -) - -func TestIntegration(t *testing.T) { - RegisterFailHandler(Fail) - RunSpecs(t, "Integration Suite") -} - -var _ = BeforeSuite(func() { - logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) - - SetDefaultEventuallyTimeout(30 * time.Second) - SetDefaultEventuallyPollingInterval(time.Second) - - ctx, cancel = context.WithCancel(ctrl.SetupSignalHandler()) - - By("starting in-process gNMI test server") - var err error - gnmiServer, gnmiAddr, _, err = gnmitestserver.NewTestServer(ctx, gnmitestserver.WithNXOSBehavior()) - Expect(err).NotTo(HaveOccurred()) - GinkgoLogr.Info("gNMI server started", "grpc", gnmiAddr) - - By("bootstrapping test environment") - err = corev1.AddToScheme(scheme.Scheme) - Expect(err).NotTo(HaveOccurred()) - - err = v1alpha1.AddToScheme(scheme.Scheme) - Expect(err).NotTo(HaveOccurred()) - - err = nxv1alpha1.AddToScheme(scheme.Scheme) - Expect(err).NotTo(HaveOccurred()) - - testEnv = &envtest.Environment{ - CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, - ErrorIfCRDPathMissing: true, - } - - // Retrieve the first found binary directory to allow running tests from IDEs - if dir := detectTestBinaryDir(); dir != "" { - testEnv.BinaryAssetsDirectory = dir - } - - restCfg, err = testEnv.Start() - Expect(err).NotTo(HaveOccurred()) - Expect(restCfg).NotTo(BeNil()) - - k8sClient, err = client.New(restCfg, client.Options{Scheme: scheme.Scheme}) - Expect(err).NotTo(HaveOccurred()) - Expect(k8sClient).NotTo(BeNil()) - - Eventually(func() error { - var namespace corev1.Namespace - return k8sClient.Get(ctx, client.ObjectKey{Name: metav1.NamespaceDefault}, &namespace) - }).Should(Succeed()) -}) - -var _ = AfterSuite(func() { - By("tearing down the test environment") - cancel() - - var errs []error - if gnmiServer != nil { - if err := gnmiServer.Close(); err != nil { - errs = append(errs, err) - } - } - - if testEnv != nil { - if err := testEnv.Stop(); err != nil { - errs = append(errs, err) - } - } - - Expect(errors.Join(errs...)).NotTo(HaveOccurred(), "errors during teardown") -}) - -// ProviderTestContext holds the context for a provider-specific test run. -type ProviderTestContext struct { - Provider ProviderType - Manager ctrl.Manager - Locker *resourcelock.ResourceLocker - CancelFunc context.CancelFunc -} - -// SetupProviderTest creates a new manager with controllers for the given provider. -// Call the returned cleanup function in AfterEach/AfterAll. -func SetupProviderTest(providerCfg ProviderConfig) *ProviderTestContext { - GinkgoHelper() - - providerCtx, providerCancel := context.WithCancel(ctx) //nolint:gosec // cancel stored in ProviderTestContext - - mgr, err := ctrl.NewManager(restCfg, ctrl.Options{ - Scheme: scheme.Scheme, - Logger: GinkgoLogr, - }) - Expect(err).ToNot(HaveOccurred()) - - // Ignore events during tests - recorder := events.NewFakeRecorder(0) - go func() { - for range recorder.Events { //nolint:revive // intentionally drain events - } - }() - - locker, err := resourcelock.NewResourceLocker(mgr.GetClient(), metav1.NamespaceDefault, 15*time.Second, 10*time.Second) - Expect(err).NotTo(HaveOccurred()) - - err = mgr.Add(locker) - Expect(err).NotTo(HaveOccurred()) - - providerFunc := providerCfg.NewProvider - - // Register the controllers - err = (&core.PrefixSetReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.RoutingPolicyReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.InterfaceReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - RequeueInterval: time.Minute, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.VLANReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - RequeueInterval: time.Minute, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.VRFReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.NTPReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.DNSReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.LLDPReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - RequeueInterval: time.Minute, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.BannerReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.OSPFReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - RequeueInterval: time.Minute, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.PIMReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.NetworkVirtualizationEdgeReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - RequeueInterval: time.Minute, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.EVPNInstanceReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - // NX-OS specific controllers - err = (&nx.VPCDomainReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - RequeueInterval: time.Minute, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.BGPReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - RequeueInterval: time.Minute, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.BGPPeerReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - RequeueInterval: time.Minute, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.SyslogReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.SNMPReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.ManagementAccessReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.AccessControlListReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.DHCPRelayReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - RequeueInterval: time.Minute, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - err = (&core.ISISReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - Recorder: recorder, - Provider: providerFunc, - Locker: locker, - }).SetupWithManager(providerCtx, mgr) - Expect(err).NotTo(HaveOccurred()) - - go func() { - defer GinkgoRecover() - err = mgr.Start(providerCtx) - if providerCtx.Err() == nil { - Expect(err).ToNot(HaveOccurred(), "failed to run manager") - } - }() - - return &ProviderTestContext{ - Provider: providerCfg.Name, - Manager: mgr, - Locker: locker, - CancelFunc: providerCancel, - } -} - -// TeardownProviderTest stops the manager for a provider test. -func TeardownProviderTest(ptc *ProviderTestContext) { - if ptc != nil && ptc.CancelFunc != nil { - ptc.CancelFunc() - } -} - -// detectTestBinaryDir locates the first directory in the k8s binary path. -func detectTestBinaryDir() string { - basePath := filepath.Join("..", "..", "bin", "k8s") - entries, err := os.ReadDir(basePath) - if err != nil { - return "" - } - idx := slices.IndexFunc(entries, func(e os.DirEntry) bool { - return e.IsDir() - }) - if idx >= 0 { - return filepath.Join(basePath, entries[idx].Name()) - } - return "" -} - -// GNMIAddr returns the gNMI server address for tests to use -func GNMIAddr() string { - return gnmiAddr -} - -// GetGNMIState returns the current accumulated gNMI state as JSON -func GetGNMIState() ([]byte, error) { - return gnmiServer.GetState() -} - -// ClearGNMIState clears the accumulated gNMI state -func ClearGNMIState() { - gnmiServer.ClearState() -} - -// NewGNMIConnection creates a gRPC connection to the in-process gNMI server -func NewGNMIConnection() (*grpc.ClientConn, error) { - return grpc.NewClient( - gnmiAddr, - grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{ - InsecureSkipVerify: true, //nolint:gosec - })), - ) -} - -// CreateTestDevice creates a Device pointing to the in-process gNMI server. -// If name is empty, a unique name is generated using GenerateName. -func CreateTestDevice(ctx context.Context, c client.Client, name string) (*v1alpha1.Device, error) { - device := &v1alpha1.Device{ - ObjectMeta: metav1.ObjectMeta{ - Namespace: metav1.NamespaceDefault, - }, - Spec: v1alpha1.DeviceSpec{ - Endpoint: v1alpha1.Endpoint{ - Address: gnmiAddr, - }, - }, - } - if name == "" { - device.GenerateName = "test-device-" - } else { - device.Name = name - } - if err := c.Create(ctx, device); err != nil { - return nil, err - } - - // Set the device status to Running so that dependent resources can reconcile - device.Status.Phase = v1alpha1.DevicePhaseRunning - if err := c.Status().Update(ctx, device); err != nil { - return nil, err - } - - return device, nil -} - -// TestdataPath returns the path to testdata for the given provider. -// Example: testdata/nxos/interfaces.txt -func TestdataPath(provider ProviderType, filename string) string { - return filepath.Join("testdata", string(provider), filename) -} diff --git a/test/integration/testdata/nxos/interfaceconfig.txt b/test/integration/testdata/nxos/interfaceconfig.txt deleted file mode 100644 index c6f24bc17..000000000 --- a/test/integration/testdata/nxos/interfaceconfig.txt +++ /dev/null @@ -1,187 +0,0 @@ -# NX-OS InterfaceConfig Integration Test -# -# Tests Cisco NX-OS provider-specific interface configuration via InterfaceConfig CRD: -# - SpanningTree: port type (edge/network), BPDU Guard, BPDU Filter -# - BufferBoost: enable/disable on physical interfaces -# - LACP options: VPC convergence, suspend individual -# -# Test topology: -# eth1/2 (Physical L2) - SpanningTree Edge + BPDUGuard, BufferBoost disabled -# po20 (Aggregate L2) - SpanningTree Network + BPDUFilter, LACP VPCConvergence, SuspendIndividual=false -# -# Expected gNMI paths: -# intf-items/phys-items/PhysIf-list[id=eth1/2]/physExtd-items/bufferBoost = "disable" -# intf-items/aggr-items/AggrIf-list[id=po20]/lacpVpcConvergence = "enable" -# intf-items/aggr-items/AggrIf-list[id=po20]/suspIndividual = "disable" -# stp-items/inst-items/if-items/If-list[id=eth1/2]/mode = "edge" -# stp-items/inst-items/if-items/If-list[id=eth1/2]/bpduguard = "enable" -# stp-items/inst-items/if-items/If-list[id=po20]/mode = "network" -# stp-items/inst-items/if-items/If-list[id=po20]/bpdufilter = "enable" - --- interfaceconfig/edge-port-config -- -apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 -kind: InterfaceConfig -metadata: - name: interface-nxconfig-edge - namespace: default -spec: - bufferBoost: - enabled: false - spanningTree: - portType: Edge - bpduGuard: true - --- interfaceconfig/network-pc-config -- -apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 -kind: InterfaceConfig -metadata: - name: interface-nxconfig-po - namespace: default -spec: - lacp: - vpcConvergence: true - suspendIndividual: false - spanningTree: - portType: Network - bpduFilter: true - --- interface/stp-edge-port -- -apiVersion: networking.metal.ironcore.dev/v1alpha1 -kind: Interface -metadata: - name: eth1-2 - namespace: default - labels: - networking.metal.ironcore.dev/device: device -spec: - deviceRef: - name: device - name: eth1/2 - type: Physical - adminState: Up - mtu: 1500 - description: "Edge port with STP and BufferBoost config" - providerConfigRef: - apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 - kind: InterfaceConfig - name: interface-nxconfig-edge - switchport: - mode: Trunk - nativeVlan: 1 - allowedVlans: - - 10 - --- interface/stp-network-pc -- -apiVersion: networking.metal.ironcore.dev/v1alpha1 -kind: Interface -metadata: - name: po-20 - namespace: default - labels: - networking.metal.ironcore.dev/device: device -spec: - deviceRef: - name: device - name: po20 - type: Aggregate - adminState: Up - mtu: 1500 - description: "Network port-channel with LACP and STP config" - providerConfigRef: - apiVersion: nx.cisco.networking.metal.ironcore.dev/v1alpha1 - kind: InterfaceConfig - name: interface-nxconfig-po - switchport: - mode: Trunk - nativeVlan: 1 - allowedVlans: - - 20 - aggregation: - controlProtocol: - mode: Active - memberInterfaceRefs: - - name: eth1-2 - --- state -- -{ - "System": { - "fm-items": { - "lacp-items": { - "adminSt": "enabled" - } - }, - "intf-items": { - "phys-items": { - "PhysIf-list": [ - { - "accessVlan": "vlan-1", - "adminSt": "up", - "descr": "Edge port with STP and BufferBoost config", - "FECMode": "auto", - "id": "eth1/2", - "layer": "Layer2", - "medium": "broadcast", - "mode": "trunk", - "mtu": 1500, - "nativeVlan": "vlan-1", - "trunkVlans": "10", - "userCfgdFlags": "admin_layer,admin_mtu,admin_state", - "physExtd-items": { - "bufferBoost": "disable" - } - } - ] - }, - "aggr-items": { - "AggrIf-list": [ - { - "accessVlan": "vlan-1", - "adminSt": "up", - "aggrExtd-items": { - "bufferBoost": "enable" - }, - "descr": "Network port-channel with LACP and STP config", - "id": "po20", - "lacpVpcConvergence": "enable", - "layer": "Layer2", - "medium": "broadcast", - "mode": "trunk", - "mtu": 1500, - "nativeVlan": "vlan-1", - "pcMode": "active", - "suspIndividual": "disable", - "trunkVlans": "20", - "userCfgdFlags": "admin_layer,admin_mtu,admin_state", - "rsmbrIfs-items": { - "RsMbrIfs-list": [ - { - "tDn": "/System/intf-items/phys-items/PhysIf-list[id='eth1/2']" - } - ] - } - } - ] - } - }, - "stp-items": { - "inst-items": { - "if-items": { - "If-list": [ - { - "id": "eth1/2", - "mode": "edge", - "bpdufilter": "default", - "bpduguard": "enable" - }, - { - "id": "po20", - "mode": "network", - "bpdufilter": "enable", - "bpduguard": "default" - } - ] - } - } - } - } -} diff --git a/test/integration/testdata/nxos/syslog.txt b/test/integration/testdata/nxos/syslog.txt deleted file mode 100644 index 4786db795..000000000 --- a/test/integration/testdata/nxos/syslog.txt +++ /dev/null @@ -1,91 +0,0 @@ -# NX-OS Syslog Integration Test -# -# Tests syslog configuration with remote log servers and facilities. -# -# Expected gNMI state mapping: -# -# logging-items/loglevel-items: Log level/facility configuration -# facility-items/Facility-list: spec.facilities -> facility severity levels -# - facilityName: Local7 facility.name -> facility name -# - severityLevel: information facility.severity=Info -> information -# -# syslog-items: Syslog service configuration -# logginghistory-items: Logging history buffer -# - level: information follows facility severity -# - size: 500 default buffer size -# originid-items: Syslog origin ID -# - idtype: string origin type -# - idvalue: logging K8s metadata.name used as origin ID -# rdst-items/RemoteDest-list: spec.servers -> remote syslog servers -# - host: 10.0.0.100 server.address -> destination host -# - severity: warnings server.severity=Warning -> warnings -# - vrfName: management server.vrfName -> VRF for reaching server -# - port: 514 default syslog port -# - transport: udp default transport -# - forwardingFacility: local7 default forwarding facility -# source-items: Syslog source interface -# - adminState: enabled syslog enabled -# - ifName: mgmt0 default source interface - --- syslog/logging -- -apiVersion: networking.metal.ironcore.dev/v1alpha1 -kind: Syslog -metadata: - name: logging - namespace: default - labels: - networking.metal.ironcore.dev/device: device -spec: - deviceRef: - name: device - servers: - - address: "10.0.0.100" - severity: Warning - vrfName: management - facilities: - - name: Local7 - severity: Info - --- state -- -{ - "System": { - "logging-items": { - "loglevel-items": { - "facility-items": { - "Facility-list": [ - { - "facilityName": "Local7", - "severityLevel": "information" - } - ] - } - } - }, - "syslog-items": { - "logginghistory-items": { - "level": "information", - "size": 500 - }, - "originid-items": { - "idtype": "string", - "idvalue": "logging" - }, - "rdst-items": { - "RemoteDest-list": [ - { - "forwardingFacility": "local7", - "host": "10.0.0.100", - "port": 514, - "severity": "warnings", - "transport": "udp", - "vrfName": "management" - } - ] - }, - "source-items": { - "adminState": "enabled", - "ifName": "mgmt0" - } - } - } -} diff --git a/test/integration/testdata/nxos/vrf.txt b/test/integration/testdata/nxos/vrf.txt deleted file mode 100644 index cd4e57af9..000000000 --- a/test/integration/testdata/nxos/vrf.txt +++ /dev/null @@ -1,42 +0,0 @@ -# NX-OS VRF Integration Test -# -# Tests VRF (Virtual Routing and Forwarding) configuration. -# -# Expected gNMI paths: -# inst-items/Inst-list - VRF instance configuration - --- vrf/tenant1 -- -apiVersion: networking.metal.ironcore.dev/v1alpha1 -kind: VRF -metadata: - name: tenant1 - namespace: default - labels: - networking.metal.ironcore.dev/device: device -spec: - deviceRef: - name: device - name: TENANT-1 - description: "Tenant 1 VRF" - --- state -- -{ - "System": { - "inst-items": { - "Inst-list": [ - { - "name": "TENANT-1", - "descr": "Tenant 1 VRF", - "l3vni": false, - "dom-items": { - "Dom-list": [ - { - "name": "TENANT-1" - } - ] - } - } - ] - } - } -}