diff --git a/.github/workflows/test-gnmi.yaml b/.github/workflows/test-gnmi.yaml new file mode 100644 index 000000000..641468e7b --- /dev/null +++ b/.github/workflows/test-gnmi.yaml @@ -0,0 +1,39 @@ +# 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: Install ginkgo + run: go install github.com/onsi/ginkgo/v2/ginkgo@latest + - name: Running gNMI tests + run: make test-gnmi PROVIDER=${{ matrix.provider }} GINKGO_PROCS=$(nproc) + diff --git a/Makefile b/Makefile index a52d4058c..28ae00414 100644 --- a/Makefile +++ b/Makefile @@ -99,9 +99,14 @@ 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) +# Number of parallel Ginkgo processes +GINKGO_PROCS ?= $(shell nproc 2>/dev/null || sysctl -n hw.logicalcpu) + .PHONY: test-gnmi -test-gnmi: FORCE ## Run integration tests for gNMI. - @printf "\e[1;33m>> gNMI integration tests not yet implemented\e[0m\n" +test-gnmi: ## Run gNMI integration tests + @if ! hash setup-envtest 2>/dev/null; then printf "\e[1;36m>> Installing setup-envtest...\e[0m\n"; go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest; fi + @printf "\e[1;36m>> ginkgo run --procs=$(GINKGO_PROCS) ./test/gnmi/ (PROVIDER=$(PROVIDER))\e[0m\n" + @KUBEBUILDER_ASSETS=$$(setup-envtest use 1.32 -p path) PROVIDER=$(PROVIDER) ginkgo run --tags=gnmi --procs=$(GINKGO_PROCS) ./test/gnmi/ .PHONY: test-lab test-lab: ## Run lab tests against a real network device. diff --git a/go.mod b/go.mod index 677b10589..53bfbd0f2 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 @@ -19,6 +20,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 @@ -108,12 +110,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 aaa3a9e3b..df9ed9eb8 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= @@ -218,28 +241,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= @@ -250,6 +288,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= @@ -257,8 +301,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..b100f5321 --- /dev/null +++ b/test/gnmi/gnmi_suite_test.go @@ -0,0 +1,358 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build gnmi + +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..8a749fbe1 --- /dev/null +++ b/test/gnmi/gnmi_test.go @@ -0,0 +1,312 @@ +// SPDX-FileCopyrightText: 2026 SAP SE or an SAP affiliate company and IronCore contributors +// SPDX-License-Identifier: Apache-2.0 + +//go:build gnmi + +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, _ := filepath.Glob(filepath.Join(testdataDir, "*.txt")) + 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.Lock() + serverState.Buf = nil + serverState.Unlock() + }) + + // 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.Lock() + serverState.Buf = make([]byte, len(statePre)) + copy(serverState.Buf, statePre) + serverState.Unlock() + } + + 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) { + serverState.RLock() + stateJSON := make([]byte, len(serverState.Buf)) + copy(stateJSON, serverState.Buf) + serverState.RUnlock() + 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) { + serverState.RLock() + stateJSON := make([]byte, len(serverState.Buf)) + copy(stateJSON, serverState.Buf) + serverState.RUnlock() + 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()) + + 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..b65198626 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,16 @@ 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 } 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..093118ecd 100644 --- a/test/gnmi/testdata/openconfig/interface_routed_vlan.txt +++ 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,7 +31,8 @@ spec: ipv4: addresses: - 192.168.10.254/24 --- state -- + +-- state/expect -- { "openconfig-interfaces:interfaces": { "interface": [ @@ -79,3 +82,12 @@ spec: ] } } + +-- state/delete -- +{ + "openconfig-system:system": { + "state": { + "boot-time": "1784731445707000000" + } + } +}