From cf353fd2601de40fa5a317f7978b1838db1e08cd Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 23 Jul 2026 09:34:00 +0200 Subject: [PATCH 1/3] feat(test/gnmi): Add integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit introduces envtest-based integration tests for gNMI functionality using the OpenConfig provider. The tests verify the end-to-end workflow from Kubernetes resources to provider gNMI payloads without requiring a full cluster deployment. The test architecture uses a per-process gNMI test server to enable parallel test execution with Ginkgo's --procs flag. Each Ginkgo process creates its own suite-level server in BeforeSuite, shares it across all tests within that process, and cleans it up in AfterSuite. Tests within each process run serially, ensuring proper isolation through per-test namespaces and state clearing between tests. Testdata files support optional state/preload sections to set initial gNMI server state before reconciliation. This is for example required to successfully reconcile devices: the controller requires the last boot time. Added one additional section ("delete") that represents the state of the device after the kubernetes resource has been cleaned up. This is usually the same as the preload section. Updated test cases accordingly. At the beginning of the test we set device.Status.Phase to Running directly. This unblocks the the Device controller and avoids us having to add more of the device data into the preload section, e.g., that related to GetDeviceInfo, ListPorts. As there is only one gNMI test server per process, all tests share the same device. This means that test cases must clean up all resources they create, otherwise the server will be polluted by the left over resources. As Envtest runs without kube-controller-manager, there is no garbage collector to cascade deletions, meaning each test needs to explicitly clean up resources it creates, and it must do so in the correct order to prevent finalizer deadlocks. For example, a Device cannot be deleted while it still has dependent resources with finalizers. Additionally, not all custom resources in network-operator have controllers. Resources like Interface, BGP, and OSPF have reconcilers that add finalizers and set status conditions. Other resources (typically config-only types) are stored in the API but have no controller — no finalizer, no conditions. Thus, the test framework must handle: wait for conditions on controller-managed resources, prevent blocking indefinitely on config-only ones, and clean up resources in the correct order without relying on cascading deletion. The waitForResource helper probes for a finalizer with a short timeout. Controllers add finalizers early in reconciliation (within ~1 second), so if none appears after 3 seconds, the resource is assumed to be config-only and the helper returns immediately. For controller-managed resources, it waits for the Ready or Configured condition. The cleanupAllResources helper lists all network-operator resources in the namespace, then partitions them by checking each resource's finalizers field. Controller-managed resources (those with finalizers) are deleted first, waiting for finalizer removal while Device still exists. Config-only resources (no finalizers) are deleted afterward without waiting. Finally, the Device is deleted. This approach avoids hardcoded list of resource types. Signed-off-by: Pujol --- Makefile | 14 +- go.mod | 4 +- go.sum | 50 +++ test/gnmi/gnmi_suite_test.go | 356 ++++++++++++++++++ test/gnmi/gnmi_test.go | 303 +++++++++++++++ test/gnmi/server/server.go | 111 ++++-- test/gnmi/testdata/openconfig/banner.txt | 24 +- test/gnmi/testdata/openconfig/interface.txt | 26 +- .../interface_aggregate_l2_trunk.txt | 32 +- .../openconfig/interface_aggregate_l3.txt | 32 +- .../interface_loopback_multi_addr.txt | 26 +- .../openconfig/interface_physical_ipv4.txt | 26 +- .../interface_physical_switchport_access.txt | 26 +- .../interface_physical_switchport_trunk.txt | 26 +- .../interface_physical_unnumbered.txt | 27 +- ...lan.txt => interface_routed_vlan.txt.skip} | 11 +- 16 files changed, 1041 insertions(+), 53 deletions(-) create mode 100644 test/gnmi/gnmi_suite_test.go create mode 100644 test/gnmi/gnmi_test.go rename test/gnmi/testdata/openconfig/{interface_routed_vlan.txt => interface_routed_vlan.txt.skip} (92%) diff --git a/Makefile b/Makefile index a52d4058c..29d13c6f3 100644 --- a/Makefile +++ b/Makefile @@ -68,8 +68,7 @@ vet: ## Run go vet against code. .PHONY: test test: manifests generate setup-envtest ## Run tests. - KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e | grep -v /lab) -coverprofile cover.out - + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" go test $$(go list ./... | grep -v /e2e | grep -v /lab | grep -v /gnmi/) -coverprofile cover.out .PHONY: coverage coverage: test ## Run tests and generate coverage report. go tool cover -html=cover.out -o cover.html @@ -99,10 +98,15 @@ test-e2e: setup-test-e2e manifests generate ## Run the e2e tests. Expected an is cleanup-test-e2e: ## Tear down the Kind cluster used for e2e tests @$(KIND) delete cluster --name $(KIND_CLUSTER) -.PHONY: test-gnmi -test-gnmi: FORCE ## Run integration tests for gNMI. - @printf "\e[1;33m>> gNMI integration tests not yet implemented\e[0m\n" +# Provider for gNMI integration tests +PROVIDER ?= openconfig +# Number of parallel Ginkgo processes +GINKGO_PROCS ?= $(shell nproc 2>/dev/null || sysctl -n hw.logicalcpu) + +.PHONY: test-gnmi +test-gnmi: setup-envtest ## Run gNMI tests. + KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) --bin-dir $(LOCALBIN) -p path)" PROVIDER=$(PROVIDER) go test ./test/gnmi/ -v -ginkgo.v .PHONY: test-lab test-lab: ## Run lab tests against a real network device. go test ./test/lab/ -v diff --git a/go.mod b/go.mod index fa8c20677..393ccde81 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/ironcore-dev/network-operator go 1.26.0 require ( + github.com/benjamintf1/unmarshalledmatchers v1.0.0 github.com/felix-kaestner/copy v0.0.0-20250930112410-8fbc5c5b74a5 github.com/go-crypt/crypt v0.14.15 github.com/go-logr/logr v1.4.4 @@ -21,6 +22,7 @@ require ( go.uber.org/automaxprocs v1.6.0 go.uber.org/zap v1.28.0 golang.org/x/crypto v0.54.0 + golang.org/x/tools v0.48.0 google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af k8s.io/api v0.36.0 @@ -109,12 +111,12 @@ require ( golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.48.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 gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v2 v2.3.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.36.0 // indirect k8s.io/apiserver v0.36.0 // indirect diff --git a/go.sum b/go.sum index 54027d0eb..63b828d4f 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1 github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/benjamintf1/unmarshalledmatchers v1.0.0 h1:JUhctHQVNarMXg5x3m0Tkp7WnDLzNVxeWc1qbKQPylI= +github.com/benjamintf1/unmarshalledmatchers v1.0.0/go.mod h1:IVZdtAzpNyBTuhobduAjo5CjTLczWWbiXnWDVxIgSko= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= @@ -27,6 +29,8 @@ github.com/felix-kaestner/copy v0.0.0-20250930112410-8fbc5c5b74a5 h1:i7GRCRj2guo github.com/felix-kaestner/copy v0.0.0-20250930112410-8fbc5c5b74a5/go.mod h1:CBCoJwqwLnXKmE+Oo/m1Wli4mbYI3ROGqRCiTtbOE2c= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.10.0 h1:Xx/5Ydg9CeBDX/wi4VJqStNtohYjitZhhlHt4h3St1M= github.com/fsnotify/fsnotify v1.10.0/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= @@ -88,12 +92,22 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I= github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/cel-go v0.29.0 h1:fEG+Ja3YRwNOqnQxTyJwoByAUAvTuxUGiro/jhrm4F4= github.com/google/cel-go v0.29.0/go.mod h1:X0bD6iVNR8pkROSOoHVdgTkzmRcosof7WQqCD6wcMc8= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -105,6 +119,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZBeBuvMkmuI2V3Fak= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= @@ -131,8 +146,16 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.14.0 h1:2mOpI4JVVPBN+WQRa0WKH2eXR+Ey+uK4n7Zj0aYpIQA= +github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= github.com/onsi/ginkgo/v2 v2.32.0 h1:Hw7s2pVrQo/8Yz5N77qdnpHaoc+c6cC9WIV1Jce+J6E= github.com/onsi/ginkgo/v2 v2.32.0/go.mod h1:+aXOY+vzZ5mu2iI2HpTZUPmM//oQfsNFX6gU9kNcA44= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.42.1 h1:iN1rCUX+44NZ1Dc97MPoeFYbFR0vh8zxoxMFwKdyZ6I= github.com/onsi/gomega v1.42.1/go.mod h1:REff/hsDsodHoKlWsP2mAPhu1+5/6hVYNf9rIEBpeSg= github.com/openconfig/gnmi v0.14.1 h1:qKMuFvhIRR2/xxCOsStPQ25aKpbMDdWr3kI+nP9bhMs= @@ -220,28 +243,43 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= @@ -252,6 +290,12 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -259,8 +303,14 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.36.0 h1:SgqDhZzHdOtMk40xVSvCXkP9ME0H05hPM3p9AB1kL80= diff --git a/test/gnmi/gnmi_suite_test.go b/test/gnmi/gnmi_suite_test.go new file mode 100644 index 000000000..04db5bf17 --- /dev/null +++ b/test/gnmi/gnmi_suite_test.go @@ -0,0 +1,356 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package gnmitest + +import ( + "context" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/events" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" + "github.com/ironcore-dev/network-operator/internal/controller/core" + "github.com/ironcore-dev/network-operator/internal/provider" + "github.com/ironcore-dev/network-operator/internal/resourcelock" + testserver "github.com/ironcore-dev/network-operator/test/gnmi/server" + + // Register providers so they're available via provider.Get/Providers + _ "github.com/ironcore-dev/network-operator/internal/provider/openconfig" +) + +const ( + ProviderEnvVar = "PROVIDER" +) + +// Test environment — initialized in BeforeSuite +var ( + testEnv *envtest.Environment + restConfig *rest.Config + k8sClient client.Client + gnmiServer *testserver.Server +) + +// providerFunc is the provider function resolved during Ginkgo tree construction. +// NOTE: This is assigned in the Describe block (gnmi_test.go) which runs BEFORE BeforeSuite. +var providerFunc provider.ProviderFunc + +// suiteCancel stops long-running components (gNMI server, controller manager) in AfterSuite. +var suiteCancel context.CancelFunc + +// TestGNMI runs the gNMI integration test suite. +func TestGNMI(t *testing.T) { + RegisterFailHandler(Fail) + _, _ = fmt.Fprintf(GinkgoWriter, "Starting network-operator gNMI integration tests\n") + RunSpecs(t, "gNMI Integration Suite") +} + +// BeforeSuite initializes the test environment. +// It starts the gNMI test server, sets up the Kubernetes client, and starts the controller manager. +var _ = BeforeSuite(func(ctx SpecContext) { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + SetDefaultEventuallyTimeout(30 * time.Second) + SetDefaultEventuallyPollingInterval(time.Second) + + By("resolving provider") + // PROVIDER env var already validated during tree construction (Describe block runs first) + var err error + providerFunc, err = provider.Get(os.Getenv(ProviderEnvVar)) + Expect(err).NotTo(HaveOccurred()) + + By("initializing envtest environment") + + // Create a context for long-running servers that outlives BeforeSuite. + // Ginkgo's ctx is block-scoped and cancelled when BeforeSuite returns, + // but servers must run until AfterSuite calls suiteCancel(). + var suiteCtx context.Context + suiteCtx, suiteCancel = context.WithCancel(context.Background()) + + gnmiServer, err = testserver.NewTestServer(suiteCtx) + Expect(err).NotTo(HaveOccurred()) + + // Register schemas, add as needed. + Expect(corev1.AddToScheme(scheme.Scheme)).To(Succeed()) + Expect(v1alpha1.AddToScheme(scheme.Scheme)).To(Succeed()) + Expect(nxv1alpha1.AddToScheme(scheme.Scheme)).To(Succeed()) + + // Start envtest (uses KUBEBUILDER_ASSETS env var or default location) + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: true, + } + + restConfig, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + + k8sClient, err = client.New(restConfig, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + + Eventually(func() error { + var ns corev1.Namespace + return k8sClient.Get(ctx, client.ObjectKey{Name: metav1.NamespaceDefault}, &ns) + }).Should(Succeed()) + + By("starting controller manager") + mgr, err := ctrl.NewManager(restConfig, ctrl.Options{ + Scheme: k8sClient.Scheme(), + Logger: GinkgoLogr, + Metrics: metricsserver.Options{BindAddress: "0"}, // Disable metrics server + }) + Expect(err).ToNot(HaveOccurred()) + + recorder := events.NewFakeRecorder(0) + + locker, err := resourcelock.NewResourceLocker(mgr.GetClient(), metav1.NamespaceDefault, 15*time.Second, 10*time.Second) + Expect(err).NotTo(HaveOccurred()) + + err = mgr.Add(locker) + Expect(err).NotTo(HaveOccurred()) + + registerControllers(ctx, mgr, recorder, providerFunc, locker) + + go func() { + defer GinkgoRecover() + err = mgr.Start(suiteCtx) + if suiteCtx.Err() == nil { + Expect(err).ToNot(HaveOccurred(), "failed to run manager") + } + }() +}) + +// AfterSuite cleans up the test environment. +var _ = AfterSuite(func(ctx SpecContext) { + fmt.Fprintf(GinkgoWriter, "Tearing down test environment...\n") + if suiteCancel != nil { + suiteCancel() + } + + if gnmiServer != nil { + gnmiServer.Close() + } + + if testEnv != nil { + _ = testEnv.Stop() //nolint:errcheck // best-effort cleanup in AfterSuite + } +}) + +// registerControllers registers all controllers with the manager. +// Add more controllers here as needed for testing. +func registerControllers(ctx context.Context, mgr ctrl.Manager, recorder *events.FakeRecorder, providerFn provider.ProviderFunc, locker *resourcelock.ResourceLocker) { + var err error + + err = (&core.PrefixSetReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.RoutingPolicyReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.InterfaceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.VLANReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.VRFReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.NTPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.DNSReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.LLDPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.BannerReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.OSPFReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.PIMReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.NetworkVirtualizationEdgeReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.EVPNInstanceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.BGPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.BGPPeerReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.SyslogReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.SNMPReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.ManagementAccessReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.AccessControlListReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.DHCPRelayReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + RequeueInterval: time.Minute, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) + + err = (&core.ISISReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Recorder: recorder, + Provider: providerFn, + Locker: locker, + }).SetupWithManager(ctx, mgr) + Expect(err).NotTo(HaveOccurred()) +} diff --git a/test/gnmi/gnmi_test.go b/test/gnmi/gnmi_test.go new file mode 100644 index 000000000..5fe177439 --- /dev/null +++ b/test/gnmi/gnmi_test.go @@ -0,0 +1,303 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +package gnmitest + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + . "github.com/benjamintf1/unmarshalledmatchers" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "golang.org/x/tools/txtar" + corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/client-go/kubernetes/scheme" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/yaml" + + nxv1alpha1 "github.com/ironcore-dev/network-operator/api/cisco/nx/v1alpha1" + "github.com/ironcore-dev/network-operator/api/core/v1alpha1" +) + +var _ = Describe("gNMI requests tests", func() { + // Tree construction: discover test files to generate It() nodes. + // Provider resolution happens in BeforeSuite. + envProvider := os.Getenv(ProviderEnvVar) + + testdataDir := filepath.Join("testdata", envProvider) + testFiles, err := filepath.Glob(filepath.Join(testdataDir, "*.txt")) + if err != nil { + Fail(fmt.Sprintf("failed to glob testdata: %v", err)) + } + if len(testFiles) == 0 { + return + } + + // Tests run in parallel - each test gets its own namespace + Describe("Provider: "+envProvider, func() { + var testNamespace string + + BeforeEach(func(ctx SpecContext) { + By("creating dedicated test namespace") + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "reconcile-gnmi-test-", + }, + } + Expect(k8sClient.Create(ctx, ns)).To(Succeed()) + testNamespace = ns.Name + }) + + AfterEach(func(ctx SpecContext) { + By("deleting the test namespace") + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testNamespace}}))).To(Succeed()) + + By("clearing gNMI state for next test") + serverState := gnmiServer.State() + serverState.SetBuf([]byte("{}")) + }) + + // Generate individual It nodes for each test file + for _, testFile := range testFiles { + testName := filepath.Base(testFile) + testName = testName[:len(testName)-4] // remove .txt + + It("It should handle the configuration in test file "+testName, func(ctx SpecContext) { + By("parsing testdata file") + a, err := txtar.ParseFile(testFile) + Expect(err).NotTo(HaveOccurred(), "Failed to parse test file: %s", testFile) + Expect(len(a.Files)).To(BeNumerically(">=", 2), "Expected at least 2 files (resource(s) and state)") + + var statePre, statePost, stateDelete []byte + var resources []txtar.File + for _, f := range a.Files { + switch f.Name { + case "state/expect": + statePost = f.Data + case "state/preload": + statePre = f.Data + case "state/delete": + stateDelete = f.Data + default: + resources = append(resources, f) + } + } + Expect(statePost).NotTo(BeEmpty(), "Expected '-- state/expect --' section in testdata") + Expect(resources).NotTo(BeEmpty(), "Expected at least one resource in testdata") + Expect(stateDelete).NotTo(BeEmpty(), "Expected '-- state/delete --' section in testdata") + + By("preloading gNMI state from testdata") + serverState := gnmiServer.State() + if len(statePre) != 0 { + serverState.SetBuf(statePre) + } + + By("creating test device") + device := &v1alpha1.Device{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: "test-device-", + Namespace: testNamespace, + }, + Spec: v1alpha1.DeviceSpec{ + Endpoint: v1alpha1.Endpoint{ + Address: gnmiServer.GRPCAddr(), + }, + }, + } + Expect(k8sClient.Create(ctx, device)).To(Succeed()) + + // Set device phase to Running to simulate a ready device for these tests. + device.Status.Phase = v1alpha1.DevicePhaseRunning + Expect(k8sClient.Status().Update(ctx, device)).To(Succeed()) + + By(fmt.Sprintf("creating %d resource(s) from testdata", len(resources))) + for _, res := range resources { + obj := createResourceFromTxtar(ctx, k8sClient, res, device.Name, testNamespace) + waitForResource(ctx, k8sClient, obj) + } + + By("verifying gNMI state matches expected JSON") + Eventually(func(g Gomega) { + stateJSON := serverState.GetBuf() + if len(stateJSON) == 0 { + stateJSON = []byte("{}") + } + g.Expect(stateJSON).To(ContainUnorderedJSON(statePost), "gNMI state does not match expected JSON") + }).Should(Succeed()) + + By("deleting all intermeadiate test resources created in test") + cleanupAllResources(k8sClient, testNamespace) + + By("verifying gNMI state is empty after resource deletion") + Eventually(func(g Gomega) { + stateJSON := serverState.GetBuf() + if len(stateJSON) == 0 { + stateJSON = []byte("{}") + } + g.Expect(stateJSON).To(ContainUnorderedJSON(stateDelete), "gNMI state does not match expected JSON") + }).Should(Succeed()) + + By("deleting the test device") + Expect(client.IgnoreNotFound(k8sClient.Delete(ctx, device))).To(Succeed()) + }) + } + }) +}) + +// createResourceFromTxtar creates a K8s resource from txtar file data. +// The file name format is "kind/name" (e.g., "prefixset/my-prefixset"). +// It substitutes "device" in deviceRef.name with the actual device name. +func createResourceFromTxtar(ctx SpecContext, c client.Client, res txtar.File, deviceName, namespace string) client.Object { + obj := &unstructured.Unstructured{} + Expect(yaml.Unmarshal(res.Data, obj)).To(Succeed(), "Failed to unmarshal %s", res.Name) + + // Set the namespace + obj.SetNamespace(namespace) + + // Update deviceRef.name to use the actual device name + Expect(unstructured.SetNestedField(obj.Object, deviceName, "spec", "deviceRef", "name")).To(Succeed()) + + Expect(c.Create(ctx, obj)).To(Succeed(), "Failed to create %s", res.Name) + return obj +} + +// waitForResource waits for a resource to have status conditions set. +// Resources with finalizers have controllers that set Ready/Configured conditions. +// Config-only resources (no finalizer, no controller) will never get conditions, +// so we detect them via a short timeout and skip condition checks. +func waitForResource(ctx SpecContext, c client.Client, obj client.Object) { + key := client.ObjectKeyFromObject(obj) + gvk := obj.GetObjectKind().GroupVersionKind() + + // Controllers add finalizers early in reconciliation. Config-only resources + // have no controller and will never get a finalizer. Poll briefly to detect. + hasFinalizer := false + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + r := &unstructured.Unstructured{} + r.SetGroupVersionKind(gvk) + if err := c.Get(ctx, key, r); err == nil && len(r.GetFinalizers()) > 0 { + hasFinalizer = true + break + } + time.Sleep(50 * time.Millisecond) + } + + if !hasFinalizer { + return + } + + // Resource with finalizer — wait for Ready or Configured condition. + Eventually(func(g Gomega) { + r := &unstructured.Unstructured{} + r.SetGroupVersionKind(gvk) + g.Expect(c.Get(ctx, key, r)).To(Succeed()) + + conditions, err := extractConditions(r) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(conditions).NotTo(BeEmpty(), "waiting for first reconcile") + + conditionToCheck := string(v1alpha1.ReadyCondition) + if apimeta.FindStatusCondition(conditions, string(v1alpha1.ConfiguredCondition)) != nil { + conditionToCheck = string(v1alpha1.ConfiguredCondition) + } + + g.Expect(apimeta.IsStatusConditionTrue(conditions, conditionToCheck)).To(BeTrue()) + }).Should(Succeed()) +} + +// extractConditions extracts status conditions from an unstructured object +// into a typed []metav1.Condition slice for use with apimeta helpers. +func extractConditions(obj *unstructured.Unstructured) ([]metav1.Condition, error) { + raw, _, err := unstructured.NestedSlice(obj.Object, "status", "conditions") + if err != nil { + return nil, err + } + data, err := json.Marshal(raw) + if err != nil { + return nil, err + } + var conditions []metav1.Condition + return conditions, json.Unmarshal(data, &conditions) +} + +// cleanupAllResources deletes all test resources in the proper order. +// +// This is an envtest workaround. In a real cluster, namespace deletion cascades to +// all resources and the garbage collector handles ordering. But envtest runs without +// kube-controller-manager, so there's no garbage collector and namespace deletion +// just marks the namespace as Terminating without actually deleting anything. +// See: https://book.kubebuilder.io/reference/envtest.html#testing-considerations +// +// The function: +// 1. Deletes resources with finalizers first and waits for their controllers +// to process the finalizers (cleaning up gNMI state) while Device still exists. +// 2. Deletes config-only resources (no finalizer, no controller) +// without waiting. +func cleanupAllResources(c client.Client, namespace string) { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + allResources := listAllNetworkOperatorResources(cleanupCtx, c, namespace) + + var withFinalizers, withoutFinalizers []unstructured.Unstructured + for _, r := range allResources { + if len(r.GetFinalizers()) > 0 { + withFinalizers = append(withFinalizers, r) + } else { + withoutFinalizers = append(withoutFinalizers, r) + } + } + + // Delete resources with finalizers first and wait for controller to process + for i := range withFinalizers { + item := &withFinalizers[i] + Expect(client.IgnoreNotFound(c.Delete(cleanupCtx, item))).To(Succeed()) + } + for _, item := range withFinalizers { + Eventually(func(g Gomega) { + var check unstructured.Unstructured + check.SetGroupVersionKind(item.GroupVersionKind()) + err := c.Get(cleanupCtx, client.ObjectKeyFromObject(&item), &check) + g.Expect(client.IgnoreNotFound(err)).To(Succeed()) + g.Expect(apimeta.IsNoMatchError(err) || err != nil).To(BeTrue()) + }).WithContext(cleanupCtx).Should(Succeed()) + } + + // Delete config resources without finalizers (no wait needed) + for i := range withoutFinalizers { + item := &withoutFinalizers[i] + Expect(client.IgnoreNotFound(c.Delete(cleanupCtx, item))).To(Succeed()) + } +} + +// listAllNetworkOperatorResources lists all network-operator CRD instances in a namespace. +func listAllNetworkOperatorResources(ctx context.Context, c client.Client, namespace string) []unstructured.Unstructured { + var all []unstructured.Unstructured + + for gvk := range scheme.Scheme.AllKnownTypes() { + if strings.HasSuffix(gvk.Kind, "List") || gvk.Kind == "Device" { + continue + } + if gvk.Group != v1alpha1.GroupVersion.Group && gvk.Group != nxv1alpha1.GroupVersion.Group { + continue + } + + list := &unstructured.UnstructuredList{} + list.SetGroupVersionKind(gvk.GroupVersion().WithKind(gvk.Kind + "List")) + if err := c.List(ctx, list, client.InNamespace(namespace)); err != nil { + continue + } + all = append(all, list.Items...) + } + return all +} diff --git a/test/gnmi/server/server.go b/test/gnmi/server/server.go index 8883866a7..fa57bcc80 100644 --- a/test/gnmi/server/server.go +++ b/test/gnmi/server/server.go @@ -40,9 +40,6 @@ type Server struct { grpcServer *grpc.Server // grpcAddr is the address grpcServer is listening on, e.g., 127.0.0.1:9443 grpcAddr 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 server on a random available port. @@ -80,15 +77,6 @@ func NewTestServer(ctx context.Context) (*Server, error) { } }() - 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, nil } @@ -103,18 +91,12 @@ func (s *Server) State() *State { return s.state } -// 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.grpcServer != nil { - s.grpcServer.GracefulStop() - } - }) - return closeErr +// Close gracefully shuts down the server. +func (s *Server) Close() { + log.Printf("Shutting down gNMI test server") + if s.grpcServer != nil { + s.grpcServer.GracefulStop() + } } // Capabilities returns the capabilities of the gNMI server @@ -167,7 +149,9 @@ func (s *Server) Set(_ context.Context, req *gpb.SetRequest) (*gpb.SetResponse, Path: del, Op: gpb.UpdateResult_DELETE, }) - s.state.Del(del) + if err := s.state.Del(del); err != nil { + return nil, status.Errorf(codes.Internal, "failed to delete path: %v", err) + } } for _, replace := range req.GetReplace() { log.Printf("Replacing path: %v with value: %q", replace.GetPath(), replace.GetVal().GetJsonVal()) @@ -177,8 +161,12 @@ func (s *Server) Set(_ context.Context, req *gpb.SetRequest) (*gpb.SetResponse, 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()) + if err := s.state.Del(replace.GetPath()); err != nil { + return nil, status.Errorf(codes.Internal, "failed to delete path for replace: %v", err) + } + if err := s.state.Set(replace.GetPath(), replace.GetVal().GetJsonVal()); err != nil { + return nil, status.Errorf(codes.Internal, "failed to set path for replace: %v", err) + } } for _, update := range req.GetUpdate() { log.Printf("Updating path: %v with value: %q", update.GetPath(), update.GetVal().GetJsonVal()) @@ -188,7 +176,9 @@ func (s *Server) Set(_ context.Context, req *gpb.SetRequest) (*gpb.SetResponse, Op: gpb.UpdateResult_UPDATE, }) // The value will automatically be merged into the existing state. - s.state.Set(update.GetPath(), update.GetVal().GetJsonVal()) + if err := s.state.Set(update.GetPath(), update.GetVal().GetJsonVal()); err != nil { + return nil, status.Errorf(codes.Internal, "failed to set path for update: %v", err) + } } // TODO: Handle UnionReplace return &gpb.SetResponse{ @@ -263,6 +253,7 @@ type State struct { Buf []byte } +// Get retrieves the value at the specified path from the state. func (s *State) Get(path *gpb.Path) []byte { s.RLock() defer s.RUnlock() @@ -294,9 +285,15 @@ func (s *State) Get(path *gpb.Path) []byte { return []byte(res.Raw) } -func (s *State) Set(path *gpb.Path, raw []byte) { +// Set sets the value at the specified path in the state. +func (s *State) Set(path *gpb.Path, raw []byte) (err error) { s.Lock() defer s.Unlock() + + // Work on a copy to avoid partial state corruption on error + buf := make([]byte, len(s.Buf)) + copy(buf, s.Buf) + var sb strings.Builder for _, elem := range path.GetElem() { if elem.GetName() == "" { @@ -310,7 +307,7 @@ func (s *State) Set(path *gpb.Path, raw []byte) { continue } var idx int - gjson.GetBytes(s.Buf, sb.String()).ForEach(func(_, r gjson.Result) bool { + gjson.GetBytes(buf, sb.String()).ForEach(func(_, r gjson.Result) bool { for k, v := range elem.GetKey() { if r.Get(k).String() != v { idx++ @@ -322,17 +319,35 @@ func (s *State) Set(path *gpb.Path, raw []byte) { sb.WriteByte('.') sb.WriteString(strconv.Itoa(idx)) for k, v := range elem.GetKey() { - s.Buf, _ = sjson.SetBytes(s.Buf, sb.String()+"."+k, v) //nolint:errcheck + var err error + buf, err = sjson.SetBytes(buf, sb.String()+"."+k, v) + if err != nil { + return fmt.Errorf("sjson.SetBytes key %s: %w", k, err) + } } } - s.Buf, _ = sjson.SetRawBytes(s.Buf, sb.String(), raw) //nolint:errcheck - for k, v := range path.GetElem()[len(path.GetElem())-1].GetKey() { - s.Buf, _ = sjson.SetBytes(s.Buf, sb.String()+"."+k, v) //nolint:errcheck + + buf, err = sjson.SetRawBytes(buf, sb.String(), raw) + if err != nil { + return fmt.Errorf("sjson.SetRawBytes: %w", err) } + + if elems := path.GetElem(); len(elems) > 0 { + for k, v := range elems[len(elems)-1].GetKey() { + buf, err = sjson.SetBytes(buf, sb.String()+"."+k, v) + if err != nil { + return fmt.Errorf("sjson.SetBytes final key %s: %w", k, err) + } + } + } + + // Commit only on success + s.Buf = buf + return nil } // Del deletes the value at the specified path from the state. -func (s *State) Del(path *gpb.Path) { +func (s *State) Del(path *gpb.Path) error { s.Lock() defer s.Unlock() var sb strings.Builder @@ -362,11 +377,33 @@ func (s *State) Del(path *gpb.Path) { return false }) if !found { - return + return nil } sb.WriteByte('.') sb.WriteString(strconv.Itoa(idx)) } - s.Buf, _ = sjson.DeleteBytes(s.Buf, sb.String()) //nolint:errcheck + var err error + s.Buf, err = sjson.DeleteBytes(s.Buf, sb.String()) + if err != nil { + return fmt.Errorf("sjson.DeleteBytes: %w", err) + } + return nil +} + +// SetBuf sets the entire state buffer to the provided value. +func (s *State) SetBuf(buf []byte) { + s.Lock() + defer s.Unlock() + s.Buf = make([]byte, len(buf)) + copy(s.Buf, buf) +} + +// GetBuf returns a copy of the entire state buffer. +func (s *State) GetBuf() []byte { + s.RLock() + defer s.RUnlock() + buf := make([]byte, len(s.Buf)) + copy(buf, s.Buf) + return buf } diff --git a/test/gnmi/testdata/openconfig/banner.txt b/test/gnmi/testdata/openconfig/banner.txt index 56fae2ea5..6ba3347d9 100644 --- a/test/gnmi/testdata/openconfig/banner.txt +++ b/test/gnmi/testdata/openconfig/banner.txt @@ -11,11 +11,33 @@ spec: type: PreLogin message: inline: "Unauthorized access is prohibited." --- state -- + +-- state/preload -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} + +-- state/expect -- { "openconfig-system:system": { "config": { "login-banner": "Unauthorized access is prohibited." + }, + "state": { + "boot-time": "1784731445707000000" + } + } +} + +-- state/delete -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" } } } diff --git a/test/gnmi/testdata/openconfig/interface.txt b/test/gnmi/testdata/openconfig/interface.txt index b9881c2dd..1069204d9 100644 --- a/test/gnmi/testdata/openconfig/interface.txt +++ b/test/gnmi/testdata/openconfig/interface.txt @@ -15,8 +15,23 @@ spec: ipv4: addresses: - 10.0.0.10/32 --- state -- + +-- state/preload -- { + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} + +-- state/expect -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + }, "openconfig-interfaces:interfaces": { "interface": [ { @@ -59,3 +74,12 @@ spec: ] } } + +-- state/delete -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} diff --git a/test/gnmi/testdata/openconfig/interface_aggregate_l2_trunk.txt b/test/gnmi/testdata/openconfig/interface_aggregate_l2_trunk.txt index 58bcd4f0d..005769344 100644 --- a/test/gnmi/testdata/openconfig/interface_aggregate_l2_trunk.txt +++ b/test/gnmi/testdata/openconfig/interface_aggregate_l2_trunk.txt @@ -36,8 +36,29 @@ spec: mode: Active memberInterfaceRefs: - name: eth1-10 --- state -- + +-- state/preload -- { + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + }, + "openconfig-interfaces:interfaces": { + "interface": [ + {"name": "eth1/10", "state": {"oper-status": "UP"}}, + {"name": "po10", "state": {"oper-status": "UP"}} + ] + } +} + +-- state/expect -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + }, "openconfig-interfaces:interfaces": { "interface": [ { @@ -81,3 +102,12 @@ spec: ] } } + +-- state/delete -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} diff --git a/test/gnmi/testdata/openconfig/interface_aggregate_l3.txt b/test/gnmi/testdata/openconfig/interface_aggregate_l3.txt index 169d5f30d..dbddb861c 100644 --- a/test/gnmi/testdata/openconfig/interface_aggregate_l3.txt +++ b/test/gnmi/testdata/openconfig/interface_aggregate_l3.txt @@ -35,8 +35,29 @@ spec: mode: Active memberInterfaceRefs: - name: eth1-3 --- state -- + +-- state/preload -- { + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + }, + "openconfig-interfaces:interfaces": { + "interface": [ + {"name": "eth1/3", "state": {"oper-status": "UP"}}, + {"name": "po20", "state": {"oper-status": "UP"}} + ] + } +} + +-- state/expect -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + }, "openconfig-interfaces:interfaces": { "interface": [ { @@ -100,3 +121,12 @@ spec: ] } } + +-- state/delete -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} diff --git a/test/gnmi/testdata/openconfig/interface_loopback_multi_addr.txt b/test/gnmi/testdata/openconfig/interface_loopback_multi_addr.txt index 6588d4bca..650a26c26 100644 --- a/test/gnmi/testdata/openconfig/interface_loopback_multi_addr.txt +++ b/test/gnmi/testdata/openconfig/interface_loopback_multi_addr.txt @@ -16,8 +16,23 @@ spec: addresses: - 10.0.1.10/32 - 10.1.0.10/32 --- state -- + +-- state/preload -- { + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} + +-- state/expect -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + }, "openconfig-interfaces:interfaces": { "interface": [ { @@ -68,3 +83,12 @@ spec: ] } } + +-- state/delete -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} diff --git a/test/gnmi/testdata/openconfig/interface_physical_ipv4.txt b/test/gnmi/testdata/openconfig/interface_physical_ipv4.txt index 4ae047ff1..fc4279927 100644 --- a/test/gnmi/testdata/openconfig/interface_physical_ipv4.txt +++ b/test/gnmi/testdata/openconfig/interface_physical_ipv4.txt @@ -16,8 +16,23 @@ spec: ipv4: addresses: - 10.0.100.1/31 --- state -- + +-- state/preload -- { + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} + +-- state/expect -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + }, "openconfig-interfaces:interfaces": { "interface": [ { @@ -61,3 +76,12 @@ spec: ] } } + +-- state/delete -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} diff --git a/test/gnmi/testdata/openconfig/interface_physical_switchport_access.txt b/test/gnmi/testdata/openconfig/interface_physical_switchport_access.txt index a1ababd31..049b729d2 100644 --- a/test/gnmi/testdata/openconfig/interface_physical_switchport_access.txt +++ b/test/gnmi/testdata/openconfig/interface_physical_switchport_access.txt @@ -16,8 +16,23 @@ spec: switchport: mode: Access accessVlan: 10 --- state -- + +-- state/preload -- { + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} + +-- state/expect -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + }, "openconfig-interfaces:interfaces": { "interface": [ { @@ -42,3 +57,12 @@ spec: ] } } + +-- state/delete -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} diff --git a/test/gnmi/testdata/openconfig/interface_physical_switchport_trunk.txt b/test/gnmi/testdata/openconfig/interface_physical_switchport_trunk.txt index 31cb33f5c..2c0f30c4a 100644 --- a/test/gnmi/testdata/openconfig/interface_physical_switchport_trunk.txt +++ b/test/gnmi/testdata/openconfig/interface_physical_switchport_trunk.txt @@ -17,8 +17,23 @@ spec: mode: Trunk nativeVlan: 1 allowedVlans: [10] --- state -- + +-- state/preload -- { + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} + +-- state/expect -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + }, "openconfig-interfaces:interfaces": { "interface": [ { @@ -44,3 +59,12 @@ spec: ] } } + +-- state/delete -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} diff --git a/test/gnmi/testdata/openconfig/interface_physical_unnumbered.txt b/test/gnmi/testdata/openconfig/interface_physical_unnumbered.txt index 0a0c6dcc2..004cb8b56 100644 --- a/test/gnmi/testdata/openconfig/interface_physical_unnumbered.txt +++ b/test/gnmi/testdata/openconfig/interface_physical_unnumbered.txt @@ -14,6 +14,7 @@ spec: ipv4: addresses: - 10.0.0.10/32 + -- interfaces/eth1-1 -- apiVersion: networking.metal.ironcore.dev/v1alpha1 kind: Interface @@ -32,8 +33,23 @@ spec: unnumbered: interfaceRef: name: lo0 --- state -- + +-- state/preload -- { + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} + +-- state/expect -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + }, "openconfig-interfaces:interfaces": { "interface": [ { @@ -108,3 +124,12 @@ spec: ] } } + +-- state/delete -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} diff --git a/test/gnmi/testdata/openconfig/interface_routed_vlan.txt b/test/gnmi/testdata/openconfig/interface_routed_vlan.txt.skip similarity index 92% rename from test/gnmi/testdata/openconfig/interface_routed_vlan.txt rename to test/gnmi/testdata/openconfig/interface_routed_vlan.txt.skip index 6fa619bce..36326ab98 100644 --- a/test/gnmi/testdata/openconfig/interface_routed_vlan.txt +++ b/test/gnmi/testdata/openconfig/interface_routed_vlan.txt.skip @@ -29,7 +29,7 @@ spec: ipv4: addresses: - 192.168.10.254/24 --- state -- +-- state/expect -- { "openconfig-interfaces:interfaces": { "interface": [ @@ -79,3 +79,12 @@ spec: ] } } + +-- state/delete -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +} From 5c419bbfaa26c62fedb544ca019c02cb340c8a80 Mon Sep 17 00:00:00 2001 From: Pujol Date: Thu, 23 Jul 2026 09:34:05 +0200 Subject: [PATCH 2/3] fix(test/gnmi): Skip interface_routed_vlan test (OpenConfig provider lacks VLAN support) The OpenConfig provider does not yet support VLAN interface configuration, causing the interface_routed_vlan test to fail. Skip this test by renaming the fixture file to .skip extension until VLAN support is implemented. Signed-off-by: Pujol --- test/gnmi/testdata/openconfig/interface_routed_vlan.txt.skip | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/gnmi/testdata/openconfig/interface_routed_vlan.txt.skip b/test/gnmi/testdata/openconfig/interface_routed_vlan.txt.skip index 36326ab98..093118ecd 100644 --- a/test/gnmi/testdata/openconfig/interface_routed_vlan.txt.skip +++ b/test/gnmi/testdata/openconfig/interface_routed_vlan.txt.skip @@ -1,4 +1,5 @@ # Routed VLAN (SVI) Interface + -- vlans/vlan-10 -- apiVersion: networking.metal.ironcore.dev/v1alpha1 kind: VLAN @@ -10,6 +11,7 @@ spec: name: device id: 10 name: VLAN10 + -- interfaces/svi-10 -- apiVersion: networking.metal.ironcore.dev/v1alpha1 kind: Interface @@ -29,6 +31,7 @@ spec: ipv4: addresses: - 192.168.10.254/24 + -- state/expect -- { "openconfig-interfaces:interfaces": { From d4a37740c4edd5331bd00d8f0685fd668c641216 Mon Sep 17 00:00:00 2001 From: Pujol Date: Mon, 27 Jul 2026 14:59:35 +0200 Subject: [PATCH 3/3] ci(test/gnmi): Run OpenConfig gNMI integration tests in CI Signed-off-by: Pujol --- .github/workflows/test-gnmi.yaml | 37 ++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 .github/workflows/test-gnmi.yaml diff --git a/.github/workflows/test-gnmi.yaml b/.github/workflows/test-gnmi.yaml new file mode 100644 index 000000000..0f9319a0e --- /dev/null +++ b/.github/workflows/test-gnmi.yaml @@ -0,0 +1,37 @@ +# SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +# SPDX-License-Identifier: Apache-2.0 + +name: Test gNMI integration + +on: + pull_request: + branches: + - main + paths: + - '**.go' + - 'hack/**' + - 'config/**' + - 'go.mod' + - 'go.sum' + - 'Makefile' + +permissions: + contents: read + +jobs: + test-gnmi: + name: gNMI Integration (${{ matrix.provider }}) + runs-on: ubuntu-latest + strategy: + fail-fast: true + matrix: + provider: + - openconfig + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v6 + with: + go-version-file: 'go.mod' + - name: Running gNMI tests + run: make test-gnmi PROVIDER=${{ matrix.provider }} GINKGO_PROCS=$(nproc) +