From a3b0be42d811d864cd0c140f7dd612fcb40db1a3 Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Thu, 6 Aug 2026 14:05:38 -0300 Subject: [PATCH 1/9] chore(deps): bump gateway-api to v1.5.1 and envoyproxy/gateway to v1.8.2 Required for the stable ListenerSet type (gateway.networking.k8s.io/v1, GA in Gateway API v1.5) and to align the SecurityPolicy types with the Envoy Gateway v1.8.x runtime. Pulls k8s.io libs to 0.36 and controller-runtime to 0.24; build, vet, and unit tests pass unchanged. --- go.mod | 110 +++++++++++----------- go.sum | 290 ++++++++++++++++++++++++++++----------------------------- 2 files changed, 197 insertions(+), 203 deletions(-) diff --git a/go.mod b/go.mod index 3709dc1..83d13e2 100644 --- a/go.mod +++ b/go.mod @@ -1,22 +1,23 @@ module github.com/nebari-dev/nebari-operator -go 1.25.6 +go 1.26.4 require ( github.com/Nerzal/gocloak/v13 v13.9.0 github.com/cert-manager/cert-manager v1.18.6 - github.com/envoyproxy/gateway v1.6.3 - github.com/onsi/ginkgo/v2 v2.23.4 - github.com/onsi/gomega v1.37.0 - k8s.io/api v0.34.1 - k8s.io/apimachinery v0.34.1 - k8s.io/client-go v0.34.1 - sigs.k8s.io/controller-runtime v0.22.4 - sigs.k8s.io/gateway-api v1.4.1 + github.com/envoyproxy/gateway v1.8.2 + github.com/onsi/ginkgo/v2 v2.28.0 + github.com/onsi/gomega v1.39.1 + k8s.io/api v0.36.2 + k8s.io/apimachinery v0.36.2 + k8s.io/client-go v0.36.2 + sigs.k8s.io/controller-runtime v0.24.1 + sigs.k8s.io/gateway-api v1.5.1 ) require ( - cel.dev/expr v0.24.0 // indirect + cel.dev/expr v0.25.1 // indirect + github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect @@ -27,30 +28,28 @@ require ( github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect - github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fsnotify/fsnotify v1.10.1 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/zapr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.22.1 // indirect - github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/jsonpointer v0.23.1 // indirect + github.com/go-openapi/jsonreference v0.21.6 // indirect github.com/go-openapi/swag v0.23.1 // indirect - github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-openapi/swag/jsonname v0.26.1 // indirect github.com/go-resty/resty/v2 v2.7.0 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect - github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect - github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.0 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a // indirect + github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/mailru/easyjson v0.9.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect @@ -59,53 +58,54 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect - github.com/prometheus/procfs v0.17.0 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/segmentio/ksuid v1.0.4 // indirect - github.com/spf13/cobra v1.10.1 // indirect + github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stoewer/go-strcase v1.3.1 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect - go.opentelemetry.io/otel v1.38.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.38.0 // indirect - go.opentelemetry.io/otel/sdk v1.38.0 // indirect - go.opentelemetry.io/otel/trace v1.38.0 // indirect - go.opentelemetry.io/proto/otlp v1.8.0 // indirect - go.uber.org/automaxprocs v1.6.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect + go.uber.org/zap v1.28.0 // indirect + go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect - golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sync v0.18.0 // indirect - golang.org/x/sys v0.38.0 // indirect - golang.org/x/term v0.37.0 // indirect - golang.org/x/text v0.31.0 // indirect - golang.org/x/time v0.13.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.55.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.45.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 // indirect - google.golang.org/grpc v1.76.0 // indirect - google.golang.org/protobuf v1.36.10 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.81.1 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.34.1 // indirect - k8s.io/apiserver v0.34.1 // indirect - k8s.io/component-base v0.34.1 // indirect - k8s.io/klog/v2 v2.130.1 // indirect - k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect - k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d // indirect - sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 // indirect + k8s.io/apiextensions-apiserver v0.36.2 // indirect + k8s.io/apiserver v0.36.2 // indirect + k8s.io/component-base v0.36.2 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/streaming v0.36.2 // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect - sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 7c414e3..ee03b09 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,7 @@ -cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= -cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= +github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Nerzal/gocloak/v13 v13.9.0 h1:YWsJsdM5b0yhM2Ba3MLydiOlujkBry4TtdzfIzSVZhw= github.com/Nerzal/gocloak/v13 v13.9.0/go.mod h1:YYuDcXZ7K2zKECyVP7pPqjKxx2AzYSpKDj8d6GuyM10= github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= @@ -21,18 +23,24 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1 github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/envoyproxy/gateway v1.6.3 h1:HPO5vUHpw/h3H83UaN8xtSxNXfYUrkSDnUeJ+jvz1aM= -github.com/envoyproxy/gateway v1.6.3/go.mod h1:YXE9a1t4gOpzH6rreY7RzoZrQCIbYSRGvKRnZU3K3E8= +github.com/envoyproxy/gateway v1.8.2 h1:d742/gq9RkWXH1t6NBtQlRi9AVvheOC2GPwqfzSZH9c= +github.com/envoyproxy/gateway v1.8.2/go.mod h1:3xA31iuvxcu8DKlfIqhIxfk+FdYyrGIQ0giy4XG6j4Q= github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= 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.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -40,26 +48,26 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= -github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= -github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= -github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= -github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= +github.com/go-openapi/jsonpointer v0.23.1/go.mod h1:iWRmZTrGn7XwYhtPt/fvdSFj1OfNBngqRT2UG3BxSqY= +github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= +github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= -github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= -github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-openapi/swag/jsonname v0.26.1 h1:VReupaV6WxlAsCn0e4DUfgV6bPmINnPpyJDLqSfNPcE= +github.com/go-openapi/swag/jsonname v0.26.1/go.mod h1:OvdW6BoWoj33pTfi7x9vFrgmT+fk7aw0BRwvCE0YOuc= +github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0an6sBo= +github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= -github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= -github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +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-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= @@ -69,30 +77,34 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a h1://KbezygeMJZCSHH+HgUZiTeSoiuFspbMg1ge+eFj18= -github.com/google/pprof v0.0.0-20250607225305-033d6d78b36a/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= 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.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.1 h1:bcSGx7UbpBqMChDtsF28Lw6v/G94LPrrbMbdC3JH2co= -github.com/klauspost/compress v1.18.1/go.mod h1:ZQFFVG+MdnR0P+l6wpXgIL4NTtwiKIdBnrBd8Nrxr+0= +github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= +github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -101,10 +113,10 @@ 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/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus= -github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= -github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y= -github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc= +github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -112,23 +124,21 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= -github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= -github.com/prometheus/procfs v0.17.0 h1:FuLQ+05u4ZI+SS/w9+BWEM2TXiHKsUQ9TADiRH7DuK0= -github.com/prometheus/procfs v0.17.0/go.mod h1:oPQLaDAMRbA+u8H5Pbfq+dl3VDAvHxMUOVhe0wYB2zw= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= -github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= -github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -145,104 +155,86 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= -go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= -go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= -go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= -go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= -go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= -go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= -go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= -go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= -go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= -go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= -go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE= -go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0= -go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= -go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= -go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= -go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +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.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= -golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= -golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= -golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= +golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= -golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= -golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= -golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= -golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= -golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= -golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= -golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= -golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI= -golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +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.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= -golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= 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.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= -google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +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= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -253,35 +245,37 @@ gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= -k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= -k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= -k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= -k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= -k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= -k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA= -k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0= -k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= -k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= -k8s.io/component-base v0.34.1 h1:v7xFgG+ONhytZNFpIz5/kecwD+sUhVE6HU7qQUiRM4A= -k8s.io/component-base v0.34.1/go.mod h1:mknCpLlTSKHzAQJJnnHVKqjxR7gBeHRv0rPXA7gdtQ0= -k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= -k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= -k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= -k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d h1:wAhiDyZ4Tdtt7e46e9M5ZSAJ/MnPGPs+Ki1gHw4w1R0= -k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 h1:qPrZsv1cwQiFeieFlRqT627fVZ+tyfou/+S5S0H5ua0= -sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= -sigs.k8s.io/controller-runtime v0.22.4 h1:GEjV7KV3TY8e+tJ2LCTxUTanW4z/FmNB7l327UfMq9A= -sigs.k8s.io/controller-runtime v0.22.4/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= -sigs.k8s.io/gateway-api v1.4.1 h1:NPxFutNkKNa8UfLd2CMlEuhIPMQgDQ6DXNKG9sHbJU8= -sigs.k8s.io/gateway-api v1.4.1/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk= +k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= +k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg= +k8s.io/apiextensions-apiserver v0.36.2 h1:3O5gqOj/dt2XWWbpMe+TXWpE9yU6pjM/tXxtHHJT/K4= +k8s.io/apiextensions-apiserver v0.36.2/go.mod h1:cL1tBWe8XSaP1H30iWKGo7hf6iAUUUJPEU70dskmAnA= +k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ= +k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4= +k8s.io/apiserver v0.36.2 h1:6vMnkmHZPeBloNkHUhmZYq7Ylv8WIB8xjyEl+eSt26E= +k8s.io/apiserver v0.36.2/go.mod h1:9PoQ2ikCytrZyZg11mGhLEF5m8Rgsb5FJmYJ4Wvnl1k= +k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= +k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= +k8s.io/component-base v0.36.2 h1:Z0VH80O7Ng0HDZnZj3WRR3urEGa0kTwmO8CwEwjVK1w= +k8s.io/component-base v0.36.2/go.mod h1:mGfFOA7Gwpdm1VW2cwSQYbiDIlz8GD2WGwH88QSeCyA= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/streaming v0.36.2 h1:NSKthPPg9UFSKsRauVJUVGH2Dvn8fhKmY4qrMkw/p98= +k8s.io/streaming v0.36.2/go.mod h1:z6fV3D+NVkoeqRMtWwlUZK6U17SY/LqNzOxWL6GyR/s= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= +sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= +sigs.k8s.io/gateway-api v1.5.1 h1:RqVRIlkhLhUO8wOHKTLnTJA6o/1un4po4/6M1nRzdd0= +sigs.k8s.io/gateway-api v1.5.1/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= From ff402f91939bd03c67d1d67ff2a1a212690b152f Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Thu, 6 Aug 2026 14:17:58 -0300 Subject: [PATCH 2/9] feat(tls): per-app ListenerSet for TLS ownership (staged migration) Introduce the per-app ListenerSet primitive (ADR-0011 Option 2): the operator creates a ListenerSet in the NebariApp's own namespace, attached to the shared Gateway via spec.parentRef, carrying the app's HTTPS listener with a co-located, owner-referenced Certificate. Staged, status-gated migration: the ListenerSet is always reconciled, but the legacy shared-Gateway listener keeps serving until the ListenerSet reports Accepted+Programmed. Only then do HTTPRoutes retarget to the ListenerSet and the shared listener is removed. On an Envoy Gateway that does not reconcile ListenerSet (pre-v1.8) the conditions never flip, so the operator stays on the legacy path and per-app TLS is unaffected. - TLSResult gains UseListenerSet; routing parentRef targets the ListenerSet (app namespace) once cut over, else the shared Gateway. - reconcileCertificate/isCertificateReady/checkUserProvidedSecret are namespace-parameterized; the ListenerSet cert is owner-referenced. - RBAC: add listenersets (+status); Owns(ListenerSet) so a Programmed transition drives the cutover reconcile. - Regenerated CRDs/RBAC. Closes #168. --- config/rbac/role.yaml | 7 + internal/controller/nebariapp_controller.go | 14 +- .../reconcilers/routing/httproute.go | 75 ++++--- .../routing/httproute_edgecases_test.go | 4 +- .../reconcilers/routing/httproute_test.go | 20 +- .../controller/reconcilers/tls/listenerset.go | 202 ++++++++++++++++++ .../controller/reconcilers/tls/reconciler.go | 119 ++++++++--- .../reconcilers/tls/reconciler_test.go | 4 +- .../controller/utils/constants/constants.go | 4 + internal/controller/utils/naming/naming.go | 9 + 10 files changed, 386 insertions(+), 72 deletions(-) create mode 100644 internal/controller/reconcilers/tls/listenerset.go diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 6496149..6f98b01 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -69,6 +69,7 @@ rules: - gateway.networking.k8s.io resources: - httproutes + - listenersets verbs: - create - delete @@ -77,6 +78,12 @@ rules: - patch - update - watch +- apiGroups: + - gateway.networking.k8s.io + resources: + - listenersets/status + verbs: + - get - apiGroups: - rbac.authorization.k8s.io resources: diff --git a/internal/controller/nebariapp_controller.go b/internal/controller/nebariapp_controller.go index b701f09..be2add4 100644 --- a/internal/controller/nebariapp_controller.go +++ b/internal/controller/nebariapp_controller.go @@ -31,6 +31,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" @@ -66,6 +67,8 @@ type NebariAppReconciler struct { // +kubebuilder:rbac:groups=core,resources=events,verbs=create;patch // +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=gateways,verbs=get;list;watch;update;patch +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=listenersets,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=listenersets/status,verbs=get // +kubebuilder:rbac:groups=cert-manager.io,resources=certificates,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=gateway.envoyproxy.io,resources=securitypolicies,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=rbac.authorization.k8s.io,resources=roles,verbs=get;list;watch;create;update;patch;delete @@ -150,6 +153,7 @@ func (r *NebariAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // a ClusterIssuer nor routing.tls.secretName is available. The nil guard below is // kept so tests can opt out of TLS reconciliation by leaving the field unset. var tlsListenerName string + var tlsUseListenerSet bool if r.TLSReconciler != nil { tlsResult, err := r.TLSReconciler.ReconcileTLS(ctx, nebariApp) if err != nil { @@ -163,6 +167,7 @@ func (r *NebariAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } if tlsResult != nil { tlsListenerName = tlsResult.ListenerName + tlsUseListenerSet = tlsResult.UseListenerSet if !tlsResult.CertReady { logger.Info("TLS secret not ready yet, will requeue") // Save status so TLSReady=False is visible, then requeue. @@ -182,7 +187,7 @@ func (r *NebariAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( // Reconcile routing (HTTPRoute creation/update) if routing is configured if nebariApp.Spec.Routing != nil { - if err := r.RoutingReconciler.ReconcileRouting(ctx, nebariApp, tlsListenerName); err != nil { + if err := r.RoutingReconciler.ReconcileRouting(ctx, nebariApp, tlsListenerName, tlsUseListenerSet); err != nil { logger.Error(err, "Routing reconciliation failed") conditions.SetCondition(nebariApp, appsv1.ConditionTypeReady, metav1.ConditionFalse, appsv1.ReasonFailed, fmt.Sprintf("Routing reconciliation failed: %v", err)) @@ -208,7 +213,7 @@ func (r *NebariAppReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } // Reconcile public route (unauthenticated paths) if routing has publicRoutes - if result, err := r.reconcilePublicRoutes(ctx, nebariApp, tlsListenerName); err != nil || result != nil { + if result, err := r.reconcilePublicRoutes(ctx, nebariApp, tlsListenerName, tlsUseListenerSet); err != nil || result != nil { if result != nil { return *result, err } @@ -307,7 +312,7 @@ func buildServiceDiscoveryStatus(app *appsv1.NebariApp) *appsv1.ServiceDiscovery // reconcilePublicRoutes handles public route reconciliation for paths that bypass OIDC. // Returns a non-nil Result pointer if the caller should return early. -func (r *NebariAppReconciler) reconcilePublicRoutes(ctx context.Context, nebariApp *appsv1.NebariApp, tlsListenerName string) (*ctrl.Result, error) { +func (r *NebariAppReconciler) reconcilePublicRoutes(ctx context.Context, nebariApp *appsv1.NebariApp, tlsListenerName string, useListenerSet bool) (*ctrl.Result, error) { logger := logf.FromContext(ctx) if nebariApp.Spec.Routing == nil || len(nebariApp.Spec.Routing.PublicRoutes) == 0 { @@ -321,7 +326,7 @@ func (r *NebariAppReconciler) reconcilePublicRoutes(ctx context.Context, nebariA return nil, nil } - if err := r.RoutingReconciler.ReconcilePublicRoute(ctx, nebariApp, tlsListenerName); err != nil { + if err := r.RoutingReconciler.ReconcilePublicRoute(ctx, nebariApp, tlsListenerName, useListenerSet); err != nil { logger.Error(err, "Public route reconciliation failed") conditions.SetCondition(nebariApp, appsv1.ConditionTypeReady, metav1.ConditionFalse, appsv1.ReasonFailed, fmt.Sprintf("Public route reconciliation failed: %v", err)) @@ -384,6 +389,7 @@ func (r *NebariAppReconciler) cleanup(ctx context.Context, nebariApp *appsv1.Neb func (r *NebariAppReconciler) SetupWithManager(mgr ctrl.Manager) error { builder := ctrl.NewControllerManagedBy(mgr). For(&appsv1.NebariApp{}). + Owns(&gatewayv1.ListenerSet{}). Named("nebariapp") // Watch cert-manager Certificates so that Certificate readiness transitions diff --git a/internal/controller/reconcilers/routing/httproute.go b/internal/controller/reconcilers/routing/httproute.go index 14bd12e..8598417 100644 --- a/internal/controller/reconcilers/routing/httproute.go +++ b/internal/controller/reconcilers/routing/httproute.go @@ -47,15 +47,16 @@ type RoutingReconciler struct { // tlsListenerName is the name of the per-app TLS listener on the Gateway, // provided by the TLS reconciler. When non-empty and TLS is enabled, the // HTTPRoute will target this listener instead of the default "https" listener. -func (r *RoutingReconciler) ReconcileRouting(ctx context.Context, nebariApp *appsv1.NebariApp, tlsListenerName string) error { +func (r *RoutingReconciler) ReconcileRouting(ctx context.Context, nebariApp *appsv1.NebariApp, tlsListenerName string, useListenerSet bool) error { logger := log.FromContext(ctx) // Determine which gateway to use gatewayName := naming.GatewayName(nebariApp) logger.Info("Reconciling routing", "gateway", gatewayName, "hostname", nebariApp.Spec.Hostname) - // Verify gateway exists - if err := r.validateGateway(ctx, gatewayName); err != nil { + // Verify the route's parent exists (the per-app ListenerSet once cut over, + // otherwise the shared Gateway). + if err := r.validateParent(ctx, nebariApp, gatewayName, useListenerSet); err != nil { logger.Error(err, "Gateway validation failed") r.Recorder.Event(nebariApp, corev1.EventTypeWarning, appsv1.EventReasonGatewayNotFound, err.Error()) conditions.SetCondition(nebariApp, appsv1.ConditionTypeRoutingReady, metav1.ConditionFalse, @@ -64,7 +65,7 @@ func (r *RoutingReconciler) ReconcileRouting(ctx context.Context, nebariApp *app } // Generate desired HTTPRoute - desiredRoute, err := r.buildHTTPRoute(nebariApp, gatewayName, tlsListenerName) + desiredRoute, err := r.buildHTTPRoute(nebariApp, gatewayName, tlsListenerName, useListenerSet) if err != nil { logger.Error(err, "Failed to build HTTPRoute") conditions.SetCondition(nebariApp, appsv1.ConditionTypeRoutingReady, metav1.ConditionFalse, @@ -159,9 +160,8 @@ func (r *RoutingReconciler) CleanupHTTPRoute(ctx context.Context, nebariApp *app // buildHTTPRoute generates an HTTPRoute resource from NebariApp spec. // tlsListenerName overrides the default "https" section name when TLS is enabled // and a per-app TLS listener has been created by the TLS reconciler. -func (r *RoutingReconciler) buildHTTPRoute(nebariApp *appsv1.NebariApp, gatewayName string, tlsListenerName string) (*gatewayv1.HTTPRoute, error) { +func (r *RoutingReconciler) buildHTTPRoute(nebariApp *appsv1.NebariApp, gatewayName string, tlsListenerName string, useListenerSet bool) (*gatewayv1.HTTPRoute, error) { routeName := naming.HTTPRouteName(nebariApp) - namespace := gatewayv1.Namespace(constants.GatewayNamespace) // Determine which Gateway listener to use // Priority: tlsListenerName (from TLS reconciler) > TLS enabled ("https") > TLS disabled ("http") @@ -199,11 +199,7 @@ func (r *RoutingReconciler) buildHTTPRoute(nebariApp *appsv1.NebariApp, gatewayN Spec: gatewayv1.HTTPRouteSpec{ CommonRouteSpec: gatewayv1.CommonRouteSpec{ ParentRefs: []gatewayv1.ParentReference{ - { - Name: gatewayv1.ObjectName(gatewayName), - Namespace: &namespace, - SectionName: §ionName, - }, + routeParentRef(nebariApp, gatewayName, sectionName, useListenerSet), }, }, Hostnames: []gatewayv1.Hostname{ @@ -297,7 +293,7 @@ func (r *RoutingReconciler) buildBackendRefs(nebariApp *appsv1.NebariApp) []gate // ReconcilePublicRoute creates or updates the public (unauthenticated) HTTPRoute for a NebariApp. // This route handles paths listed in routing.publicRoutes that should bypass OIDC authentication. -func (r *RoutingReconciler) ReconcilePublicRoute(ctx context.Context, nebariApp *appsv1.NebariApp, tlsListenerName string) error { +func (r *RoutingReconciler) ReconcilePublicRoute(ctx context.Context, nebariApp *appsv1.NebariApp, tlsListenerName string, useListenerSet bool) error { logger := log.FromContext(ctx) // Only create public route if there are public routes configured @@ -310,7 +306,7 @@ func (r *RoutingReconciler) ReconcilePublicRoute(ctx context.Context, nebariApp logger.Info("Reconciling public route", "gateway", gatewayName, "hostname", nebariApp.Spec.Hostname, "publicRoutes", nebariApp.Spec.Routing.PublicRoutes) - desiredRoute, err := r.buildPublicHTTPRoute(nebariApp, gatewayName, tlsListenerName) + desiredRoute, err := r.buildPublicHTTPRoute(nebariApp, gatewayName, tlsListenerName, useListenerSet) if err != nil { logger.Error(err, "Failed to build public HTTPRoute") conditions.SetCondition(nebariApp, appsv1.ConditionTypeRoutingReady, metav1.ConditionFalse, @@ -389,9 +385,8 @@ func (r *RoutingReconciler) CleanupPublicHTTPRoute(ctx context.Context, nebariAp // buildPublicHTTPRoute generates an HTTPRoute for public routes that bypass OIDC authentication. // This route is separate from the main route so the SecurityPolicy only targets the main route. -func (r *RoutingReconciler) buildPublicHTTPRoute(nebariApp *appsv1.NebariApp, gatewayName string, tlsListenerName string) (*gatewayv1.HTTPRoute, error) { +func (r *RoutingReconciler) buildPublicHTTPRoute(nebariApp *appsv1.NebariApp, gatewayName string, tlsListenerName string, useListenerSet bool) (*gatewayv1.HTTPRoute, error) { routeName := naming.PublicHTTPRouteName(nebariApp) - namespace := gatewayv1.Namespace(constants.GatewayNamespace) sectionName := gatewayv1.SectionName("https") tlsEnabled := true @@ -436,11 +431,7 @@ func (r *RoutingReconciler) buildPublicHTTPRoute(nebariApp *appsv1.NebariApp, ga Spec: gatewayv1.HTTPRouteSpec{ CommonRouteSpec: gatewayv1.CommonRouteSpec{ ParentRefs: []gatewayv1.ParentReference{ - { - Name: gatewayv1.ObjectName(gatewayName), - Namespace: &namespace, - SectionName: §ionName, - }, + routeParentRef(nebariApp, gatewayName, sectionName, useListenerSet), }, }, Hostnames: []gatewayv1.Hostname{ @@ -462,20 +453,56 @@ func (r *RoutingReconciler) buildPublicHTTPRoute(nebariApp *appsv1.NebariApp, ga return route, nil } -// validateGateway checks if the specified gateway exists -func (r *RoutingReconciler) validateGateway(ctx context.Context, gatewayName string) error { +// routeParentRef builds the ParentReference an HTTPRoute uses to attach: the +// per-app ListenerSet in the NebariApp's namespace once TLS has cut over to it +// (ADR-0011 Option 2), otherwise the shared Gateway in the Gateway namespace. +func routeParentRef(nebariApp *appsv1.NebariApp, gatewayName string, sectionName gatewayv1.SectionName, useListenerSet bool) gatewayv1.ParentReference { + if useListenerSet { + group := gatewayv1.Group(gatewayv1.GroupName) + kind := gatewayv1.Kind("ListenerSet") + ns := gatewayv1.Namespace(nebariApp.Namespace) + return gatewayv1.ParentReference{ + Group: &group, + Kind: &kind, + Name: gatewayv1.ObjectName(naming.ListenerSetName(nebariApp)), + Namespace: &ns, + SectionName: §ionName, + } + } + ns := gatewayv1.Namespace(constants.GatewayNamespace) + return gatewayv1.ParentReference{ + Name: gatewayv1.ObjectName(gatewayName), + Namespace: &ns, + SectionName: §ionName, + } +} + +// validateParent checks that the route's intended parent exists: the per-app +// ListenerSet (in the NebariApp namespace) once cut over, otherwise the shared +// Gateway (in the Gateway namespace). +func (r *RoutingReconciler) validateParent(ctx context.Context, nebariApp *appsv1.NebariApp, gatewayName string, useListenerSet bool) error { + if useListenerSet { + ls := &gatewayv1.ListenerSet{} + key := client.ObjectKey{Name: naming.ListenerSetName(nebariApp), Namespace: nebariApp.Namespace} + if err := r.Client.Get(ctx, key, ls); err != nil { + if errors.IsNotFound(err) { + return fmt.Errorf("listenerset %s not found in namespace %s", key.Name, key.Namespace) + } + return fmt.Errorf("failed to get listenerset: %w", err) + } + return nil + } + gateway := &gatewayv1.Gateway{} gatewayKey := client.ObjectKey{ Name: gatewayName, Namespace: constants.GatewayNamespace, } - if err := r.Client.Get(ctx, gatewayKey, gateway); err != nil { if errors.IsNotFound(err) { return fmt.Errorf("gateway %s not found in namespace %s", gatewayName, constants.GatewayNamespace) } return fmt.Errorf("failed to get gateway: %w", err) } - return nil } diff --git a/internal/controller/reconcilers/routing/httproute_edgecases_test.go b/internal/controller/reconcilers/routing/httproute_edgecases_test.go index 4958017..c27c5d7 100644 --- a/internal/controller/reconcilers/routing/httproute_edgecases_test.go +++ b/internal/controller/reconcilers/routing/httproute_edgecases_test.go @@ -163,7 +163,7 @@ func TestReconcileRoutingEdgeCases(t *testing.T) { Recorder: record.NewFakeRecorder(10), } - err := reconciler.ReconcileRouting(context.Background(), tt.nebariApp, "") + err := reconciler.ReconcileRouting(context.Background(), tt.nebariApp, "", false) if (err != nil) != tt.expectError { t.Errorf("expected error=%v, got error=%v", tt.expectError, err) } @@ -220,7 +220,7 @@ func TestHTTPRouteOwnerReference(t *testing.T) { Recorder: record.NewFakeRecorder(10), } - err := reconciler.ReconcileRouting(context.Background(), nebariApp, "") + err := reconciler.ReconcileRouting(context.Background(), nebariApp, "", false) if err != nil { t.Fatalf("ReconcileRouting failed: %v", err) } diff --git a/internal/controller/reconcilers/routing/httproute_test.go b/internal/controller/reconcilers/routing/httproute_test.go index a828318..18da100 100644 --- a/internal/controller/reconcilers/routing/httproute_test.go +++ b/internal/controller/reconcilers/routing/httproute_test.go @@ -75,7 +75,7 @@ func TestValidateGateway(t *testing.T) { Recorder: record.NewFakeRecorder(10), } - err := reconciler.validateGateway(context.Background(), tt.gatewayName) + err := reconciler.validateParent(context.Background(), &appsv1.NebariApp{}, tt.gatewayName, false) if (err != nil) != tt.expectError { t.Errorf("expected error=%v, got error=%v", tt.expectError, err) } @@ -202,7 +202,7 @@ func TestBuildHTTPRoute(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - route, err := reconciler.buildHTTPRoute(tt.nebariApp, tt.gatewayName, "") + route, err := reconciler.buildHTTPRoute(tt.nebariApp, tt.gatewayName, "", false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -277,7 +277,7 @@ func TestBuildHTTPRoute_SetControllerReferenceError(t *testing.T) { }, } - route, err := reconciler.buildHTTPRoute(nebariApp, "nebari-gateway", "") + route, err := reconciler.buildHTTPRoute(nebariApp, "nebari-gateway", "", false) if err == nil { t.Error("expected error when scheme has no types registered, got nil") } @@ -305,7 +305,7 @@ func TestBuildPublicHTTPRoute_SetControllerReferenceError(t *testing.T) { }, } - route, err := reconciler.buildPublicHTTPRoute(nebariApp, "nebari-gateway", "") + route, err := reconciler.buildPublicHTTPRoute(nebariApp, "nebari-gateway", "", false) if err == nil { t.Error("expected error when scheme has no types registered, got nil") } @@ -489,7 +489,7 @@ func TestReconcileRouting(t *testing.T) { Recorder: record.NewFakeRecorder(10), } - err := reconciler.ReconcileRouting(context.Background(), tt.nebariApp, "") + err := reconciler.ReconcileRouting(context.Background(), tt.nebariApp, "", false) if (err != nil) != tt.expectError { t.Errorf("expected error=%v, got error=%v", tt.expectError, err) } @@ -547,7 +547,7 @@ func TestReconcileRouting_BuildError(t *testing.T) { Recorder: record.NewFakeRecorder(10), } - err := reconciler.ReconcileRouting(context.Background(), nebariApp, "") + err := reconciler.ReconcileRouting(context.Background(), nebariApp, "", false) if err == nil { t.Fatal("expected error from ReconcileRouting when buildHTTPRoute fails, got nil") } @@ -712,7 +712,7 @@ func TestBuildPublicHTTPRoute(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - route, err := reconciler.buildPublicHTTPRoute(tt.nebariApp, tt.gatewayName, "") + route, err := reconciler.buildPublicHTTPRoute(tt.nebariApp, tt.gatewayName, "", false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -844,7 +844,7 @@ func TestReconcilePublicRoute(t *testing.T) { Scheme: scheme, Recorder: record.NewFakeRecorder(10), } - err := reconciler.ReconcilePublicRoute(context.Background(), tt.nebariApp, "") + err := reconciler.ReconcilePublicRoute(context.Background(), tt.nebariApp, "", false) if (err != nil) != tt.expectError { t.Errorf("expected error=%v, got error=%v", tt.expectError, err) } @@ -992,7 +992,7 @@ func TestBuildHTTPRouteWithTLSListener(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { reconciler := &RoutingReconciler{Scheme: scheme} - route, err := reconciler.buildHTTPRoute(tt.nebariApp, constants.PublicGatewayName, tt.tlsListenerName) + route, err := reconciler.buildHTTPRoute(tt.nebariApp, constants.PublicGatewayName, tt.tlsListenerName, false) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -1077,7 +1077,7 @@ func TestBuildHTTPRouteAnnotations(t *testing.T) { }, } - route, err := reconciler.buildHTTPRoute(nebariApp, constants.PublicGatewayName, "") + route, err := reconciler.buildHTTPRoute(nebariApp, constants.PublicGatewayName, "", false) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/controller/reconcilers/tls/listenerset.go b/internal/controller/reconcilers/tls/listenerset.go new file mode 100644 index 0000000..159a172 --- /dev/null +++ b/internal/controller/reconcilers/tls/listenerset.go @@ -0,0 +1,202 @@ +/* +Copyright 2026, OpenTeams. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tls + +import ( + "context" + "fmt" + + appsv1 "github.com/nebari-dev/nebari-operator/api/v1" + "github.com/nebari-dev/nebari-operator/internal/controller/utils/constants" + "github.com/nebari-dev/nebari-operator/internal/controller/utils/naming" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/log" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// Gateway API standard-channel ListenerSet condition types (gateway.networking.k8s.io/v1). +const ( + listenerSetConditionAccepted = "Accepted" + listenerSetConditionProgrammed = "Programmed" +) + +// reconcileListenerSet creates or updates the per-app ListenerSet (ADR-0011 +// Option 2). The ListenerSet lives in the NebariApp's own namespace, attaches to +// the shared Gateway via spec.parentRef, and carries a single HTTPS Terminate +// listener whose certificate secret is co-located in that same namespace (so no +// ReferenceGrant is needed). It is owner-referenced to the NebariApp, so it is +// garbage-collected with the app. +// +// This is always reconciled, even on an Envoy Gateway that does not yet support +// ListenerSet: there it simply never reaches Programmed=True and the caller keeps +// serving via the legacy shared-Gateway listener (see reconcileTLS phase logic). +func (r *TLSReconciler) reconcileListenerSet(ctx context.Context, nebariApp *appsv1.NebariApp, secretName string) error { + logger := log.FromContext(ctx) + + parentGatewayName := naming.GatewayName(nebariApp) + listenerName := naming.ListenerName(nebariApp) + hostname := gatewayv1.Hostname(nebariApp.Spec.Hostname) + tlsMode := gatewayv1.TLSModeTerminate + fromSame := gatewayv1.NamespacesFromSame + parentGroup := gatewayv1.Group(gatewayv1.GroupName) + parentKind := gatewayv1.Kind("Gateway") + parentNS := gatewayv1.Namespace(constants.GatewayNamespace) + + ls := &gatewayv1.ListenerSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: naming.ListenerSetName(nebariApp), + Namespace: nebariApp.Namespace, + }, + } + + op, err := controllerutil.CreateOrUpdate(ctx, r.Client, ls, func() error { + if ls.Labels == nil { + ls.Labels = make(map[string]string) + } + ls.Labels["app.kubernetes.io/managed-by"] = "nebari-operator" + ls.Labels["nebari.dev/nebariapp-name"] = nebariApp.Name + + ls.Spec.ParentRef = gatewayv1.ParentGatewayReference{ + Group: &parentGroup, + Kind: &parentKind, + Name: gatewayv1.ObjectName(parentGatewayName), + Namespace: &parentNS, + } + ls.Spec.Listeners = []gatewayv1.ListenerEntry{ + { + Name: gatewayv1.SectionName(listenerName), + Hostname: &hostname, + Port: 443, + Protocol: gatewayv1.HTTPSProtocolType, + TLS: &gatewayv1.ListenerTLSConfig{ + Mode: &tlsMode, + // No namespace on the ref: the secret is co-located in the + // ListenerSet's own namespace, so it resolves without a + // ReferenceGrant. + CertificateRefs: []gatewayv1.SecretObjectReference{ + {Name: gatewayv1.ObjectName(secretName)}, + }, + }, + AllowedRoutes: &gatewayv1.AllowedRoutes{ + Namespaces: &gatewayv1.RouteNamespaces{ + From: &fromSame, + }, + }, + }, + } + + // Same namespace as the NebariApp, so a real owner reference works and GC + // removes the ListenerSet with the app. + return controllerutil.SetControllerReference(nebariApp, ls, r.Scheme) + }) + if err != nil { + return fmt.Errorf("failed to create or update ListenerSet: %w", err) + } + + logger.Info("ListenerSet reconciled", + "listenerSet", ls.Name, "namespace", nebariApp.Namespace, + "parentGateway", parentGatewayName, "operation", op) + if op == controllerutil.OperationResultCreated { + r.Recorder.Event(nebariApp, corev1.EventTypeNormal, appsv1.EventReasonGatewayListenerAdded, + fmt.Sprintf("Created ListenerSet %s/%s attached to Gateway %s/%s", + nebariApp.Namespace, ls.Name, constants.GatewayNamespace, parentGatewayName)) + } + return nil +} + +// isListenerSetProgrammed reports whether this NebariApp's ListenerSet has been +// accepted and programmed by the gateway controller. The staged migration only +// cuts routes over to the ListenerSet (and tears down the legacy shared-Gateway +// listener) once both conditions are True. On an Envoy Gateway that does not +// reconcile ListenerSet (e.g. pre-v1.8), the conditions never flip and this +// returns false, so the operator keeps serving via the legacy path. +// +// A missing ListenerSet returns (false, nil): not yet created, treat as not +// programmed rather than an error. +func (r *TLSReconciler) isListenerSetProgrammed(ctx context.Context, nebariApp *appsv1.NebariApp) (bool, error) { + ls := &gatewayv1.ListenerSet{} + if err := r.Client.Get(ctx, types.NamespacedName{ + Name: naming.ListenerSetName(nebariApp), + Namespace: nebariApp.Namespace, + }, ls); err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, fmt.Errorf("failed to get ListenerSet for status check: %w", err) + } + + accepted, programmed := false, false + for _, c := range ls.Status.Conditions { + switch c.Type { + case listenerSetConditionAccepted: + accepted = c.Status == metav1.ConditionTrue + case listenerSetConditionProgrammed: + programmed = c.Status == metav1.ConditionTrue + } + } + return accepted && programmed, nil +} + +// reconcileTLSAttachment reconciles the per-app ListenerSet and decides which +// listener actually serves this app's HTTPS traffic, returning whether the app +// has cut over to the ListenerSet. +// +// Staged, status-gated migration (ADR-0011 Option 2): +// - The ListenerSet is always (re)created. +// - Until it reports Accepted+Programmed, the legacy per-app listener on the +// shared Gateway is kept in place, so TLS keeps working on an Envoy Gateway +// that does not yet reconcile ListenerSet (pre-v1.8) or has not programmed it +// yet. +// - Once Programmed, the shared-Gateway listener is removed and traffic is +// served by the ListenerSet. Cutover is per-NebariApp and driven by status, +// with no user-facing strategy flag. +// +// secretName is the TLS secret name; it is resolved in the app namespace for the +// ListenerSet and in the Gateway namespace for the legacy shared listener. +func (r *TLSReconciler) reconcileTLSAttachment(ctx context.Context, nebariApp *appsv1.NebariApp, secretName string) (bool, error) { + logger := log.FromContext(ctx) + + if err := r.reconcileListenerSet(ctx, nebariApp, secretName); err != nil { + return false, err + } + + programmed, err := r.isListenerSetProgrammed(ctx, nebariApp) + if err != nil { + return false, err + } + + if programmed { + // Cut over: the ListenerSet is serving, so retire the legacy shared-Gateway + // listener. removeGatewayListener is idempotent (no-op once removed). + if err := r.removeGatewayListener(ctx, nebariApp); err != nil { + return false, err + } + logger.V(1).Info("Serving via per-app ListenerSet; legacy Gateway listener retired", + "listenerSet", naming.ListenerSetName(nebariApp)) + return true, nil + } + + // Not yet programmed: keep the legacy shared-Gateway listener serving. + if err := r.reconcileGatewayListener(ctx, nebariApp, secretName); err != nil { + return false, err + } + return false, nil +} diff --git a/internal/controller/reconcilers/tls/reconciler.go b/internal/controller/reconcilers/tls/reconciler.go index 9b3d0a0..c4f1a7f 100644 --- a/internal/controller/reconcilers/tls/reconciler.go +++ b/internal/controller/reconcilers/tls/reconciler.go @@ -62,6 +62,14 @@ type TLSResult struct { // on the user-provided-secret path it reflects whether the named secret exists // and is of type kubernetes.io/tls. CertReady bool + + // UseListenerSet reports whether this app has cut over to the per-app + // ListenerSet path (ADR-0011 Option 2). When true, the app's HTTPS listener + // lives on a ListenerSet in the NebariApp's own namespace (attached to the + // shared Gateway via parentRef) and HTTPRoutes must attach to that ListenerSet + // rather than to a listener on the shared Gateway. When false, the legacy + // shared-Gateway listener is in use. See reconcileTLSAttachment. + UseListenerSet bool } // isTLSEnabled returns true if TLS is enabled for the NebariApp. @@ -124,13 +132,20 @@ func (r *TLSReconciler) ReconcileTLS(ctx context.Context, nebariApp *appsv1.Neba "clusterIssuer", r.ClusterIssuerName, "gateway", naming.GatewayName(nebariApp)) - if err := r.reconcileCertificate(ctx, nebariApp); err != nil { + secretName := naming.CertificateSecretName(nebariApp) + + // App-namespace Certificate for the per-app ListenerSet (ADR-0011 Option 2). + // Reconciled regardless of phase so that, on an Envoy Gateway that supports + // ListenerSet, the listener has a secret to program against and can reach + // Programmed=True. Owner-referenced (same namespace) for garbage collection. + if err := r.reconcileCertificate(ctx, nebariApp, nebariApp.Namespace, true); err != nil { conditions.SetCondition(nebariApp, appsv1.ConditionTypeTLSReady, metav1.ConditionFalse, "CertificateFailed", fmt.Sprintf("Failed to reconcile Certificate: %v", err)) return nil, err } - if err := r.reconcileGatewayListener(ctx, nebariApp, naming.CertificateSecretName(nebariApp)); err != nil { + useListenerSet, err := r.reconcileTLSAttachment(ctx, nebariApp, secretName) + if err != nil { if containsListenerConflict(err) { conditions.SetCondition(nebariApp, appsv1.ConditionTypeTLSReady, metav1.ConditionFalse, appsv1.ReasonGatewayListenerConflict, @@ -139,12 +154,30 @@ func (r *TLSReconciler) ReconcileTLS(ctx context.Context, nebariApp *appsv1.Neba nebariApp.Spec.Hostname)) } else { conditions.SetCondition(nebariApp, appsv1.ConditionTypeTLSReady, metav1.ConditionFalse, - "GatewayListenerFailed", fmt.Sprintf("Failed to reconcile Gateway listener: %v", err)) + "GatewayListenerFailed", fmt.Sprintf("Failed to reconcile TLS listener: %v", err)) } return nil, err } - certReady, err := r.isCertificateReady(ctx, nebariApp) + // Once cut over to the ListenerSet, the legacy shared-Gateway Certificate is + // no longer referenced; drop it (idempotent, label-matched). Before cutover, + // keep it: it backs the shared-Gateway listener that is still serving. + if useListenerSet { + if err := r.cleanupOwnedCertificate(ctx, nebariApp); err != nil { + logger.Error(err, "failed to clean up legacy Gateway-namespace Certificate after ListenerSet cutover") + } + } else if err := r.reconcileCertificate(ctx, nebariApp, constants.GatewayNamespace, false); err != nil { + conditions.SetCondition(nebariApp, appsv1.ConditionTypeTLSReady, metav1.ConditionFalse, + "CertificateFailed", fmt.Sprintf("Failed to reconcile Certificate: %v", err)) + return nil, err + } + + // Certificate readiness reflects whichever namespace is actively serving. + certNS := constants.GatewayNamespace + if useListenerSet { + certNS = nebariApp.Namespace + } + certReady, err := r.isCertificateReady(ctx, nebariApp, certNS) if err != nil { conditions.SetCondition(nebariApp, appsv1.ConditionTypeTLSReady, metav1.ConditionFalse, "CertificateCheckFailed", fmt.Sprintf("Failed to check Certificate readiness: %v", err)) @@ -153,16 +186,17 @@ func (r *TLSReconciler) ReconcileTLS(ctx context.Context, nebariApp *appsv1.Neba if certReady { conditions.SetCondition(nebariApp, appsv1.ConditionTypeTLSReady, metav1.ConditionTrue, - "TLSConfigured", "TLS certificate is ready and Gateway listener is configured") + "TLSConfigured", "TLS certificate is ready and the listener is configured") } else { conditions.SetCondition(nebariApp, appsv1.ConditionTypeTLSReady, metav1.ConditionFalse, appsv1.ReasonCertificateNotReady, "Waiting for cert-manager Certificate to become ready") } return &TLSResult{ - ListenerName: naming.ListenerName(nebariApp), - SecretName: naming.CertificateSecretName(nebariApp), - CertReady: certReady, + ListenerName: naming.ListenerName(nebariApp), + SecretName: secretName, + CertReady: certReady, + UseListenerSet: useListenerSet, }, nil } @@ -181,7 +215,8 @@ func (r *TLSReconciler) reconcileUserProvidedTLS(ctx context.Context, nebariApp return nil, err } - if err := r.reconcileGatewayListener(ctx, nebariApp, secretName); err != nil { + useListenerSet, err := r.reconcileTLSAttachment(ctx, nebariApp, secretName) + if err != nil { if containsListenerConflict(err) { conditions.SetCondition(nebariApp, appsv1.ConditionTypeTLSReady, metav1.ConditionFalse, appsv1.ReasonGatewayListenerConflict, @@ -190,11 +225,20 @@ func (r *TLSReconciler) reconcileUserProvidedTLS(ctx context.Context, nebariApp nebariApp.Spec.Hostname)) } else { conditions.SetCondition(nebariApp, appsv1.ConditionTypeTLSReady, metav1.ConditionFalse, - "GatewayListenerFailed", fmt.Sprintf("Failed to reconcile Gateway listener: %v", err)) + "GatewayListenerFailed", fmt.Sprintf("Failed to reconcile TLS listener: %v", err)) } return nil, err } + // The user-provided secret is looked up in whichever namespace is serving: + // the app namespace once cut over to the ListenerSet, else the Gateway + // namespace for the legacy shared listener. TODO(#168): finish the + // user-secret migration so a single app-namespace secret drives both phases. + secretNS := constants.GatewayNamespace + if useListenerSet { + secretNS = nebariApp.Namespace + } + // Capture the previous TLSReady reason before SetCondition mutates it, so we // only emit an event when the reason actually transitions. ReconcileTLS runs // on every reconcile (~30s-1m), so unconditionally emitting would push the @@ -205,7 +249,7 @@ func (r *TLSReconciler) reconcileUserProvidedTLS(ctx context.Context, nebariApp prevReason = prev.Reason } - status, reason, msg := r.checkUserProvidedSecret(ctx, secretName) + status, reason, msg := r.checkUserProvidedSecret(ctx, secretNS, secretName) conditions.SetCondition(nebariApp, appsv1.ConditionTypeTLSReady, status, reason, msg) if reason != prevReason { @@ -222,9 +266,10 @@ func (r *TLSReconciler) reconcileUserProvidedTLS(ctx context.Context, nebariApp } return &TLSResult{ - ListenerName: naming.ListenerName(nebariApp), - SecretName: secretName, - CertReady: status == metav1.ConditionTrue, + ListenerName: naming.ListenerName(nebariApp), + SecretName: secretName, + CertReady: status == metav1.ConditionTrue, + UseListenerSet: useListenerSet, }, nil } @@ -255,8 +300,17 @@ func (r *TLSReconciler) CleanupTLS(ctx context.Context, nebariApp *appsv1.Nebari return nil } -// reconcileCertificate creates or updates a cert-manager Certificate for the NebariApp. -func (r *TLSReconciler) reconcileCertificate(ctx context.Context, nebariApp *appsv1.NebariApp) error { +// reconcileCertificate creates or updates a cert-manager Certificate for the +// NebariApp in the given namespace. +// +// Two homes exist during the ListenerSet migration (ADR-0011 Option 2): +// - constants.GatewayNamespace (ownerRef=false): the legacy home, referenced by +// the shared-Gateway listener. Cross-namespace from the NebariApp, so ownership +// is tracked by labels (SetControllerReference cannot cross namespaces). +// - nebariApp.Namespace (ownerRef=true): the per-app ListenerSet's home. Same +// namespace as the NebariApp, so the Certificate is owner-referenced and +// garbage-collected with the NebariApp. +func (r *TLSReconciler) reconcileCertificate(ctx context.Context, nebariApp *appsv1.NebariApp, namespace string, ownerRef bool) error { logger := log.FromContext(ctx) certName := naming.CertificateName(nebariApp) @@ -265,12 +319,14 @@ func (r *TLSReconciler) reconcileCertificate(ctx context.Context, nebariApp *app cert := &certmanagerv1.Certificate{ ObjectMeta: metav1.ObjectMeta{ Name: certName, - Namespace: constants.GatewayNamespace, + Namespace: namespace, }, } op, err := controllerutil.CreateOrUpdate(ctx, r.Client, cert, func() error { - // Set labels (cannot use SetControllerReference since Certificate is cross-namespace) + // Ownership labels are always set: the shared-Gateway (cross-namespace) + // home relies on them for cleanup, and they remain useful metadata on the + // app-namespace home where an ownerReference is also set. if cert.Labels == nil { cert.Labels = make(map[string]string) } @@ -287,6 +343,9 @@ func (r *TLSReconciler) reconcileCertificate(ctx context.Context, nebariApp *app }, } + if ownerRef { + return controllerutil.SetControllerReference(nebariApp, cert, r.Scheme) + } return nil }) @@ -294,15 +353,15 @@ func (r *TLSReconciler) reconcileCertificate(ctx context.Context, nebariApp *app return fmt.Errorf("failed to create or update Certificate: %w", err) } - logger.Info("Certificate reconciled", "name", certName, "namespace", constants.GatewayNamespace, "operation", op) + logger.Info("Certificate reconciled", "name", certName, "namespace", namespace, "operation", op) switch op { case controllerutil.OperationResultCreated: r.Recorder.Event(nebariApp, corev1.EventTypeNormal, appsv1.EventReasonCertificateCreated, - fmt.Sprintf("Created cert-manager Certificate %s/%s", constants.GatewayNamespace, certName)) + fmt.Sprintf("Created cert-manager Certificate %s/%s", namespace, certName)) case controllerutil.OperationResultUpdated: r.Recorder.Event(nebariApp, corev1.EventTypeNormal, appsv1.EventReasonCertificateUpdated, - fmt.Sprintf("Updated cert-manager Certificate %s/%s", constants.GatewayNamespace, certName)) + fmt.Sprintf("Updated cert-manager Certificate %s/%s", namespace, certName)) } return nil @@ -442,12 +501,12 @@ func toLower(c byte) byte { // isCertificateReady checks whether the cert-manager Certificate has a Ready=True condition. // Returns (ready, error) so that transient API failures are distinguished from "cert not ready". -func (r *TLSReconciler) isCertificateReady(ctx context.Context, nebariApp *appsv1.NebariApp) (bool, error) { +func (r *TLSReconciler) isCertificateReady(ctx context.Context, nebariApp *appsv1.NebariApp, namespace string) (bool, error) { certName := naming.CertificateName(nebariApp) cert := &certmanagerv1.Certificate{} if err := r.Client.Get(ctx, types.NamespacedName{ Name: certName, - Namespace: constants.GatewayNamespace, + Namespace: namespace, }, cert); err != nil { return false, fmt.Errorf("failed to get Certificate for readiness check: %w", err) } @@ -556,29 +615,29 @@ func (r *TLSReconciler) cleanupOwnedCertificate(ctx context.Context, nebariApp * // its readiness. The check is best-effort: a missing or malformed secret yields // ConditionFalse but does not error, so the caller can still proceed to attach // the listener. -func (r *TLSReconciler) checkUserProvidedSecret(ctx context.Context, secretName string) (metav1.ConditionStatus, string, string) { +func (r *TLSReconciler) checkUserProvidedSecret(ctx context.Context, namespace, secretName string) (metav1.ConditionStatus, string, string) { secret := &corev1.Secret{} err := r.Client.Get(ctx, types.NamespacedName{ Name: secretName, - Namespace: constants.GatewayNamespace, + Namespace: namespace, }, secret) if err != nil { if apierrors.IsNotFound(err) { return metav1.ConditionFalse, appsv1.ReasonUserProvidedSecretNotFound, fmt.Sprintf("TLS secret %s/%s not found; create it and the listener will pick it up", - constants.GatewayNamespace, secretName) + namespace, secretName) } return metav1.ConditionFalse, appsv1.ReasonUserProvidedSecretCheckFailed, - fmt.Sprintf("failed to check TLS secret %s/%s: %v", constants.GatewayNamespace, secretName, err) + fmt.Sprintf("failed to check TLS secret %s/%s: %v", namespace, secretName, err) } if secret.Type != corev1.SecretTypeTLS { return metav1.ConditionFalse, appsv1.ReasonUserProvidedSecretInvalidType, fmt.Sprintf("TLS secret %s/%s is type %s, expected kubernetes.io/tls", - constants.GatewayNamespace, secretName, secret.Type) + namespace, secretName, secret.Type) } // `kubectl create secret tls` enforces non-empty tls.crt and tls.key, but @@ -589,10 +648,10 @@ func (r *TLSReconciler) checkUserProvidedSecret(ctx context.Context, secretName return metav1.ConditionFalse, appsv1.ReasonUserProvidedSecretInvalidType, fmt.Sprintf("TLS secret %s/%s has type kubernetes.io/tls but is missing tls.crt or tls.key data", - constants.GatewayNamespace, secretName) + namespace, secretName) } return metav1.ConditionTrue, appsv1.ReasonUserProvidedSecretReady, - fmt.Sprintf("using pre-provisioned TLS secret %s/%s", constants.GatewayNamespace, secretName) + fmt.Sprintf("using pre-provisioned TLS secret %s/%s", namespace, secretName) } diff --git a/internal/controller/reconcilers/tls/reconciler_test.go b/internal/controller/reconcilers/tls/reconciler_test.go index 3479890..65a05ab 100644 --- a/internal/controller/reconcilers/tls/reconciler_test.go +++ b/internal/controller/reconcilers/tls/reconciler_test.go @@ -1187,7 +1187,7 @@ func TestCheckUserProvidedSecret(t *testing.T) { Recorder: record.NewFakeRecorder(10), } - status, reason, msg := reconciler.checkUserProvidedSecret(context.Background(), tt.secretName) + status, reason, msg := reconciler.checkUserProvidedSecret(context.Background(), constants.GatewayNamespace, tt.secretName) if status != tt.expectStatus { t.Errorf("expected status %s, got %s", tt.expectStatus, status) } @@ -1457,7 +1457,7 @@ func TestIsCertificateReady(t *testing.T) { Scheme: scheme, } - ready, err := reconciler.isCertificateReady(context.Background(), tt.nebariApp) + ready, err := reconciler.isCertificateReady(context.Background(), tt.nebariApp, constants.GatewayNamespace) if tt.expectError && err == nil { t.Error("expected error, got nil") diff --git a/internal/controller/utils/constants/constants.go b/internal/controller/utils/constants/constants.go index 7c826f7..8fca461 100644 --- a/internal/controller/utils/constants/constants.go +++ b/internal/controller/utils/constants/constants.go @@ -39,6 +39,10 @@ const ( // Resource naming suffixes const ( + // ListenerSetSuffix is appended to NebariApp name for the per-app ListenerSet + // resource created in the NebariApp's own namespace (ADR-0011 Option 2). + ListenerSetSuffix = "listeners" + // HTTPRouteSuffix is appended to NebariApp name for HTTPRoute resources HTTPRouteSuffix = "route" diff --git a/internal/controller/utils/naming/naming.go b/internal/controller/utils/naming/naming.go index fc3e5d7..16cffe7 100644 --- a/internal/controller/utils/naming/naming.go +++ b/internal/controller/utils/naming/naming.go @@ -24,6 +24,7 @@ func ValidateResourceNames(nebariApp *appsv1.NebariApp) error { {"Certificate", CertificateName(nebariApp)}, {"CertificateSecret", CertificateSecretName(nebariApp)}, {"GatewayListener", ListenerName(nebariApp)}, + {"ListenerSet", ListenerSetName(nebariApp)}, {"OIDCClientSecret", ClientSecretName(nebariApp)}, } @@ -103,6 +104,14 @@ func ListenerName(nebariApp *appsv1.NebariApp) string { return fmt.Sprintf("tls-%s-%s", nebariApp.Name, nebariApp.Namespace) } +// ListenerSetName generates the name for the per-app ListenerSet created in the +// NebariApp's own namespace (ADR-0011 Option 2). The ListenerSet attaches to the +// shared Gateway via spec.parentRef and carries this app's HTTPS listener. +// Pattern: -listeners +func ListenerSetName(nebariApp *appsv1.NebariApp) string { + return ResourceName(nebariApp, constants.ListenerSetSuffix) +} + // GatewayName returns the Gateway name for a NebariApp based on its gateway spec. // Returns the internal gateway name when spec.gateway is "internal", // otherwise returns the public gateway name. From 796c45546525cdd46b0fa61d70a9eeeea10d745f Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Thu, 6 Aug 2026 14:21:08 -0300 Subject: [PATCH 3/9] test(tls): cover per-app ListenerSet primitive, status gate, and cutover Add unit tests for reconcileListenerSet (app-namespace object, parentRef to the shared Gateway, same-namespace cert ref, owner reference), isListenerSetProgrammed (Accepted+Programmed gating), the staged-migration attachment (stays on the legacy shared listener until Programmed, cuts over and removes it once Programmed), and route parentRef target selection. Document the ListenerSet ownership model and the routing.tls.secretName namespace change in docs/reconcilers/routing.md. --- docs/reconcilers/routing.md | 37 ++++ .../routing/listenerset_route_test.go | 67 ++++++ .../reconcilers/tls/listenerset_test.go | 207 ++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 internal/controller/reconcilers/routing/listenerset_route_test.go create mode 100644 internal/controller/reconcilers/tls/listenerset_test.go diff --git a/docs/reconcilers/routing.md b/docs/reconcilers/routing.md index 5e83b0a..32713ef 100644 --- a/docs/reconcilers/routing.md +++ b/docs/reconcilers/routing.md @@ -740,3 +740,40 @@ These match the resources deployed by the foundational infrastructure via ArgoCD - [Gateway API Documentation](https://gateway-api.sigs.k8s.io/) - [Envoy Gateway](https://gateway.envoyproxy.io/) - [cert-manager](https://cert-manager.io/) + +## TLS listener ownership: per-app ListenerSet (ADR-0011 Option 2) + +The operator no longer mutates the shared platform Gateway to attach each app's +HTTPS listener. Instead it owns a per-app **`ListenerSet`** +(`gateway.networking.k8s.io/v1`, Standard channel) in the **NebariApp's own +namespace**, attached to the shared Gateway via `spec.parentRef`. The app's TLS +`Certificate` and secret are co-located in that same namespace and +owner-referenced to the NebariApp, so they are garbage-collected with it (no +cross-namespace label bookkeeping, no `ReferenceGrant`). Generated `HTTPRoute`s +attach to the ListenerSet once it is serving. + +This removes the shared-Gateway co-ownership that previously left the platform +`gateway-config` GitOps app permanently OutOfSync. + +### Staged, status-gated migration + +The cutover is automatic and per-NebariApp, with no user-facing strategy flag: + +1. The ListenerSet is always reconciled. +2. Until it reports `Accepted=True` **and** `Programmed=True`, the operator keeps + the legacy per-app listener on the shared Gateway in place and routes attach + there. On an Envoy Gateway that does not reconcile ListenerSet (**pre-v1.8**) + the conditions never flip, so per-app TLS is unaffected. +3. Once Programmed, routes retarget to the ListenerSet and the legacy + shared-Gateway listener is removed. + +Runtime requirement for the ListenerSet path: **Envoy Gateway v1.8.2+** +(the version that reconciles the stable `ListenerSet`). + +### `routing.tls.secretName` (user-provided secrets) + +Under the ListenerSet path a user-provided TLS secret is resolved in the +**NebariApp's namespace** (co-located with the ListenerSet), not the Gateway +namespace. Place the secret alongside the NebariApp. During the transitional +window the legacy Gateway-namespace lookup still applies until the ListenerSet is +Programmed. diff --git a/internal/controller/reconcilers/routing/listenerset_route_test.go b/internal/controller/reconcilers/routing/listenerset_route_test.go new file mode 100644 index 0000000..9c27cbf --- /dev/null +++ b/internal/controller/reconcilers/routing/listenerset_route_test.go @@ -0,0 +1,67 @@ +/* +Copyright 2026, OpenTeams. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package routing + +import ( + "testing" + + appsv1 "github.com/nebari-dev/nebari-operator/api/v1" + "github.com/nebari-dev/nebari-operator/internal/controller/utils/constants" + "github.com/nebari-dev/nebari-operator/internal/controller/utils/naming" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// TestRouteParentRef_TargetSelection verifies that generated HTTPRoutes attach to +// the per-app ListenerSet (in the NebariApp namespace) once cut over, and to the +// shared Gateway (in the Gateway namespace) otherwise. +func TestRouteParentRef_TargetSelection(t *testing.T) { + app := &appsv1.NebariApp{ + ObjectMeta: metav1.ObjectMeta{Name: "myapp", Namespace: "team-a"}, + Spec: appsv1.NebariAppSpec{Hostname: "myapp.example.com"}, + } + section := gatewayv1.SectionName(naming.ListenerName(app)) + + t.Run("legacy shared Gateway", func(t *testing.T) { + ref := routeParentRef(app, naming.GatewayName(app), section, false) + if string(ref.Name) != naming.GatewayName(app) { + t.Errorf("name = %q, want %q", ref.Name, naming.GatewayName(app)) + } + if ref.Namespace == nil || string(*ref.Namespace) != constants.GatewayNamespace { + t.Errorf("namespace = %v, want %q", ref.Namespace, constants.GatewayNamespace) + } + if ref.Kind != nil && string(*ref.Kind) != "Gateway" { + t.Errorf("kind = %v, want Gateway/nil", ref.Kind) + } + }) + + t.Run("per-app ListenerSet after cutover", func(t *testing.T) { + ref := routeParentRef(app, naming.GatewayName(app), section, true) + if ref.Kind == nil || string(*ref.Kind) != "ListenerSet" { + t.Errorf("kind = %v, want ListenerSet", ref.Kind) + } + if string(ref.Name) != naming.ListenerSetName(app) { + t.Errorf("name = %q, want %q", ref.Name, naming.ListenerSetName(app)) + } + if ref.Namespace == nil || string(*ref.Namespace) != app.Namespace { + t.Errorf("namespace = %v, want %q (app namespace)", ref.Namespace, app.Namespace) + } + if ref.SectionName == nil || string(*ref.SectionName) != string(section) { + t.Errorf("sectionName = %v, want %q", ref.SectionName, section) + } + }) +} diff --git a/internal/controller/reconcilers/tls/listenerset_test.go b/internal/controller/reconcilers/tls/listenerset_test.go new file mode 100644 index 0000000..b9ef7a9 --- /dev/null +++ b/internal/controller/reconcilers/tls/listenerset_test.go @@ -0,0 +1,207 @@ +/* +Copyright 2026, OpenTeams. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package tls + +import ( + "context" + "testing" + + appsv1 "github.com/nebari-dev/nebari-operator/api/v1" + "github.com/nebari-dev/nebari-operator/internal/controller/utils/constants" + "github.com/nebari-dev/nebari-operator/internal/controller/utils/naming" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/record" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +func lsTestApp() *appsv1.NebariApp { + return &appsv1.NebariApp{ + ObjectMeta: metav1.ObjectMeta{Name: "myapp", Namespace: "team-a", UID: "uid-myapp"}, + Spec: appsv1.NebariAppSpec{Hostname: "myapp.example.com"}, + } +} + +// listenerSetWithConditions builds a ListenerSet carrying the given Accepted / +// Programmed condition statuses, used to seed the fake client's status. +func listenerSetWithConditions(app *appsv1.NebariApp, accepted, programmed metav1.ConditionStatus) *gatewayv1.ListenerSet { + return &gatewayv1.ListenerSet{ + ObjectMeta: metav1.ObjectMeta{Name: naming.ListenerSetName(app), Namespace: app.Namespace}, + Status: gatewayv1.ListenerSetStatus{ + Conditions: []metav1.Condition{ + {Type: listenerSetConditionAccepted, Status: accepted, Reason: "T", LastTransitionTime: metav1.Now()}, + {Type: listenerSetConditionProgrammed, Status: programmed, Reason: "T", LastTransitionTime: metav1.Now()}, + }, + }, + } +} + +func TestReconcileListenerSet(t *testing.T) { + scheme := newScheme() + app := lsTestApp() + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(app).Build() + r := &TLSReconciler{Client: c, Scheme: scheme, Recorder: record.NewFakeRecorder(10)} + + secret := naming.CertificateSecretName(app) + if err := r.reconcileListenerSet(context.Background(), app, secret); err != nil { + t.Fatalf("reconcileListenerSet: %v", err) + } + + ls := &gatewayv1.ListenerSet{} + if err := c.Get(context.Background(), types.NamespacedName{ + Name: naming.ListenerSetName(app), Namespace: app.Namespace, + }, ls); err != nil { + t.Fatalf("ListenerSet not created: %v", err) + } + + // Lives in the app namespace. + if ls.Namespace != "team-a" { + t.Errorf("ListenerSet namespace = %q, want team-a", ls.Namespace) + } + // parentRef points at the shared Gateway in the Gateway namespace. + if string(ls.Spec.ParentRef.Name) != naming.GatewayName(app) { + t.Errorf("parentRef.name = %q, want %q", ls.Spec.ParentRef.Name, naming.GatewayName(app)) + } + if ls.Spec.ParentRef.Namespace == nil || string(*ls.Spec.ParentRef.Namespace) != constants.GatewayNamespace { + t.Errorf("parentRef.namespace = %v, want %q", ls.Spec.ParentRef.Namespace, constants.GatewayNamespace) + } + // Single HTTPS Terminate listener with a same-namespace cert ref. + if len(ls.Spec.Listeners) != 1 { + t.Fatalf("listeners = %d, want 1", len(ls.Spec.Listeners)) + } + l := ls.Spec.Listeners[0] + if string(l.Name) != naming.ListenerName(app) { + t.Errorf("listener name = %q, want %q", l.Name, naming.ListenerName(app)) + } + if l.Port != 443 || l.Protocol != gatewayv1.HTTPSProtocolType { + t.Errorf("listener = %d/%s, want 443/HTTPS", l.Port, l.Protocol) + } + if l.TLS == nil || l.TLS.Mode == nil || *l.TLS.Mode != gatewayv1.TLSModeTerminate { + t.Errorf("listener TLS mode not Terminate: %+v", l.TLS) + } + if len(l.TLS.CertificateRefs) != 1 || string(l.TLS.CertificateRefs[0].Name) != secret { + t.Errorf("cert ref = %+v, want name %q", l.TLS.CertificateRefs, secret) + } + if l.TLS.CertificateRefs[0].Namespace != nil { + t.Errorf("cert ref must be same-namespace (no explicit namespace), got %v", *l.TLS.CertificateRefs[0].Namespace) + } + // Owner-referenced to the NebariApp so GC removes it with the app. + if len(ls.OwnerReferences) != 1 || ls.OwnerReferences[0].Name != app.Name { + t.Errorf("ownerReferences = %+v, want single ref to %q", ls.OwnerReferences, app.Name) + } +} + +func TestIsListenerSetProgrammed(t *testing.T) { + scheme := newScheme() + app := lsTestApp() + + tests := []struct { + name string + seed *gatewayv1.ListenerSet + want bool + }{ + {name: "missing ListenerSet", seed: nil, want: false}, + {name: "accepted only", seed: listenerSetWithConditions(app, metav1.ConditionTrue, metav1.ConditionFalse), want: false}, + {name: "programmed only", seed: listenerSetWithConditions(app, metav1.ConditionFalse, metav1.ConditionTrue), want: false}, + {name: "accepted and programmed", seed: listenerSetWithConditions(app, metav1.ConditionTrue, metav1.ConditionTrue), want: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := fake.NewClientBuilder().WithScheme(scheme) + if tt.seed != nil { + b = b.WithObjects(tt.seed) + } + r := &TLSReconciler{Client: b.Build(), Scheme: scheme, Recorder: record.NewFakeRecorder(10)} + got, err := r.isListenerSetProgrammed(context.Background(), app) + if err != nil { + t.Fatalf("isListenerSetProgrammed: %v", err) + } + if got != tt.want { + t.Errorf("programmed = %v, want %v", got, tt.want) + } + }) + } +} + +// TestReconcileTLSAttachment_StaysLegacyUntilProgrammed asserts the load-bearing +// safety property: while the ListenerSet is not Programmed, the attachment keeps +// the legacy shared-Gateway listener in place and reports useListenerSet=false. +func TestReconcileTLSAttachment_StaysLegacyUntilProgrammed(t *testing.T) { + scheme := newScheme() + app := lsTestApp() + gw := newGateway(naming.GatewayName(app)) // shared Gateway, no per-app listener yet + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(app, gw).Build() + r := &TLSReconciler{Client: c, Scheme: scheme, Recorder: record.NewFakeRecorder(10)} + + useLS, err := r.reconcileTLSAttachment(context.Background(), app, naming.CertificateSecretName(app)) + if err != nil { + t.Fatalf("reconcileTLSAttachment: %v", err) + } + if useLS { + t.Fatal("expected useListenerSet=false while ListenerSet not Programmed") + } + // Legacy shared-Gateway listener must have been added. + got := &gatewayv1.Gateway{} + if err := c.Get(context.Background(), types.NamespacedName{Name: gw.Name, Namespace: gw.Namespace}, got); err != nil { + t.Fatalf("get gateway: %v", err) + } + found := false + for _, l := range got.Spec.Listeners { + if string(l.Name) == naming.ListenerName(app) { + found = true + } + } + if !found { + t.Error("legacy shared-Gateway listener not present in Phase A") + } +} + +// TestReconcileTLSAttachment_CutsOverWhenProgrammed asserts that once the +// ListenerSet is Programmed the attachment reports useListenerSet=true and +// removes the legacy shared-Gateway listener. +func TestReconcileTLSAttachment_CutsOverWhenProgrammed(t *testing.T) { + scheme := newScheme() + app := lsTestApp() + + // Shared Gateway already carries this app's legacy listener. + legacy := gatewayv1.Listener{ + Name: gatewayv1.SectionName(naming.ListenerName(app)), Port: 443, Protocol: gatewayv1.HTTPSProtocolType, + } + gw := newGateway(naming.GatewayName(app), legacy) + programmed := listenerSetWithConditions(app, metav1.ConditionTrue, metav1.ConditionTrue) + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(app, gw, programmed).Build() + r := &TLSReconciler{Client: c, Scheme: scheme, Recorder: record.NewFakeRecorder(10)} + + useLS, err := r.reconcileTLSAttachment(context.Background(), app, naming.CertificateSecretName(app)) + if err != nil { + t.Fatalf("reconcileTLSAttachment: %v", err) + } + if !useLS { + t.Fatal("expected useListenerSet=true once ListenerSet Programmed") + } + // Legacy shared-Gateway listener must have been removed on cutover. + got := &gatewayv1.Gateway{} + if err := c.Get(context.Background(), types.NamespacedName{Name: gw.Name, Namespace: gw.Namespace}, got); err != nil { + t.Fatalf("get gateway: %v", err) + } + for _, l := range got.Spec.Listeners { + if string(l.Name) == naming.ListenerName(app) { + t.Error("legacy shared-Gateway listener should be removed after cutover") + } + } +} From b233c2f02b93619084d7a1eb2a10ec0dd297fdd1 Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Thu, 6 Aug 2026 15:49:19 -0300 Subject: [PATCH 4/9] chore: fix gofmt import ordering in nebariapp_controller.go --- internal/controller/nebariapp_controller.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/controller/nebariapp_controller.go b/internal/controller/nebariapp_controller.go index be2add4..4fcd1e6 100644 --- a/internal/controller/nebariapp_controller.go +++ b/internal/controller/nebariapp_controller.go @@ -31,11 +31,11 @@ import ( "k8s.io/apimachinery/pkg/runtime" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" - gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" appsv1 "github.com/nebari-dev/nebari-operator/api/v1" From aba6ab062b2b8abaea0ea56d4ff19d946f221989 Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Thu, 6 Aug 2026 15:55:17 -0300 Subject: [PATCH 5/9] chore: re-trigger CI From 5e8197f8d2674bf0bae94a8691d2c0feaf7f2836 Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Thu, 6 Aug 2026 21:12:57 -0300 Subject: [PATCH 6/9] chore: bump Dockerfile golang base to 1.26 to match go.mod floor (deps require go 1.26.4) --- Dockerfile | 2 +- README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 0e3831a..db7f1f4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build the manager binary -FROM golang:1.25 AS builder +FROM golang:1.26 AS builder ARG TARGETOS ARG TARGETARCH diff --git a/README.md b/README.md index ced20ff..44911b7 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ href="https://github.com/nebari-dev/nebari-operator/releases/latest">Latest
   Release Go 1.25+

@@ -191,7 +191,7 @@ See the [Configuration Reference](docs/configuration-reference.md) for all avail | Tool | Version | Notes | | --- | --- | --- | -| `go` | 1.25+ | Controller and tests | +| `go` | 1.26+ | Controller and tests | | `docker` or `podman` | 24+ | Image builds | | `kubectl` | 1.28+ | Cluster interaction | | `make` | any | Build automation | From db08ddc4f9afbc00e1bc3ea2a34e56ea5cacfd1e Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Fri, 7 Aug 2026 08:50:00 -0300 Subject: [PATCH 7/9] ci: lint on go 1.26 via golangci goinstall; exclude dep-bump deprecations and test goconst - build golangci-lint from source (install-mode goinstall) so it runs on the go 1.26 module (no released binary is built with go>=1.26 yet) - exclude SA1019 deprecations (controller-runtime 0.24 old events API, scheme.Builder) in cmd/main.go and api/groupversion_info.go; migration tracked separately - exclude goconst on _test.go fixture strings - align go.mod to go 1.26.5 --- .github/workflows/build-pr.yml | 5 +++++ .golangci.yml | 11 +++++++++++ go.mod | 2 +- 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build-pr.yml b/.github/workflows/build-pr.yml index 791ab97..a42daa0 100644 --- a/.github/workflows/build-pr.yml +++ b/.github/workflows/build-pr.yml @@ -56,6 +56,11 @@ jobs: uses: golangci/golangci-lint-action@v9 with: version: v2.5.0 + # Build golangci-lint from source with the workflow's Go toolchain. + # go.mod targets go 1.26.x and no released golangci-lint binary is yet + # built with go >= 1.26, so binary install-mode fails its built-with + # version guard. goinstall compiles it with the runner's Go. + install-mode: goinstall - name: Generate manifests run: make manifests diff --git a/.golangci.yml b/.golangci.yml index e5b21b0..ba9da06 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -36,6 +36,17 @@ linters: - dupl - lll path: internal/* + # Repeated fixture strings in tests are not worth extracting to constants. + - linters: + - goconst + path: _test\.go + # Deprecations surfaced by the controller-runtime 0.24 / apimachinery bump + # (old events API via GetEventRecorderFor, scheme.Builder). The migration + # off these is tracked separately and is out of scope for this change. + - linters: + - staticcheck + text: 'SA1019' + path: (cmd/operator/main\.go|api/v1/groupversion_info\.go) paths: - third_party$ - builtin$ diff --git a/go.mod b/go.mod index 83d13e2..bb5e67c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/nebari-dev/nebari-operator -go 1.26.4 +go 1.26.5 require ( github.com/Nerzal/gocloak/v13 v13.9.0 From 7518e7c5e015754eaf25a131a8292fb0ce04abd4 Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Fri, 7 Aug 2026 10:57:04 -0300 Subject: [PATCH 8/9] test(e2e): temporarily bump foundational Envoy Gateway to v1.8.2 The e2e/dev foundational stack installed Envoy Gateway v1.2.4, which does not ship the standard gateway.networking.k8s.io/v1 ListenerSet CRD, so the per-app ListenerSet reconcile fails on 'no matches for kind ListenerSet' and TLS never becomes Ready. Bump to v1.8.2 so e2e exercises the real ListenerSet path. Marked TEMPORARY: the operator is version-pinned alongside NIC, so its foundational Envoy Gateway version should track NIC's pin rather than being hardcoded here. Reconcile before approval. --- dev/scripts/services/install.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/dev/scripts/services/install.sh b/dev/scripts/services/install.sh index dad5d98..1e728b3 100755 --- a/dev/scripts/services/install.sh +++ b/dev/scripts/services/install.sh @@ -59,8 +59,13 @@ kubectl create namespace envoy-gateway-system --dry-run=client -o yaml | kubectl # Install Envoy Gateway with Helm log_info "Installing Envoy Gateway via Helm (this may take a few minutes)..." +# TEMPORARY (remove before approval): bumped from v1.2.4 so the e2e/dev cluster +# ships the standard gateway.networking.k8s.io/v1 ListenerSet CRD (Gateway API +# v1.5+), which the per-app ListenerSet reconcile requires. This pre-empts NIC's +# foundational Envoy Gateway pin; reconcile with NIC's EG upgrade +# (nebari-infrastructure-core#496) before this merges rather than hardcoding it here. helm upgrade --install eg oci://docker.io/envoyproxy/gateway-helm \ - --version v1.2.4 \ + --version v1.8.2 \ --namespace envoy-gateway-system \ --wait \ --timeout 5m 2>&1 | grep -v "unrecognized format" From 7ff6496f0f850d9640becf60dcfb1351e5a1a626 Mon Sep 17 00:00:00 2001 From: viniciusdc Date: Thu, 13 Aug 2026 18:01:55 -0300 Subject: [PATCH 9/9] feat(tls): reason-aware ListenerSet cutover and off-path teardown Rework the per-app ListenerSet cutover (ADR-0011 Option 2) after review and live testing on Envoy Gateway v1.8.2 showed the Programmed-gated staged hold deadlocks an in-place migration: a ListenerSet detaches the hostname's routes from the shared Gateway the moment it is allowed to attach, so holding the legacy listener both takes the app offline and blocks the ListenerSet on a HostnameConflict it can never clear. Replace isListenerSetProgrammed with a reason-aware shouldCutOver that keys off the status EG actually reports: cut over when the set-level Programmed condition is True, or when our own listener reports HostnameConflict with refs resolving (our legacy listener is the only blocker, so remove it first and let the ListenerSet program); otherwise stay on the legacy listener. NotAllowed keeps the legacy listener, since removing it on a Gateway that refuses the attachment would strand the app. A conflict with a peer app's ListenerSet on the same hostname is indistinguishable by condition and is left to the conflict-handling rework (#168). Route the user-provided-secret path back onto the legacy shared-Gateway listener (the secret lives in the Gateway namespace; the app-namespace secret copy is TODO #168), and delete any ListenerSet a prior cert-manager reconcile left behind. Tear down the ListenerSet and legacy listener when TLS is disabled, so a cut-over app that turns TLS off does not keep terminating HTTPS and detaching the route. Add unit coverage for the decision matrix and the off-path teardown, and a Serial e2e spec that drives the real cutover and both transitions against EG v1.8.2. --- .../controller/reconcilers/tls/listenerset.go | 131 ++++++--- .../reconcilers/tls/listenerset_test.go | 217 +++++++++++++-- .../controller/reconcilers/tls/reconciler.go | 49 +++- test/e2e/tls_listenerset_test.go | 262 ++++++++++++++++++ 4 files changed, 583 insertions(+), 76 deletions(-) create mode 100644 test/e2e/tls_listenerset_test.go diff --git a/internal/controller/reconcilers/tls/listenerset.go b/internal/controller/reconcilers/tls/listenerset.go index 159a172..49409cf 100644 --- a/internal/controller/reconcilers/tls/listenerset.go +++ b/internal/controller/reconcilers/tls/listenerset.go @@ -25,6 +25,7 @@ import ( "github.com/nebari-dev/nebari-operator/internal/controller/utils/naming" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -32,12 +33,6 @@ import ( gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" ) -// Gateway API standard-channel ListenerSet condition types (gateway.networking.k8s.io/v1). -const ( - listenerSetConditionAccepted = "Accepted" - listenerSetConditionProgrammed = "Programmed" -) - // reconcileListenerSet creates or updates the per-app ListenerSet (ADR-0011 // Option 2). The ListenerSet lives in the NebariApp's own namespace, attaches to // the shared Gateway via spec.parentRef, and carries a single HTTPS Terminate @@ -47,7 +42,8 @@ const ( // // This is always reconciled, even on an Envoy Gateway that does not yet support // ListenerSet: there it simply never reaches Programmed=True and the caller keeps -// serving via the legacy shared-Gateway listener (see reconcileTLS phase logic). +// serving via the legacy shared-Gateway listener (see reconcileTLSAttachment / +// shouldCutOver). func (r *TLSReconciler) reconcileListenerSet(ctx context.Context, nebariApp *appsv1.NebariApp, secretName string) error { logger := log.FromContext(ctx) @@ -122,16 +118,27 @@ func (r *TLSReconciler) reconcileListenerSet(ctx context.Context, nebariApp *app return nil } -// isListenerSetProgrammed reports whether this NebariApp's ListenerSet has been -// accepted and programmed by the gateway controller. The staged migration only -// cuts routes over to the ListenerSet (and tears down the legacy shared-Gateway -// listener) once both conditions are True. On an Envoy Gateway that does not -// reconcile ListenerSet (e.g. pre-v1.8), the conditions never flip and this -// returns false, so the operator keeps serving via the legacy path. +// shouldCutOver decides whether this NebariApp's HTTPS traffic should be served +// by its per-app ListenerSet (ADR-0011 Option 2) rather than the legacy +// shared-Gateway listener. It is reason-aware, keying off the ListenerSet status +// the way Envoy Gateway actually reports it (validated on EG v1.8.2): +// +// - Programmed=True (set-level): the ListenerSet is live. Cut over. +// - The app's own listener reports Conflicted=True/HostnameConflict while its +// refs still resolve: the ListenerSet is blocked only by our own legacy +// listener holding the same (port, hostname). Cutting over removes that legacy +// listener so the ListenerSet can leave the conflict and program. A ListenerSet +// that merely claims a hostname already detaches that hostname's routes from +// the shared Gateway, so holding the legacy listener does not keep serving in +// the meantime, it only deadlocks the cutover. +// - Anything else (no status yet, Accepted=False/NotAllowed, unresolved refs): +// do NOT cut over, keep the legacy listener serving. NotAllowed in particular +// means the Gateway refuses the attachment (e.g. spec.allowedListeners unset), +// so the ListenerSet can never serve and removing the legacy listener would +// strand the app. // -// A missing ListenerSet returns (false, nil): not yet created, treat as not -// programmed rather than an error. -func (r *TLSReconciler) isListenerSetProgrammed(ctx context.Context, nebariApp *appsv1.NebariApp) (bool, error) { +// A missing ListenerSet returns (false, nil): treat as not-yet-created. +func (r *TLSReconciler) shouldCutOver(ctx context.Context, nebariApp *appsv1.NebariApp) (bool, error) { ls := &gatewayv1.ListenerSet{} if err := r.Client.Get(ctx, types.NamespacedName{ Name: naming.ListenerSetName(nebariApp), @@ -140,34 +147,53 @@ func (r *TLSReconciler) isListenerSetProgrammed(ctx context.Context, nebariApp * if apierrors.IsNotFound(err) { return false, nil } - return false, fmt.Errorf("failed to get ListenerSet for status check: %w", err) + return false, fmt.Errorf("failed to get ListenerSet for cutover decision: %w", err) + } + + // Fully programmed: the ListenerSet is serving, cut over unconditionally. + if meta.IsStatusConditionTrue(ls.Status.Conditions, string(gatewayv1.ListenerSetConditionProgrammed)) { + return true, nil } - accepted, programmed := false, false - for _, c := range ls.Status.Conditions { - switch c.Type { - case listenerSetConditionAccepted: - accepted = c.Status == metav1.ConditionTrue - case listenerSetConditionProgrammed: - programmed = c.Status == metav1.ConditionTrue + // Not programmed yet: cut over only on our own hostname conflict with refs + // resolving (see docstring) — removing our legacy listener frees the tuple so + // the ListenerSet can program. Every other state (NotAllowed, unresolved refs) + // stays on legacy. + // + // NOTE: a HostnameConflict is indistinguishable by condition type/status/reason + // from a conflict with a *peer* app's ListenerSet on the same hostname (only the + // condition message names the culprit, validated on EG v1.8.2). Cutting over in + // that peer case would strand this app, and unlike the legacy path it no longer + // surfaces a conflict condition. Restoring a surfaced signal and discriminating + // the peer case is left to the conflict-handling rework (#168), not parsed here. + listenerName := gatewayv1.SectionName(naming.ListenerName(nebariApp)) + for _, l := range ls.Status.Listeners { + if l.Name != listenerName { + continue } + conflict := meta.FindStatusCondition(l.Conditions, string(gatewayv1.ListenerConditionConflicted)) + hostnameConflict := conflict != nil && conflict.Status == metav1.ConditionTrue && + conflict.Reason == string(gatewayv1.ListenerReasonHostnameConflict) + refsResolved := !meta.IsStatusConditionFalse(l.Conditions, string(gatewayv1.ListenerConditionResolvedRefs)) + return hostnameConflict && refsResolved, nil } - return accepted && programmed, nil + + return false, nil } // reconcileTLSAttachment reconciles the per-app ListenerSet and decides which -// listener actually serves this app's HTTPS traffic, returning whether the app -// has cut over to the ListenerSet. +// listener serves this app's HTTPS traffic, returning whether it has cut over to +// the ListenerSet (ADR-0011 Option 2). // -// Staged, status-gated migration (ADR-0011 Option 2): -// - The ListenerSet is always (re)created. -// - Until it reports Accepted+Programmed, the legacy per-app listener on the -// shared Gateway is kept in place, so TLS keeps working on an Envoy Gateway -// that does not yet reconcile ListenerSet (pre-v1.8) or has not programmed it -// yet. -// - Once Programmed, the shared-Gateway listener is removed and traffic is -// served by the ListenerSet. Cutover is per-NebariApp and driven by status, -// with no user-facing strategy flag. +// The ListenerSet is always (re)created. Then, reason-aware (see shouldCutOver): +// - If the app should cut over, the legacy shared-Gateway listener is removed so +// the ListenerSet owns the (port, hostname) tuple, and routes are pointed at +// the ListenerSet. removeGatewayListener is idempotent. +// - Otherwise the legacy shared-Gateway listener is kept in place and serves, +// and routes stay on it. This covers the brief pre-status window right after +// creation and the genuinely-unsupported cluster (Gateway refusing the +// attachment). Cutover is per-NebariApp and driven by status, with no +// user-facing strategy flag. // // secretName is the TLS secret name; it is resolved in the app namespace for the // ListenerSet and in the Gateway namespace for the legacy shared listener. @@ -178,14 +204,13 @@ func (r *TLSReconciler) reconcileTLSAttachment(ctx context.Context, nebariApp *a return false, err } - programmed, err := r.isListenerSetProgrammed(ctx, nebariApp) + cutover, err := r.shouldCutOver(ctx, nebariApp) if err != nil { return false, err } - if programmed { - // Cut over: the ListenerSet is serving, so retire the legacy shared-Gateway - // listener. removeGatewayListener is idempotent (no-op once removed). + if cutover { + // removeGatewayListener is idempotent (no-op once removed / never created). if err := r.removeGatewayListener(ctx, nebariApp); err != nil { return false, err } @@ -194,9 +219,33 @@ func (r *TLSReconciler) reconcileTLSAttachment(ctx context.Context, nebariApp *a return true, nil } - // Not yet programmed: keep the legacy shared-Gateway listener serving. + // Keep the legacy shared-Gateway listener serving until the ListenerSet is + // usable. if err := r.reconcileGatewayListener(ctx, nebariApp, secretName); err != nil { return false, err } return false, nil } + +// removeListenerSet deletes this NebariApp's per-app ListenerSet if present, so an +// app that moves off the ListenerSet path (e.g. switching to a user-provided TLS +// secret) does not leave a ListenerSet still claiming the hostname and detaching +// the route from the shared Gateway. Idempotent: a missing ListenerSet is a no-op. +func (r *TLSReconciler) removeListenerSet(ctx context.Context, nebariApp *appsv1.NebariApp) error { + ls := &gatewayv1.ListenerSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: naming.ListenerSetName(nebariApp), + Namespace: nebariApp.Namespace, + }, + } + err := r.Client.Delete(ctx, ls) + if apierrors.IsNotFound(err) { + return nil + } + if err != nil { + return fmt.Errorf("failed to delete ListenerSet: %w", err) + } + log.FromContext(ctx).V(1).Info("Removed per-app ListenerSet", + "listenerSet", ls.Name, "namespace", nebariApp.Namespace) + return nil +} diff --git a/internal/controller/reconcilers/tls/listenerset_test.go b/internal/controller/reconcilers/tls/listenerset_test.go index b9ef7a9..66e6178 100644 --- a/internal/controller/reconcilers/tls/listenerset_test.go +++ b/internal/controller/reconcilers/tls/listenerset_test.go @@ -23,6 +23,8 @@ import ( appsv1 "github.com/nebari-dev/nebari-operator/api/v1" "github.com/nebari-dev/nebari-operator/internal/controller/utils/constants" "github.com/nebari-dev/nebari-operator/internal/controller/utils/naming" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/tools/record" @@ -37,15 +39,62 @@ func lsTestApp() *appsv1.NebariApp { } } -// listenerSetWithConditions builds a ListenerSet carrying the given Accepted / -// Programmed condition statuses, used to seed the fake client's status. -func listenerSetWithConditions(app *appsv1.NebariApp, accepted, programmed metav1.ConditionStatus) *gatewayv1.ListenerSet { +// lsProgrammed builds a ListenerSet whose set-level Programmed condition is True. +func lsProgrammed(app *appsv1.NebariApp) *gatewayv1.ListenerSet { return &gatewayv1.ListenerSet{ ObjectMeta: metav1.ObjectMeta{Name: naming.ListenerSetName(app), Namespace: app.Namespace}, Status: gatewayv1.ListenerSetStatus{ Conditions: []metav1.Condition{ - {Type: listenerSetConditionAccepted, Status: accepted, Reason: "T", LastTransitionTime: metav1.Now()}, - {Type: listenerSetConditionProgrammed, Status: programmed, Reason: "T", LastTransitionTime: metav1.Now()}, + {Type: string(gatewayv1.ListenerSetConditionProgrammed), Status: metav1.ConditionTrue, + Reason: string(gatewayv1.ListenerSetReasonProgrammed), LastTransitionTime: metav1.Now()}, + }, + }, + } +} + +// lsNotAllowed builds a ListenerSet the Gateway refuses to attach (e.g. +// spec.allowedListeners unset): set-level Accepted/Programmed False with reason +// NotAllowed, and no per-listener status at all (Envoy Gateway does not evaluate +// the listeners in this state). +func lsNotAllowed(app *appsv1.NebariApp) *gatewayv1.ListenerSet { + return &gatewayv1.ListenerSet{ + ObjectMeta: metav1.ObjectMeta{Name: naming.ListenerSetName(app), Namespace: app.Namespace}, + Status: gatewayv1.ListenerSetStatus{ + Conditions: []metav1.Condition{ + {Type: string(gatewayv1.ListenerSetConditionAccepted), Status: metav1.ConditionFalse, + Reason: string(gatewayv1.ListenerSetReasonNotAllowed), LastTransitionTime: metav1.Now()}, + {Type: string(gatewayv1.ListenerSetConditionProgrammed), Status: metav1.ConditionFalse, + Reason: string(gatewayv1.ListenerSetReasonNotAllowed), LastTransitionTime: metav1.Now()}, + }, + }, + } +} + +// lsHostnameConflict builds a ListenerSet blocked only by a hostname conflict with +// our own legacy listener: set-level ListenersNotValid, and the app's per-listener +// entry Conflicted=True/HostnameConflict with ResolvedRefs set per refsResolved. +func lsHostnameConflict(app *appsv1.NebariApp, refsResolved bool) *gatewayv1.ListenerSet { + refs := metav1.ConditionTrue + if !refsResolved { + refs = metav1.ConditionFalse + } + return &gatewayv1.ListenerSet{ + ObjectMeta: metav1.ObjectMeta{Name: naming.ListenerSetName(app), Namespace: app.Namespace}, + Status: gatewayv1.ListenerSetStatus{ + Conditions: []metav1.Condition{ + {Type: string(gatewayv1.ListenerSetConditionAccepted), Status: metav1.ConditionFalse, + Reason: string(gatewayv1.ListenerSetReasonListenersNotValid), LastTransitionTime: metav1.Now()}, + }, + Listeners: []gatewayv1.ListenerEntryStatus{ + { + Name: gatewayv1.SectionName(naming.ListenerName(app)), + Conditions: []metav1.Condition{ + {Type: string(gatewayv1.ListenerConditionConflicted), Status: metav1.ConditionTrue, + Reason: string(gatewayv1.ListenerReasonHostnameConflict), LastTransitionTime: metav1.Now()}, + {Type: string(gatewayv1.ListenerConditionResolvedRefs), Status: refs, + Reason: "R", LastTransitionTime: metav1.Now()}, + }, + }, }, }, } @@ -106,19 +155,30 @@ func TestReconcileListenerSet(t *testing.T) { } } -func TestIsListenerSetProgrammed(t *testing.T) { +// TestShouldCutOver covers the reason-aware cutover decision: cut over once the +// ListenerSet is Programmed, or when the only thing blocking it is a hostname +// conflict with our own legacy listener (with refs resolving); stay on the legacy +// listener otherwise, in particular when the Gateway refuses the attachment +// (NotAllowed) since removing the legacy listener there would strand the app. +func TestShouldCutOver(t *testing.T) { scheme := newScheme() app := lsTestApp() + noStatus := &gatewayv1.ListenerSet{ + ObjectMeta: metav1.ObjectMeta{Name: naming.ListenerSetName(app), Namespace: app.Namespace}, + } + tests := []struct { name string seed *gatewayv1.ListenerSet want bool }{ {name: "missing ListenerSet", seed: nil, want: false}, - {name: "accepted only", seed: listenerSetWithConditions(app, metav1.ConditionTrue, metav1.ConditionFalse), want: false}, - {name: "programmed only", seed: listenerSetWithConditions(app, metav1.ConditionFalse, metav1.ConditionTrue), want: false}, - {name: "accepted and programmed", seed: listenerSetWithConditions(app, metav1.ConditionTrue, metav1.ConditionTrue), want: true}, + {name: "no status yet", seed: noStatus, want: false}, + {name: "programmed", seed: lsProgrammed(app), want: true}, + {name: "not allowed (allowedListeners unset)", seed: lsNotAllowed(app), want: false}, + {name: "hostname conflict with our legacy listener, refs resolved", seed: lsHostnameConflict(app, true), want: true}, + {name: "hostname conflict but refs unresolved", seed: lsHostnameConflict(app, false), want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -127,21 +187,21 @@ func TestIsListenerSetProgrammed(t *testing.T) { b = b.WithObjects(tt.seed) } r := &TLSReconciler{Client: b.Build(), Scheme: scheme, Recorder: record.NewFakeRecorder(10)} - got, err := r.isListenerSetProgrammed(context.Background(), app) + got, err := r.shouldCutOver(context.Background(), app) if err != nil { - t.Fatalf("isListenerSetProgrammed: %v", err) + t.Fatalf("shouldCutOver: %v", err) } if got != tt.want { - t.Errorf("programmed = %v, want %v", got, tt.want) + t.Errorf("cutover = %v, want %v", got, tt.want) } }) } } -// TestReconcileTLSAttachment_StaysLegacyUntilProgrammed asserts the load-bearing -// safety property: while the ListenerSet is not Programmed, the attachment keeps -// the legacy shared-Gateway listener in place and reports useListenerSet=false. -func TestReconcileTLSAttachment_StaysLegacyUntilProgrammed(t *testing.T) { +// TestReconcileTLSAttachment_StaysLegacyBeforeStatus asserts that with a +// freshly-created ListenerSet carrying no status yet, the attachment keeps the +// legacy shared-Gateway listener in place and reports useListenerSet=false. +func TestReconcileTLSAttachment_StaysLegacyBeforeStatus(t *testing.T) { scheme := newScheme() app := lsTestApp() gw := newGateway(naming.GatewayName(app)) // shared Gateway, no per-app listener yet @@ -153,7 +213,7 @@ func TestReconcileTLSAttachment_StaysLegacyUntilProgrammed(t *testing.T) { t.Fatalf("reconcileTLSAttachment: %v", err) } if useLS { - t.Fatal("expected useListenerSet=false while ListenerSet not Programmed") + t.Fatal("expected useListenerSet=false before the ListenerSet reports usable status") } // Legacy shared-Gateway listener must have been added. got := &gatewayv1.Gateway{} @@ -167,7 +227,42 @@ func TestReconcileTLSAttachment_StaysLegacyUntilProgrammed(t *testing.T) { } } if !found { - t.Error("legacy shared-Gateway listener not present in Phase A") + t.Error("legacy shared-Gateway listener not present before cutover") + } +} + +// TestReconcileTLSAttachment_StaysLegacyWhenNotAllowed asserts that when the +// Gateway refuses the attachment (allowedListeners unset), the attachment keeps +// the legacy listener and does NOT cut over, so the app is not stranded. +func TestReconcileTLSAttachment_StaysLegacyWhenNotAllowed(t *testing.T) { + scheme := newScheme() + app := lsTestApp() + legacy := gatewayv1.Listener{ + Name: gatewayv1.SectionName(naming.ListenerName(app)), Port: 443, Protocol: gatewayv1.HTTPSProtocolType, + } + gw := newGateway(naming.GatewayName(app), legacy) + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(app, gw, lsNotAllowed(app)).Build() + r := &TLSReconciler{Client: c, Scheme: scheme, Recorder: record.NewFakeRecorder(10)} + + useLS, err := r.reconcileTLSAttachment(context.Background(), app, naming.CertificateSecretName(app)) + if err != nil { + t.Fatalf("reconcileTLSAttachment: %v", err) + } + if useLS { + t.Fatal("expected useListenerSet=false when the Gateway refuses the attachment (NotAllowed)") + } + got := &gatewayv1.Gateway{} + if err := c.Get(context.Background(), types.NamespacedName{Name: gw.Name, Namespace: gw.Namespace}, got); err != nil { + t.Fatalf("get gateway: %v", err) + } + found := false + for _, l := range got.Spec.Listeners { + if string(l.Name) == naming.ListenerName(app) { + found = true + } + } + if !found { + t.Error("legacy shared-Gateway listener must be kept when NotAllowed") } } @@ -175,16 +270,29 @@ func TestReconcileTLSAttachment_StaysLegacyUntilProgrammed(t *testing.T) { // ListenerSet is Programmed the attachment reports useListenerSet=true and // removes the legacy shared-Gateway listener. func TestReconcileTLSAttachment_CutsOverWhenProgrammed(t *testing.T) { + assertCutover(t, lsProgrammed(lsTestApp())) +} + +// TestReconcileTLSAttachment_CutsOverOnHostnameConflict asserts the migration +// case: when the ListenerSet is blocked only by a hostname conflict with our own +// legacy listener, the attachment removes that legacy listener (freeing the tuple +// so the ListenerSet can program) and reports useListenerSet=true. +func TestReconcileTLSAttachment_CutsOverOnHostnameConflict(t *testing.T) { + assertCutover(t, lsHostnameConflict(lsTestApp(), true)) +} + +// assertCutover runs reconcileTLSAttachment against a shared Gateway that already +// carries the app's legacy listener plus the given seeded ListenerSet, and asserts +// the app cuts over: useListenerSet=true and the legacy listener removed. +func assertCutover(t *testing.T, seededLS *gatewayv1.ListenerSet) { + t.Helper() scheme := newScheme() app := lsTestApp() - - // Shared Gateway already carries this app's legacy listener. legacy := gatewayv1.Listener{ Name: gatewayv1.SectionName(naming.ListenerName(app)), Port: 443, Protocol: gatewayv1.HTTPSProtocolType, } gw := newGateway(naming.GatewayName(app), legacy) - programmed := listenerSetWithConditions(app, metav1.ConditionTrue, metav1.ConditionTrue) - c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(app, gw, programmed).Build() + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(app, gw, seededLS).Build() r := &TLSReconciler{Client: c, Scheme: scheme, Recorder: record.NewFakeRecorder(10)} useLS, err := r.reconcileTLSAttachment(context.Background(), app, naming.CertificateSecretName(app)) @@ -192,9 +300,8 @@ func TestReconcileTLSAttachment_CutsOverWhenProgrammed(t *testing.T) { t.Fatalf("reconcileTLSAttachment: %v", err) } if !useLS { - t.Fatal("expected useListenerSet=true once ListenerSet Programmed") + t.Fatal("expected useListenerSet=true on cutover") } - // Legacy shared-Gateway listener must have been removed on cutover. got := &gatewayv1.Gateway{} if err := c.Get(context.Background(), types.NamespacedName{Name: gw.Name, Namespace: gw.Namespace}, got); err != nil { t.Fatalf("get gateway: %v", err) @@ -205,3 +312,65 @@ func TestReconcileTLSAttachment_CutsOverWhenProgrammed(t *testing.T) { } } } + +// TestReconcileUserProvidedTLS_RemovesStaleListenerSet asserts that switching an +// app to a user-provided TLS secret removes any ListenerSet a prior cert-manager +// reconcile cut over to (so it stops claiming the hostname and detaching the +// route), and reports UseListenerSet=false. +func TestReconcileUserProvidedTLS_RemovesStaleListenerSet(t *testing.T) { + scheme := newScheme() + app := lsTestApp() + app.Spec.Routing = &appsv1.RoutingConfig{ + TLS: &appsv1.RoutingTLSConfig{SecretName: "user-tls"}, + } + // A ListenerSet a prior cert-manager reconcile created and cut over to. + staleLS := lsProgrammed(app) + // The user-provided secret lives in the Gateway namespace. + userSecret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "user-tls", Namespace: constants.GatewayNamespace}, + Type: corev1.SecretTypeTLS, + Data: map[string][]byte{corev1.TLSCertKey: []byte("crt"), corev1.TLSPrivateKeyKey: []byte("key")}, + } + gw := newGateway(naming.GatewayName(app)) + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(app, gw, staleLS, userSecret).Build() + r := &TLSReconciler{Client: c, Scheme: scheme, Recorder: record.NewFakeRecorder(10)} + + res, err := r.ReconcileTLS(context.Background(), app) + if err != nil { + t.Fatalf("ReconcileTLS: %v", err) + } + if res == nil || res.UseListenerSet { + t.Fatalf("expected non-nil result with UseListenerSet=false, got %+v", res) + } + // The stale ListenerSet must be deleted so it no longer claims the hostname. + err = c.Get(context.Background(), types.NamespacedName{ + Name: naming.ListenerSetName(app), Namespace: app.Namespace, + }, &gatewayv1.ListenerSet{}) + if !apierrors.IsNotFound(err) { + t.Errorf("expected stale ListenerSet to be deleted, got err=%v", err) + } +} + +// TestReconcileTLS_DisabledRemovesStaleListenerSet asserts that disabling TLS on +// an app that had cut over tears down the ListenerSet, so it stops terminating +// HTTPS and detaching the route from the shared Gateway. +func TestReconcileTLS_DisabledRemovesStaleListenerSet(t *testing.T) { + scheme := newScheme() + app := lsTestApp() + disabled := false + app.Spec.Routing = &appsv1.RoutingConfig{TLS: &appsv1.RoutingTLSConfig{Enabled: &disabled}} + staleLS := lsProgrammed(app) // a ListenerSet from when TLS was enabled and cut over + gw := newGateway(naming.GatewayName(app)) + c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(app, gw, staleLS).Build() + r := &TLSReconciler{Client: c, Scheme: scheme, Recorder: record.NewFakeRecorder(10)} + + if _, err := r.ReconcileTLS(context.Background(), app); err != nil { + t.Fatalf("ReconcileTLS: %v", err) + } + err := c.Get(context.Background(), types.NamespacedName{ + Name: naming.ListenerSetName(app), Namespace: app.Namespace, + }, &gatewayv1.ListenerSet{}) + if !apierrors.IsNotFound(err) { + t.Errorf("expected ListenerSet removed when TLS disabled, got err=%v", err) + } +} diff --git a/internal/controller/reconcilers/tls/reconciler.go b/internal/controller/reconcilers/tls/reconciler.go index c4f1a7f..4114fe4 100644 --- a/internal/controller/reconcilers/tls/reconciler.go +++ b/internal/controller/reconcilers/tls/reconciler.go @@ -99,6 +99,17 @@ func (r *TLSReconciler) ReconcileTLS(ctx context.Context, nebariApp *appsv1.Neba if !isTLSEnabled(nebariApp) { logger.Info("TLS not enabled, skipping TLS reconciliation") + // Tear down any per-app TLS listener left from when TLS was enabled. A + // cut-over ListenerSet in particular keeps terminating HTTPS and detaching + // the route from the shared Gateway, so it must go once TLS is off; the + // legacy listener removal is idempotent. Both are no-ops for an app that + // never had per-app TLS. + if err := r.removeListenerSet(ctx, nebariApp); err != nil { + return nil, err + } + if err := r.removeGatewayListener(ctx, nebariApp); err != nil { + return nil, err + } conditions.SetCondition(nebariApp, appsv1.ConditionTypeTLSReady, metav1.ConditionFalse, "TLSDisabled", "TLS is not enabled for this app") return nil, nil @@ -215,8 +226,25 @@ func (r *TLSReconciler) reconcileUserProvidedTLS(ctx context.Context, nebariApp return nil, err } - useListenerSet, err := r.reconcileTLSAttachment(ctx, nebariApp, secretName) - if err != nil { + // User-provided secrets stay on the legacy shared-Gateway listener: the secret + // lives in the Gateway namespace, and copying it into the app namespace so a + // per-app ListenerSet could reference it is TODO(#168). Until that lands, this + // path deliberately does not create a ListenerSet (an unprogrammable ListenerSet + // would still claim the hostname and detach the route), it keeps serving from + // the shared Gateway. + // + // Drop any ListenerSet a prior cert-manager reconcile created and cut over to, + // before re-adding the legacy listener: a lingering ListenerSet would keep + // claiming the hostname and leave the re-added shared-Gateway listener serving + // nothing. + if err := r.removeListenerSet(ctx, nebariApp); err != nil { + conditions.SetCondition(nebariApp, appsv1.ConditionTypeTLSReady, metav1.ConditionFalse, + "ListenerSetCleanupFailed", + fmt.Sprintf("Failed to remove ListenerSet during switch to user-provided secret: %v", err)) + return nil, err + } + + if err := r.reconcileGatewayListener(ctx, nebariApp, secretName); err != nil { if containsListenerConflict(err) { conditions.SetCondition(nebariApp, appsv1.ConditionTypeTLSReady, metav1.ConditionFalse, appsv1.ReasonGatewayListenerConflict, @@ -230,14 +258,9 @@ func (r *TLSReconciler) reconcileUserProvidedTLS(ctx context.Context, nebariApp return nil, err } - // The user-provided secret is looked up in whichever namespace is serving: - // the app namespace once cut over to the ListenerSet, else the Gateway - // namespace for the legacy shared listener. TODO(#168): finish the - // user-secret migration so a single app-namespace secret drives both phases. + // The user-provided secret lives in the Gateway namespace, the legacy shared + // listener's home. secretNS := constants.GatewayNamespace - if useListenerSet { - secretNS = nebariApp.Namespace - } // Capture the previous TLSReady reason before SetCondition mutates it, so we // only emit an event when the reason actually transitions. ReconcileTLS runs @@ -269,7 +292,7 @@ func (r *TLSReconciler) reconcileUserProvidedTLS(ctx context.Context, nebariApp ListenerName: naming.ListenerName(nebariApp), SecretName: secretName, CertReady: status == metav1.ConditionTrue, - UseListenerSet: useListenerSet, + UseListenerSet: false, }, nil } @@ -279,7 +302,11 @@ func (r *TLSReconciler) reconcileUserProvidedTLS(ctx context.Context, nebariApp // minimize orphaned resources. Certificate deletion goes through // cleanupOwnedCertificate, which only removes Certificates whose ownership // labels match this NebariApp, so an unowned Certificate that happens to share -// the derived name is left alone. +// the derived name is left alone. The per-app ListenerSet is not deleted here: +// it is owner-referenced to the NebariApp and garbage-collected when the app is +// deleted (this cleanup runs on app teardown). It is torn down explicitly only +// when the app stays but leaves the ListenerSet path (TLS disabled or a +// user-provided secret), via removeListenerSet. func (r *TLSReconciler) CleanupTLS(ctx context.Context, nebariApp *appsv1.NebariApp) error { logger := log.FromContext(ctx) var errs []error diff --git a/test/e2e/tls_listenerset_test.go b/test/e2e/tls_listenerset_test.go new file mode 100644 index 0000000..13de648 --- /dev/null +++ b/test/e2e/tls_listenerset_test.go @@ -0,0 +1,262 @@ +//go:build e2e +// +build e2e + +/* +Copyright 2026, OpenTeams. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package e2e + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/nebari-dev/nebari-operator/test/utils" +) + +// This suite exercises the per-app ListenerSet cutover (ADR-0011 Option 2) end to +// end against a real Envoy Gateway. It needs the standard ListenerSet CRD (Envoy +// Gateway v1.8.2+) and drives the cert-manager TLS path, so it is Serial and +// self-contained: it turns on the operator's ClusterIssuer and the Gateway's +// spec.allowedListeners in BeforeAll and reverts both in AfterAll, so the rest of +// the suite keeps seeing the default (no per-app cert-manager TLS) behaviour. +var _ = Describe("NebariApp per-app ListenerSet cutover (cert-manager TLS)", Ordered, Serial, func() { + const ( + testNamespace = "e2e-test-listenerset" + appName = "cutover-app" + userSecretName = "cutover-byo-tls" + gatewayNS = "envoy-gateway-system" + operatorNS = "nebari-operator-system" + operatorDeploy = "nebari-operator-controller-manager" + clusterIssuer = "selfsigned-issuer" + ) + var ( + hostname string + listenerName string // legacy shared-Gateway listener name: tls-- + lsSelector string // label selector for the per-app ListenerSet + certDir string + ) + + // listenerSetProgrammed returns the set-level Programmed condition status of the + // app's ListenerSet, or "" if the ListenerSet does not exist. + listenerSetProgrammed := func() string { + out, _ := utils.Run(exec.Command("kubectl", "get", "listenerset", + "-n", testNamespace, "-l", lsSelector, + "-o", `jsonpath={.items[0].status.conditions[?(@.type=="Programmed")].status}`)) + return strings.TrimSpace(out) + } + + // listenerSetNames returns the app's ListenerSet resource names ("" when none). + listenerSetNames := func() string { + out, _ := utils.Run(exec.Command("kubectl", "get", "listenerset", + "-n", testNamespace, "-l", lsSelector, "-o", "name")) + return strings.TrimSpace(out) + } + + // gatewayHasLegacyListener reports whether the shared Gateway still carries this + // app's legacy per-app HTTPS listener. + gatewayHasLegacyListener := func() bool { + out, _ := utils.Run(exec.Command("kubectl", "get", "gateway", "nebari-gateway", + "-n", gatewayNS, + "-o", fmt.Sprintf(`jsonpath={.spec.listeners[?(@.name=="%s")].name}`, listenerName))) + return strings.TrimSpace(out) != "" + } + + BeforeAll(func() { + hostname = fmt.Sprintf("cutover-%d.nebari.local", time.Now().Unix()) + listenerName = fmt.Sprintf("tls-%s-%s", appName, testNamespace) + lsSelector = fmt.Sprintf("nebari.dev/nebariapp-name=%s", appName) + + By("checking the standard ListenerSet CRD is present (Envoy Gateway v1.8.2+)") + if _, err := utils.Run(exec.Command("kubectl", "get", "crd", + "listenersets.gateway.networking.k8s.io")); err != nil { + Skip("standard ListenerSet CRD not present - needs Envoy Gateway v1.8.2+") + } + + By("checking the shared Gateway exists") + if _, err := utils.Run(exec.Command("kubectl", "get", "gateway", "nebari-gateway", + "-n", gatewayNS)); err != nil { + Skip("Gateway 'nebari-gateway' not found - run 'make setup' in dev/ first") + } + + By("checking the ClusterIssuer exists") + if _, err := utils.Run(exec.Command("kubectl", "get", "clusterissuer", clusterIssuer)); err != nil { + Skip(fmt.Sprintf("ClusterIssuer %q not found", clusterIssuer)) + } + + SetupTestNamespace(testNamespace) + DeployTestApp(testNamespace) + + By("generating a self-signed TLS cert for the user-secret transition") + var err error + certDir, err = os.MkdirTemp("", "cutover-byo-") + Expect(err).NotTo(HaveOccurred()) + certPath := filepath.Join(certDir, "tls.crt") + keyPath := filepath.Join(certDir, "tls.key") + _, err = utils.Run(exec.Command("openssl", "req", "-x509", "-nodes", + "-newkey", "rsa:2048", "-days", "1", + "-subj", fmt.Sprintf("/CN=%s", hostname), + "-addext", fmt.Sprintf("subjectAltName=DNS:%s", hostname), + "-keyout", keyPath, "-out", certPath)) + Expect(err).NotTo(HaveOccurred()) + _, _ = utils.Run(exec.Command("kubectl", "delete", "secret", userSecretName, + "-n", gatewayNS, "--ignore-not-found=true")) + _, err = utils.Run(exec.Command("kubectl", "create", "secret", "tls", userSecretName, + "-n", gatewayNS, + fmt.Sprintf("--cert=%s", certPath), fmt.Sprintf("--key=%s", keyPath))) + Expect(err).NotTo(HaveOccurred()) + + By("allowing per-app ListenerSets from the test namespace on the shared Gateway") + _, err = utils.Run(exec.Command("kubectl", "patch", "gateway", "nebari-gateway", + "-n", gatewayNS, "--type", "merge", "-p", fmt.Sprintf( + `{"spec":{"allowedListeners":{"namespaces":{"from":"Selector",`+ + `"selector":{"matchLabels":{"kubernetes.io/metadata.name":%q}}}}}}`, testNamespace))) + Expect(err).NotTo(HaveOccurred()) + + By("enabling the operator's ClusterIssuer so the cert-manager path (and ListenerSet) is used") + _, err = utils.Run(exec.Command("kubectl", "set", "env", + fmt.Sprintf("deployment/%s", operatorDeploy), "-n", operatorNS, + fmt.Sprintf("TLS_CLUSTER_ISSUER_NAME=%s", clusterIssuer))) + Expect(err).NotTo(HaveOccurred()) + _, err = utils.Run(exec.Command("kubectl", "rollout", "status", + fmt.Sprintf("deployment/%s", operatorDeploy), "-n", operatorNS, "--timeout=120s")) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterAll(func() { + _, _ = utils.Run(exec.Command("kubectl", "delete", "nebariapp", appName, + "-n", testNamespace, "--ignore-not-found=true", "--timeout=60s")) + + By("reverting the operator ClusterIssuer override") + _, _ = utils.Run(exec.Command("kubectl", "set", "env", + fmt.Sprintf("deployment/%s", operatorDeploy), "-n", operatorNS, + "TLS_CLUSTER_ISSUER_NAME-")) + _, _ = utils.Run(exec.Command("kubectl", "rollout", "status", + fmt.Sprintf("deployment/%s", operatorDeploy), "-n", operatorNS, "--timeout=120s")) + + By("reverting the Gateway allowedListeners override") + _, _ = utils.Run(exec.Command("kubectl", "patch", "gateway", "nebari-gateway", + "-n", gatewayNS, "--type", "json", "-p", + `[{"op":"remove","path":"/spec/allowedListeners"}]`)) + + _, _ = utils.Run(exec.Command("kubectl", "delete", "secret", userSecretName, + "-n", gatewayNS, "--ignore-not-found=true")) + CleanupTestNamespace(testNamespace) + if certDir != "" { + _ = os.RemoveAll(certDir) + } + }) + + It("cuts a cert-manager TLS app over to a per-app ListenerSet and retires the legacy listener", func() { + By("applying a NebariApp with cert-manager TLS") + manifest := fmt.Sprintf(`apiVersion: reconcilers.nebari.dev/v1 +kind: NebariApp +metadata: + name: %s + namespace: %s +spec: + hostname: %s + service: + name: test-app + port: 80 + routing: + tls: + enabled: true +`, appName, testNamespace, hostname) + cmd := exec.Command("kubectl", "apply", "-f", "-") + cmd.Stdin = strings.NewReader(manifest) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + By("waiting for the per-app ListenerSet to be Programmed") + Eventually(listenerSetProgrammed, 3*time.Minute, 5*time.Second).Should(Equal("True")) + + By("verifying the HTTPRoute reparented to the ListenerSet") + Eventually(func(g Gomega) { + out, err := utils.Run(exec.Command("kubectl", "get", "httproute", + fmt.Sprintf("%s-route", appName), "-n", testNamespace, + "-o", "jsonpath={.spec.parentRefs[0].kind}")) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(strings.TrimSpace(out)).To(Equal("ListenerSet")) + }, 1*time.Minute, 5*time.Second).Should(Succeed()) + + By("verifying the legacy shared-Gateway listener was removed on cutover") + Eventually(gatewayHasLegacyListener, 1*time.Minute, 5*time.Second).Should(BeFalse()) + + By("verifying TLSReady is True") + out, err := utils.Run(exec.Command("kubectl", "get", "nebariapp", appName, + "-n", testNamespace, + "-o", `jsonpath={.status.conditions[?(@.type=="TLSReady")].status}`)) + Expect(err).NotTo(HaveOccurred()) + Expect(strings.TrimSpace(out)).To(Equal("True")) + }) + + It("removes the ListenerSet and falls back to the legacy listener when switching to a user-provided secret", func() { + By("setting routing.tls.secretName on the cut-over app") + _, err := utils.Run(exec.Command("kubectl", "patch", "nebariapp", appName, + "-n", testNamespace, "--type", "merge", "-p", + fmt.Sprintf(`{"spec":{"routing":{"tls":{"secretName":%q}}}}`, userSecretName))) + Expect(err).NotTo(HaveOccurred()) + + By("verifying the ListenerSet is deleted (no longer claims the hostname)") + Eventually(listenerSetNames, 2*time.Minute, 5*time.Second).Should(BeEmpty()) + + By("verifying the legacy Gateway listener is back and references the user secret") + Eventually(func(g Gomega) { + out, err := utils.Run(exec.Command("kubectl", "get", "gateway", "nebari-gateway", + "-n", gatewayNS, "-o", fmt.Sprintf( + `jsonpath={.spec.listeners[?(@.name=="%s")].tls.certificateRefs[0].name}`, listenerName))) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(strings.TrimSpace(out)).To(Equal(userSecretName)) + }, 1*time.Minute, 5*time.Second).Should(Succeed()) + }) + + It("removes both the ListenerSet and the legacy listener when TLS is disabled", func() { + By("returning the app to the cert-manager path and waiting for re-cutover") + _, err := utils.Run(exec.Command("kubectl", "patch", "nebariapp", appName, + "-n", testNamespace, "--type", "json", "-p", + `[{"op":"remove","path":"/spec/routing/tls/secretName"}]`)) + Expect(err).NotTo(HaveOccurred()) + Eventually(listenerSetProgrammed, 3*time.Minute, 5*time.Second).Should(Equal("True")) + + By("disabling TLS on the cut-over app") + _, err = utils.Run(exec.Command("kubectl", "patch", "nebariapp", appName, + "-n", testNamespace, "--type", "merge", "-p", + `{"spec":{"routing":{"tls":{"enabled":false}}}}`)) + Expect(err).NotTo(HaveOccurred()) + + By("verifying the ListenerSet is deleted") + Eventually(listenerSetNames, 2*time.Minute, 5*time.Second).Should(BeEmpty()) + + By("verifying no legacy Gateway listener remains for the app") + Eventually(gatewayHasLegacyListener, 1*time.Minute, 5*time.Second).Should(BeFalse()) + + By("verifying the HTTPRoute now targets the plain HTTP listener") + Eventually(func(g Gomega) { + out, err := utils.Run(exec.Command("kubectl", "get", "httproute", + fmt.Sprintf("%s-route", appName), "-n", testNamespace, + "-o", "jsonpath={.spec.parentRefs[0].sectionName}")) + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(strings.TrimSpace(out)).To(Equal("http")) + }, 1*time.Minute, 5*time.Second).Should(Succeed()) + }) +})