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