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">
@@ -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())
+ })
+})