From 6c3f4a62efa6ee449941ad3dd8d72a9fdd39bb82 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 9 Jul 2026 00:52:52 +0200 Subject: [PATCH] Remove gNMI functionality from e2e tests e2e tests should only evaluate the correct deployment of the operator as defined by the kubebuilder markers. Hence, in this commit we remove all the gNMI related infrastructure from the e2e tests. The current integration tests with the gNMI test server will be later moved into its own space within the project. The tests will use an `envtest` environment, removing the need to deploy a cluster to evaluate that the workflow from k8s resource to provider payload is correctly implemented. Hence, no need to have it as a module.The standalone main.go and Dockerfile are removed. We relocate the txtar test fixtures from test/e2e/testdata/ to test/gnmi/testdata/, where the gNMI integration tests will live. The default profile in kustomize now has prometheus enabled by default. With this change we can eliminate some code and remove the Makefile targets that modify the profile when running the e2e tests. Allow overriding the Prometheus Operator and cert-manager installation URLs via environment variables PROMETHEUS_OPERATOR_URL and CERT_MANAGER_URL. Signed-off-by: Pujol --- Makefile | 34 +-- Makefile.maker.yaml | 26 ++- .../controller-manager-metrics-monitor.yaml | 15 -- config/default/kustomization.yaml | 2 +- config/develop/gnmi-test-server.yaml | 83 ------- config/develop/kustomization.yaml | 1 - go.mod | 3 +- go.sum | 2 + test/e2e/e2e_suite_test.go | 22 +- test/e2e/e2e_test.go | 164 ++----------- test/e2e/util_test.go | 101 +++----- test/gnmi/Dockerfile | 43 ---- test/gnmi/go.mod | 20 -- test/gnmi/go.sum | 49 ---- .../testdata/openconfig}/banner.txt | 0 .../testdata/openconfig}/interface.txt | 0 .../interface_aggregate_l2_trunk.txt | 0 .../openconfig}/interface_aggregate_l3.txt | 0 .../interface_loopback_multi_addr.txt | 0 .../openconfig}/interface_physical_ipv4.txt | 0 .../interface_physical_switchport_access.txt | 0 .../interface_physical_switchport_trunk.txt | 0 .../interface_physical_unnumbered.txt | 0 .../openconfig}/interface_routed_vlan.txt | 0 test/gnmi/{main.go => testserver/server.go} | 220 +++++++++++------- 25 files changed, 231 insertions(+), 554 deletions(-) delete mode 100644 config/develop/gnmi-test-server.yaml delete mode 100644 test/gnmi/Dockerfile delete mode 100644 test/gnmi/go.mod delete mode 100644 test/gnmi/go.sum rename test/{e2e/testdata => gnmi/testdata/openconfig}/banner.txt (100%) rename test/{e2e/testdata => gnmi/testdata/openconfig}/interface.txt (100%) rename test/{e2e/testdata => gnmi/testdata/openconfig}/interface_aggregate_l2_trunk.txt (100%) rename test/{e2e/testdata => gnmi/testdata/openconfig}/interface_aggregate_l3.txt (100%) rename test/{e2e/testdata => gnmi/testdata/openconfig}/interface_loopback_multi_addr.txt (100%) rename test/{e2e/testdata => gnmi/testdata/openconfig}/interface_physical_ipv4.txt (100%) rename test/{e2e/testdata => gnmi/testdata/openconfig}/interface_physical_switchport_access.txt (100%) rename test/{e2e/testdata => gnmi/testdata/openconfig}/interface_physical_switchport_trunk.txt (100%) rename test/{e2e/testdata => gnmi/testdata/openconfig}/interface_physical_unnumbered.txt (100%) rename test/{e2e/testdata => gnmi/testdata/openconfig}/interface_routed_vlan.txt (100%) rename test/gnmi/{main.go => testserver/server.go} (65%) diff --git a/Makefile b/Makefile index 1c13ac2b4..6468cf3d6 100644 --- a/Makefile +++ b/Makefile @@ -36,6 +36,10 @@ default: build-all # Image to use all building/pushing image targets IMG ?= controller:latest +# E2E test dependency versions +E2E_PROMETHEUS_OPERATOR_VERSION ?= v0.82.2 +E2E_CERTMANAGER_VERSION ?= v1.17.2 + # CONTAINER_TOOL defines the container tool to be used for building images. # The default is docker, but it can be overridden to use other tools (i.e. podman or nerdctl). CONTAINER_TOOL ?= docker @@ -83,7 +87,19 @@ test-e2e: FORCE 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 + @KIND_CLUSTER=$(KIND_CLUSTER) \ + E2E_PROMETHEUS_OPERATOR_VERSION=$(E2E_PROMETHEUS_OPERATOR_VERSION) \ + E2E_CERTMANAGER_VERSION=$(E2E_CERTMANAGER_VERSION) \ + go test ./test/e2e/ -v -ginkgo.v + +# Run gNMI server unit tests +test-gnmi-unit: FORCE + @printf "\e[1;36m>> go test ./test/gnmi/testserver/...\e[0m\n" + @go test ./test/gnmi/testserver/... + +# Run gNMI integration tests (envtest-based) +test-gnmi: FORCE test-gnmi-unit + @printf "\e[1;33m>> gNMI integration tests not yet implemented\e[0m\n" docker-build: FORCE @printf "\e[1;36m>> $(CONTAINER_TOOL) build --tag=$(IMG) .\e[0m\n" @@ -159,16 +175,6 @@ netop-provider: @printf "\e[1;36m>> ./build/netop-provider --help\e[0m\n" @./build/netop-provider --help -TEST_SERVER_IMG ?= ghcr.io/ironcore-dev/gnmi-test-server:latest - -docker-build-test-gnmi-server: FORCE - @printf "\e[1;36m>> $(CONTAINER_TOOL) build --tag=$(TEST_SERVER_IMG) ./test/gnmi\e[0m\n" - @$(CONTAINER_TOOL) build --tag=$(TEST_SERVER_IMG) ./test/gnmi - -docker-run-test-gnmi-server: FORCE docker-build-test-gnmi-server - @printf "\e[1;36m>> $(CONTAINER_TOOL) run -p 8000:8000 -p 9339:9339 $(TEST_SERVER_IMG)\e[0m\n" - @$(CONTAINER_TOOL) run --rm -p 8000:8000 -p 9339:9339 $(TEST_SERVER_IMG) - # TEST_LAB_IMG defines the image to used for packaging the lab tests. TEST_LAB_IMG ?= ghcr.io/ironcore-dev/network-operator-lab-test:latest @@ -274,9 +280,9 @@ check: FORCE static-check build/cover.html build-all generate: install-controller-gen @printf "\e[1;36m>> controller-gen\e[0m\n" - @controller-gen crd rbac:roleName=manager-role webhook paths="./..." output:crd:artifacts:config=config/crd/bases output:rbac:artifacts:config=config/rbac - @controller-gen object:headerFile="hack/boilerplate.go.txt",year=$(YEAR) paths="./..." - @controller-gen applyconfiguration:headerFile="hack/boilerplate.go.txt" paths="./..." + @controller-gen crd rbac:roleName=manager-role webhook paths="{./api/...,./internal/...,./cmd/...}" output:crd:artifacts:config=config/crd/bases output:rbac:artifacts:config=config/rbac + @controller-gen object:headerFile="hack/boilerplate.go.txt",year=$(YEAR) paths="{./api/...,./internal/...,./cmd/...}" + @controller-gen applyconfiguration:headerFile="hack/boilerplate.go.txt" paths="{./api/...,./internal/...,./cmd/...}" run-golangci-lint: FORCE install-golangci-lint @printf "\e[1;36m>> golangci-lint\e[0m\n" diff --git a/Makefile.maker.yaml b/Makefile.maker.yaml index ca4d0a800..3077ddbc3 100644 --- a/Makefile.maker.yaml +++ b/Makefile.maker.yaml @@ -30,7 +30,7 @@ golangciLint: shellCheck: ignorePaths: - - 'docs/node_modules/*' + - "docs/node_modules/*" goReleaser: createConfig: true @@ -38,7 +38,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: @@ -54,7 +54,7 @@ renovate: - felix-kaestner testPackages: - except: '/test' + except: "/test" githubWorkflow: ci: @@ -69,12 +69,16 @@ githubWorkflow: enabled: false variables: - GO_BUILDENV: 'CGO_ENABLED=0' + GO_BUILDENV: "CGO_ENABLED=0" verbatim: | # Image to use all building/pushing image targets IMG ?= controller:latest + # E2E test dependency versions + E2E_PROMETHEUS_OPERATOR_VERSION ?= v0.82.2 + E2ECERTMANAGER_VERSION ?= v1.17.2 + # CONTAINER_TOOL defines the container tool to be used for building images. # The default is docker, but it can be overridden to use other tools (i.e. podman or nerdctl). CONTAINER_TOOL ?= docker @@ -122,7 +126,19 @@ verbatim: | 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 + @KIND_CLUSTER=$(KIND_CLUSTER) \ + E2E_PROMETHEUS_OPERATOR_VERSION=$(E2E_PROMETHEUS_OPERATOR_VERSION) \ + E2E_CERTMANAGER_VERSION=$(E2E_CERTMANAGER_VERSION) \ + go test ./test/e2e/ -v -ginkgo.v + + # Run gNMI server unit tests + test-gnmi-unit: FORCE + @printf "\e[1;36m>> go test ./test/gnmi/testserver/...\e[0m\n" + @go test ./test/gnmi/testserver/... + + # Run gNMI integration tests (envtest-based) + test-gnmi: FORCE test-gnmi-unit + @printf "\e[1;33m>> gNMI integration tests not yet implemented\e[0m\n" docker-build: FORCE @printf "\e[1;36m>> $(CONTAINER_TOOL) build --tag=$(IMG) .\e[0m\n" diff --git a/charts/network-operator/templates/prometheus/controller-manager-metrics-monitor.yaml b/charts/network-operator/templates/prometheus/controller-manager-metrics-monitor.yaml index 1c409fdde..50c9b922e 100644 --- a/charts/network-operator/templates/prometheus/controller-manager-metrics-monitor.yaml +++ b/charts/network-operator/templates/prometheus/controller-manager-metrics-monitor.yaml @@ -20,22 +20,7 @@ spec: scheme: {{ if .Values.metrics.secure }}https{{ else }}http{{ end }} {{- if .Values.metrics.secure }} tlsConfig: - serverName: {{ include "network-operator.resourceName" (dict "suffix" "controller-manager-metrics-service" "context" $) }}.{{ .Release.Namespace }}.svc - {{- if .Values.certManager.enabled }} - ca: - secret: - name: metrics-server-cert - key: ca.crt - cert: - secret: - name: metrics-server-cert - key: tls.crt - keySecret: - name: metrics-server-cert - key: tls.key - {{- else }} insecureSkipVerify: true - {{- end }} {{- end }} selector: matchLabels: 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 deleted file mode 100644 index 789b76681..000000000 --- a/config/develop/gnmi-test-server.yaml +++ /dev/null @@ -1,83 +0,0 @@ -apiVersion: apps/v1 -kind: Deployment -metadata: - name: gnmi-test-server - namespace: default - labels: - app.kubernetes.io/name: gnmi-test-server - app.kubernetes.io/instance: gnmi-test-server - app.kubernetes.io/version: latest - app.kubernetes.io/component: test-server - app.kubernetes.io/part-of: network-operator - app.kubernetes.io/managed-by: kustomize -spec: - selector: - matchLabels: - app.kubernetes.io/name: gnmi-test-server - app.kubernetes.io/instance: gnmi-test-server - replicas: 1 - template: - metadata: - labels: - app.kubernetes.io/name: gnmi-test-server - app.kubernetes.io/instance: gnmi-test-server - app.kubernetes.io/version: latest - app.kubernetes.io/component: test-server - app.kubernetes.io/part-of: network-operator - app.kubernetes.io/managed-by: kustomize - spec: - securityContext: - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault - containers: - - name: gnmi-test-server - image: ghcr.io/ironcore-dev/gnmi-test-server:latest - imagePullPolicy: IfNotPresent - ports: - - containerPort: 9339 - name: grpc - protocol: TCP - - containerPort: 8000 - name: http - protocol: TCP - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - "ALL" - resources: - requests: - cpu: 10m - memory: 32Mi - limits: - cpu: 100m - memory: 128Mi - terminationGracePeriodSeconds: 10 ---- -apiVersion: v1 -kind: Service -metadata: - name: gnmi-test-server - namespace: default - labels: - app.kubernetes.io/name: gnmi-test-server - app.kubernetes.io/instance: gnmi-test-server - app.kubernetes.io/version: latest - app.kubernetes.io/component: test-server - app.kubernetes.io/part-of: network-operator - app.kubernetes.io/managed-by: kustomize -spec: - selector: - app.kubernetes.io/name: gnmi-test-server - app.kubernetes.io/instance: gnmi-test-server - ports: - - name: grpc - port: 9339 - targetPort: 9339 - protocol: TCP - - name: http - port: 8000 - targetPort: 8000 - protocol: TCP - type: ClusterIP 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/go.mod b/go.mod index 13926e4ed..f7342c4e8 100644 --- a/go.mod +++ b/go.mod @@ -16,10 +16,10 @@ require ( github.com/sapcc/go-api-declarations v1.24.0 github.com/stretchr/testify v1.11.1 github.com/tidwall/gjson v1.19.0 + github.com/tidwall/sjson v1.2.5 go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.28.0 golang.org/x/crypto v0.53.0 - golang.org/x/tools v0.47.0 google.golang.org/grpc v1.82.0 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af k8s.io/api v0.36.0 @@ -109,6 +109,7 @@ require ( golang.org/x/term v0.44.0 // indirect golang.org/x/text v0.38.0 // indirect golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.47.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect diff --git a/go.sum b/go.sum index 068bc5b43..8dfd43d0b 100644 --- a/go.sum +++ b/go.sum @@ -183,10 +183,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/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index 3702bb802..69b8c6a9e 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -30,10 +30,6 @@ var ( // with the code source changes to be tested. const image = "ghcr.io/ironcore-dev/network-operator:latest" -// serverImage is the name of the image which will be built and loaded -// 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 @@ -45,31 +41,15 @@ func TestE2E(t *testing.T) { } var _ = BeforeSuite(func(ctx SpecContext) { - By("Ensure that Prometheus is enabled") - cwd, err := GetProjectDir() - Expect(err).NotTo(HaveOccurred(), "Failed to get project directory") - - err = UncommentCode(cwd+"/config/default/kustomization.yaml", "#- ../prometheus", "#") - Expect(err).NotTo(HaveOccurred(), "Failed to enable Prometheus") - By("building the manager(Operator) image") cmd := exec.CommandContext(ctx, "make", "docker-build", "IMG="+image) - _, err = Run(cmd) + _, err := Run(cmd) ExpectWithOffset(1, err).NotTo(HaveOccurred(), "Failed to build the manager(Operator) image") 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") - 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") - - 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") - // 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. diff --git a/test/e2e/e2e_test.go b/test/e2e/e2e_test.go index 08c8b54d3..cbecf9633 100644 --- a/test/e2e/e2e_test.go +++ b/test/e2e/e2e_test.go @@ -10,13 +10,11 @@ import ( "os" "os/exec" "path/filepath" - "slices" "strings" "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "golang.org/x/tools/txtar" ) // namespace where the project is deployed in @@ -33,45 +31,17 @@ 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", - ) + By("creating manager namespace") + cmd := exec.CommandContext(ctx, "kubectl", "create", "ns", namespace, "--dry-run=client", "-o", "yaml") 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) + cmd = exec.CommandContext(ctx, "kubectl", "apply", "-f", "-") + cmd.Stdin = strings.NewReader(out) _, err = Run(cmd) Expect(err).NotTo(HaveOccurred(), "Failed to create namespace") @@ -86,21 +56,25 @@ var _ = Describe("Manager", Ordered, func() { 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") + // Retry deploy because cert-manager webhook may not be ready immediately + // after its deployment is Available (TLS certificate propagation delay). + Eventually(func() error { + cmd = exec.CommandContext(ctx, "make", "deploy") + _, err = Run(cmd) + return err + }).WithTimeout(2*time.Minute).WithPolling(10*time.Second).Should(Succeed(), "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) + cmd := exec.CommandContext(ctx, "kubectl", "delete", "clusterrolebinding", metricsRoleBindingName, "--ignore-not-found") _, 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) + cmd = exec.CommandContext(ctx, "kubectl", "delete", "pod", "curl-metrics", "-n", namespace, "--ignore-not-found") _, err = Run(cmd) Expect(err).NotTo(HaveOccurred(), "Failed to delete curl-metrics pod") @@ -118,11 +92,6 @@ var _ = Describe("Manager", Ordered, func() { 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, @@ -311,113 +280,6 @@ var _ = Describe("Manager", Ordered, func() { }) // +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, numFiles int) { - 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(numFiles), "Unexpected number of files in the test archive") - - // All sections except the last are resource manifests; last is expected state. - resources := a.Files[:len(a.Files)-1] - stateFile := a.Files[len(a.Files)-1] - - for _, res := range resources { - err = Apply(ctx, string(res.Data)) - Expect(err).NotTo(HaveOccurred(), "Failed to apply resource %s", res.Name) - - // Determine wait condition from resource type prefix. - // vlans/ have no provider in openconfig — skip wait. - var condition string - switch { - case strings.HasPrefix(res.Name, "banners/"): - condition = "Ready" - case strings.HasPrefix(res.Name, "vlans/"): - continue - default: - condition = "Configured" - } - - // #nosec G204 - cmd := exec.CommandContext( - ctx, "kubectl", "wait", res.Name, - "--for", "condition="+condition, - "--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(stateFile.Data)) - Expect(err).NotTo(HaveOccurred(), "State output does not match expected JSON") - - // Delete resources in reverse order. - for _, res := range slices.Backward(resources) { - // #nosec G204 - cmd = exec.CommandContext(ctx, "kubectl", "delete", res.Name) - _, err = Run(cmd) - Expect(err).NotTo(HaveOccurred(), "Failed to delete object") - } - - cmd = exec.CommandContext(ctx, "kubectl", "delete", "devices/device", "--cascade=foreground") - _, 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", 2), - Entry("Loopback Multi-Address", "interface_loopback_multi_addr.txt", 2), - Entry("Physical IPv4 Address", "interface_physical_ipv4.txt", 2), - Entry("Physical Unnumbered", "interface_physical_unnumbered.txt", 3), - Entry("Physical Switchport Access", "interface_physical_switchport_access.txt", 2), - Entry("Physical Switchport Trunk", "interface_physical_switchport_trunk.txt", 2), - Entry("Aggregate L2 Trunk", "interface_aggregate_l2_trunk.txt", 3), - Entry("Aggregate L3 Address", "interface_aggregate_l3.txt", 3), - Entry("Routed VLAN", "interface_routed_vlan.txt", 3), - Entry("Banner PreLogin", "banner.txt", 2), - ) }) }) diff --git a/test/e2e/util_test.go b/test/e2e/util_test.go index e820a8037..735f1c0e6 100644 --- a/test/e2e/util_test.go +++ b/test/e2e/util_test.go @@ -4,7 +4,6 @@ package e2e import ( - "bufio" "bytes" "context" "encoding/json" @@ -19,10 +18,21 @@ import ( . "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 getPrometheusURL() string { + version := os.Getenv("E2E_PROMETHEUS_OPERATOR_VERSION") + if version == "" { + panic("E2E_PROMETHEUS_OPERATOR_VERSION environment variable must be set") + } + return fmt.Sprintf("https://github.com/prometheus-operator/prometheus-operator/releases/download/%s/bundle.yaml", version) +} + +func getCertManagerURL() string { + version := os.Getenv("E2E_CERTMANAGER_VERSION") + if version == "" { + panic("E2E_CERTMANAGER_VERSION environment variable must be set") + } + return fmt.Sprintf("https://github.com/cert-manager/cert-manager/releases/download/%s/cert-manager.yaml", version) +} func warnError(err error) { _, _ = fmt.Fprintf(GinkgoWriter, "warning: %v\n", err) @@ -118,14 +128,14 @@ func sortSlices(v any) any { // 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) + cmd := exec.CommandContext(ctx, "kubectl", "create", "-f", getPrometheusURL()) // #nosec G204 -- URL from trusted version constant _, err := Run(cmd) return err } // UninstallPrometheusOperator uninstalls the prometheus func UninstallPrometheusOperator(ctx context.Context) { - cmd := exec.CommandContext(ctx, "kubectl", "delete", "-f", prometheusURL) + cmd := exec.CommandContext(ctx, "kubectl", "delete", "-f", getPrometheusURL()) // #nosec G204 -- URL from trusted version constant if _, err := Run(cmd); err != nil { warnError(err) } @@ -160,26 +170,28 @@ func IsPrometheusCRDsInstalled(ctx context.Context) bool { // InstallCertManager installs the cert manager bundle. func InstallCertManager(ctx context.Context) error { - cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", certmanagerURL) + cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", getCertManagerURL()) // #nosec G204 -- URL from trusted version constant 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 + // Wait for all cert-manager deployments to be ready. + for _, deploy := range []string{"cert-manager", "cert-manager-cainjector", "cert-manager-webhook"} { + cmd = exec.CommandContext( // #nosec G204 -- deployment names from trusted constant list + ctx, "kubectl", "wait", "deployment.apps/"+deploy, + "--for", "condition=Available", + "--namespace", "cert-manager", + "--timeout", "5m", + ) + if _, err := Run(cmd); err != nil { + return err + } + } + return nil } // UninstallCertManager uninstalls the cert manager func UninstallCertManager(ctx context.Context) { - cmd := exec.CommandContext(ctx, "kubectl", "delete", "-f", certmanagerURL) + cmd := exec.CommandContext(ctx, "kubectl", "delete", "-f", getCertManagerURL()) // #nosec G204 -- URL from trusted version constant if _, err := Run(cmd); err != nil { warnError(err) } @@ -221,7 +233,7 @@ func IsCertManagerCRDsInstalled(ctx context.Context) bool { // 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 { + if v, ok := os.LookupEnv("KIND_CLUSTER"); ok && v != "" { cluster = v } // See: https://kind.sigs.k8s.io/docs/user/rootless/#creating-a-kind-cluster-with-rootless-nerdctl @@ -276,50 +288,3 @@ func GetProjectDir() (string, error) { 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 deleted file mode 100644 index d80e16ac4..000000000 --- a/test/gnmi/Dockerfile +++ /dev/null @@ -1,43 +0,0 @@ -# syntax=docker/dockerfile:1 -# SPDX-FileCopyrightText: 2025 SAP SE or an SAP affiliate company and IronCore contributors -# SPDX-License-Identifier: Apache-2.0 - -ARG GO_VERSION=1.26 -ARG ALPINE_VERSION=3.22 - -FROM --platform=${BUILDPLATFORM:-linux/amd64} golang:${GO_VERSION}-alpine${ALPINE_VERSION} AS build - -ARG TARGETOS -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 - -# 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 - -FROM alpine:${ALPINE_VERSION} - -# Create a non-root user -RUN addgroup -g 65532 -S nonroot && \ - adduser -u 65532 -S nonroot -G nonroot - -# Copy executable from build and set ownership to non-root user -COPY --from=build --chown=nonroot:nonroot /usr/bin/server /server - -# Switch to non-root user -USER 65532:65532 - -# Switch into workspace -WORKDIR / - -# Start the server application -CMD ["/server", "--port=9339", "--http-port=8000"] diff --git a/test/gnmi/go.mod b/test/gnmi/go.mod deleted file mode 100644 index 76e61f3e9..000000000 --- a/test/gnmi/go.mod +++ /dev/null @@ -1,20 +0,0 @@ -module github.com/ironcore-dev/gnmi-test-server - -go 1.26 - -require ( - github.com/openconfig/gnmi v0.14.1 - github.com/tidwall/gjson v1.19.0 - github.com/tidwall/sjson v1.2.5 - google.golang.org/grpc v1.82.0 -) - -require ( - github.com/tidwall/match v1.1.1 // indirect - github.com/tidwall/pretty v1.2.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect - google.golang.org/protobuf v1.36.11 // indirect -) diff --git a/test/gnmi/go.sum b/test/gnmi/go.sum deleted file mode 100644 index 211a96b32..000000000 --- a/test/gnmi/go.sum +++ /dev/null @@ -1,49 +0,0 @@ -github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= -github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= -github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= -github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= -github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= -github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/openconfig/gnmi v0.14.1 h1:qKMuFvhIRR2/xxCOsStPQ25aKpbMDdWr3kI+nP9bhMs= -github.com/openconfig/gnmi v0.14.1/go.mod h1:whr6zVq9PCU8mV1D0K9v7Ajd3+swoN6Yam9n8OH3eT0= -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 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs= -github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= -github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= -github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= -go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= -go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= -gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= -gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= -google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/test/e2e/testdata/banner.txt b/test/gnmi/testdata/openconfig/banner.txt similarity index 100% rename from test/e2e/testdata/banner.txt rename to test/gnmi/testdata/openconfig/banner.txt diff --git a/test/e2e/testdata/interface.txt b/test/gnmi/testdata/openconfig/interface.txt similarity index 100% rename from test/e2e/testdata/interface.txt rename to test/gnmi/testdata/openconfig/interface.txt diff --git a/test/e2e/testdata/interface_aggregate_l2_trunk.txt b/test/gnmi/testdata/openconfig/interface_aggregate_l2_trunk.txt similarity index 100% rename from test/e2e/testdata/interface_aggregate_l2_trunk.txt rename to test/gnmi/testdata/openconfig/interface_aggregate_l2_trunk.txt diff --git a/test/e2e/testdata/interface_aggregate_l3.txt b/test/gnmi/testdata/openconfig/interface_aggregate_l3.txt similarity index 100% rename from test/e2e/testdata/interface_aggregate_l3.txt rename to test/gnmi/testdata/openconfig/interface_aggregate_l3.txt diff --git a/test/e2e/testdata/interface_loopback_multi_addr.txt b/test/gnmi/testdata/openconfig/interface_loopback_multi_addr.txt similarity index 100% rename from test/e2e/testdata/interface_loopback_multi_addr.txt rename to test/gnmi/testdata/openconfig/interface_loopback_multi_addr.txt diff --git a/test/e2e/testdata/interface_physical_ipv4.txt b/test/gnmi/testdata/openconfig/interface_physical_ipv4.txt similarity index 100% rename from test/e2e/testdata/interface_physical_ipv4.txt rename to test/gnmi/testdata/openconfig/interface_physical_ipv4.txt diff --git a/test/e2e/testdata/interface_physical_switchport_access.txt b/test/gnmi/testdata/openconfig/interface_physical_switchport_access.txt similarity index 100% rename from test/e2e/testdata/interface_physical_switchport_access.txt rename to test/gnmi/testdata/openconfig/interface_physical_switchport_access.txt diff --git a/test/e2e/testdata/interface_physical_switchport_trunk.txt b/test/gnmi/testdata/openconfig/interface_physical_switchport_trunk.txt similarity index 100% rename from test/e2e/testdata/interface_physical_switchport_trunk.txt rename to test/gnmi/testdata/openconfig/interface_physical_switchport_trunk.txt diff --git a/test/e2e/testdata/interface_physical_unnumbered.txt b/test/gnmi/testdata/openconfig/interface_physical_unnumbered.txt similarity index 100% rename from test/e2e/testdata/interface_physical_unnumbered.txt rename to test/gnmi/testdata/openconfig/interface_physical_unnumbered.txt diff --git a/test/e2e/testdata/interface_routed_vlan.txt b/test/gnmi/testdata/openconfig/interface_routed_vlan.txt similarity index 100% rename from test/e2e/testdata/interface_routed_vlan.txt rename to test/gnmi/testdata/openconfig/interface_routed_vlan.txt diff --git a/test/gnmi/main.go b/test/gnmi/testserver/server.go similarity index 65% rename from test/gnmi/main.go rename to test/gnmi/testserver/server.go index 9aea5f87f..03d0aae36 100644 --- a/test/gnmi/main.go +++ b/test/gnmi/testserver/server.go @@ -1,14 +1,14 @@ // SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors // SPDX-License-Identifier: Apache-2.0 -package main +package testserver import ( "bytes" "context" "crypto/tls" "encoding/json" - "flag" + "errors" "fmt" "io" "log" @@ -36,8 +36,115 @@ var _ gpb.GNMIServer = (*Server)(nil) // Server implements the GNMI gRPC server type Server struct { gpb.UnimplementedGNMIServer + // state is the internal state of the server, which can be manipulated via gNMI Set requests or the HTTP API. + state *State + // grpcServer is the gRPC server instance where gNMI clients can connect to. + grpcServer *grpc.Server + // grpcAddr is the address grpcServer is listening on, e.g., 127.0.0.1:9443 + grpcAddr string + // httpServer is the HTTP server instance used to inspect and manipulate the server's internal state. + httpServer *http.Server + // httpAddr is the address httpServer is listening on, e.g., 127.0.0.1:8000 + httpAddr string + // closeOnce ensures Close only runs once, even when triggered by both + // context cancellation and an explicit caller. + closeOnce sync.Once +} + +// NewTestServer starts an in-process gNMI + HTTP server on random available ports. +// Returns the server, gRPC address, HTTP address, and any error. +func NewTestServer(ctx context.Context) (*Server, string, string, error) { + lc := &net.ListenConfig{} + grpcLis, err := lc.Listen(ctx, "tcp", "127.0.0.1:0") + if err != nil { + return nil, "", "", fmt.Errorf("failed to listen for gRPC: %w", err) + } + + httpLis, err := lc.Listen(ctx, "tcp", "127.0.0.1:0") + if err != nil { + grpcLis.Close() + return nil, "", "", fmt.Errorf("failed to listen for HTTP: %w", err) + } + + cert, err := gtls.NewCert() + if err != nil { + grpcLis.Close() + httpLis.Close() + return nil, "", "", fmt.Errorf("failed to create TLS certificate: %w", err) + } + + grpcServer := grpc.NewServer(grpc.Creds(credentials.NewTLS(&tls.Config{ + Certificates: []tls.Certificate{cert}, + }))) + + server := &Server{ + state: &State{}, + grpcServer: grpcServer, + grpcAddr: grpcLis.Addr().String(), + httpAddr: httpLis.Addr().String(), + } + + gpb.RegisterGNMIServer(grpcServer, server) + + reflection.Register(grpcServer) + + mux := http.NewServeMux() + mux.HandleFunc("/v1/state", server.handleState) + server.httpServer = &http.Server{Handler: mux} + + 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) + } + }() + + 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) + } + }() + + go func() { //nolint:gosec // G118: ctx is already done, must use Background for shutdown timeout + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Close(shutdownCtx); err != nil { //nolint:contextcheck // shutdownCtx is correctly derived from Background + log.Printf("Shutdown error: %v", err) + } + }() + + return server, server.grpcAddr, server.httpAddr, nil +} + +// GRPCAddr returns the gRPC server address +func (s *Server) GRPCAddr() string { + return s.grpcAddr +} - State *State +// HTTPAddr returns the HTTP server address +func (s *Server) HTTPAddr() string { + return s.httpAddr +} + +// Close gracefully shuts down the server. It is safe to call multiple times +// and from multiple goroutines; only the first call performs shutdown. +func (s *Server) Close(ctx context.Context) error { + var closeErr error + s.closeOnce.Do(func() { + log.Printf("Shutting down gNMI test server") + + if s.httpServer != nil { + if err := s.httpServer.Shutdown(ctx); err != nil { + closeErr = fmt.Errorf("HTTP shutdown: %w", err) + } + } + if s.grpcServer != nil { + s.grpcServer.GracefulStop() + } + }) + return closeErr } func (s *Server) Capabilities(_ context.Context, _ *gpb.CapabilityRequest) (*gpb.CapabilityResponse, error) { @@ -51,7 +158,7 @@ func (s *Server) Get(_ context.Context, req *gpb.GetRequest) (*gpb.GetResponse, return nil, status.Error(codes.InvalidArgument, "root path is not allowed") } log.Printf("Getting path: %v", path) - val := s.State.Get(path) + val := s.state.Get(path) if val == nil { notifications = append(notifications, &gpb.Notification{ Timestamp: time.Now().UnixNano(), @@ -87,28 +194,28 @@ func (s *Server) Set(_ context.Context, req *gpb.SetRequest) (*gpb.SetResponse, Path: del, Op: gpb.UpdateResult_DELETE, }) - s.State.Del(del) + 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, + Path: replace.GetPath(), 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()) + 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, + Path: update.GetPath(), Op: gpb.UpdateResult_UPDATE, }) // The value will automatically be merged into the existing state. - s.State.Set(update.GetPath(), update.GetVal().GetJsonVal()) + s.state.Set(update.GetPath(), update.GetVal().GetJsonVal()) } // TODO: Handle UnionReplace return &gpb.SetResponse{ @@ -120,7 +227,7 @@ func (s *Server) Set(_ context.Context, req *gpb.SetRequest) (*gpb.SetResponse, func (s *Server) Subscribe(stream grpc.BidiStreamingServer[gpb.SubscribeRequest, gpb.SubscribeResponse]) error { req, err := stream.Recv() switch { - case err == io.EOF: + case errors.Is(err, io.EOF): return nil case err != nil: return err @@ -178,31 +285,38 @@ func (s *Server) Subscribe(stream grpc.BidiStreamingServer[gpb.SubscribeRequest, // handleState handles HTTP requests to the /v1/state endpoint func (s *Server) handleState(w http.ResponseWriter, r *http.Request) { method := r.Method - if override := r.Header.Get("X-HTTP-Method-Override"); override != "" { + if override := r.Header.Get("X-Http-Method-Override"); override != "" { method = override } switch method { case http.MethodGet: - s.State.RLock() - defer s.State.RUnlock() + 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("{}")) + if len(s.state.Buf) == 0 { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("{}")); err != nil { + log.Printf("Failed to write empty response: %v", err) + } return } var buf bytes.Buffer - if err := json.Compact(&buf, s.State.Buf); err != nil { + 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")) + if _, err := w.Write([]byte("Internal Server Error")); err != nil { + log.Printf("Failed to write error response: %v", err) + } return } - w.Write(buf.Bytes()) + w.WriteHeader(http.StatusOK) + if _, err := w.Write(buf.Bytes()); err != nil { + log.Printf("Failed to write response: %v", err) + } case http.MethodDelete: - s.State.Lock() - defer s.State.Unlock() - s.State.Buf = nil + s.state.Lock() + defer s.state.Unlock() + s.state.Buf = nil w.WriteHeader(http.StatusNoContent) default: w.WriteHeader(http.StatusMethodNotAllowed) @@ -216,7 +330,7 @@ type State struct { Buf []byte } -func (s State) Get(path *gpb.Path) []byte { +func (s *State) Get(path *gpb.Path) []byte { s.RLock() defer s.RUnlock() var sb strings.Builder @@ -322,61 +436,3 @@ func (s *State) Del(path *gpb.Path) { 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)) - if err != nil { - log.Fatalf("Failed to listen on port %d: %v", *port, 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("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) - } -}