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/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 | 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/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" 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/go.mod b/go.mod index 3709dc1..bb5e67c 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.5 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= diff --git a/internal/controller/nebariapp_controller.go b/internal/controller/nebariapp_controller.go index b701f09..4fcd1e6 100644 --- a/internal/controller/nebariapp_controller.go +++ b/internal/controller/nebariapp_controller.go @@ -35,6 +35,7 @@ import ( "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" @@ -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/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.go b/internal/controller/reconcilers/tls/listenerset.go new file mode 100644 index 0000000..49409cf --- /dev/null +++ b/internal/controller/reconcilers/tls/listenerset.go @@ -0,0 +1,251 @@ +/* +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" + "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" + "sigs.k8s.io/controller-runtime/pkg/log" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" +) + +// 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 reconcileTLSAttachment / +// shouldCutOver). +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 +} + +// 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): 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), + Namespace: nebariApp.Namespace, + }, ls); err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + 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 + } + + // 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 false, nil +} + +// reconcileTLSAttachment reconciles the per-app ListenerSet and decides which +// listener serves this app's HTTPS traffic, returning whether it has cut over to +// the ListenerSet (ADR-0011 Option 2). +// +// 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. +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 + } + + cutover, err := r.shouldCutOver(ctx, nebariApp) + if err != nil { + return false, err + } + + if cutover { + // removeGatewayListener is idempotent (no-op once removed / never created). + 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 + } + + // 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 new file mode 100644 index 0000000..66e6178 --- /dev/null +++ b/internal/controller/reconcilers/tls/listenerset_test.go @@ -0,0 +1,376 @@ +/* +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" + 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" + "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"}, + } +} + +// 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: 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()}, + }, + }, + }, + }, + } +} + +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) + } +} + +// 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: "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) { + 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.shouldCutOver(context.Background(), app) + if err != nil { + t.Fatalf("shouldCutOver: %v", err) + } + if got != tt.want { + t.Errorf("cutover = %v, want %v", got, tt.want) + } + }) + } +} + +// 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 + 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 before the ListenerSet reports usable status") + } + // 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 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") + } +} + +// 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) { + 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() + 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, seededLS).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 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") + } + } +} + +// 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 9b3d0a0..4114fe4 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. @@ -91,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 @@ -124,13 +143,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 +165,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 +197,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,6 +226,24 @@ func (r *TLSReconciler) reconcileUserProvidedTLS(ctx context.Context, nebariApp return nil, err } + // 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, @@ -190,11 +253,15 @@ 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 lives in the Gateway namespace, the legacy shared + // listener's home. + secretNS := constants.GatewayNamespace + // 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 +272,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 +289,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: false, }, nil } @@ -234,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 @@ -255,8 +327,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 +346,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 +370,9 @@ func (r *TLSReconciler) reconcileCertificate(ctx context.Context, nebariApp *app }, } + if ownerRef { + return controllerutil.SetControllerReference(nebariApp, cert, r.Scheme) + } return nil }) @@ -294,15 +380,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 +528,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 +642,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 +675,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. 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()) + }) +})