Skip to content

feat(tls): per-app ListenerSet for TLS ownership - #170

Draft
viniciusdc wants to merge 9 commits into
mainfrom
feat/per-app-listenerset
Draft

feat(tls): per-app ListenerSet for TLS ownership#170
viniciusdc wants to merge 9 commits into
mainfrom
feat/per-app-listenerset

Conversation

@viniciusdc

@viniciusdc viniciusdc commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Closes #168.

What

Implements ADR-0011 Option 2: the operator owns a per-app ListenerSet (standard gateway.networking.k8s.io/v1) in the NebariApp's own namespace, attached to the shared Gateway via spec.parentRef, instead of mutating the shared Gateway's listener list. The app's Certificate + TLS secret are co-located in that namespace and owner-referenced, so they garbage-collect with the app (no cross-namespace label bookkeeping, no ReferenceGrant). This removes the shared-Gateway co-ownership that leaves gateway-config permanently OutOfSync.

Runtime dependency: Envoy Gateway v1.8.2+ (hard)

This needs the cluster to serve the standard gateway.networking.k8s.io/v1 ListenerSet CRD, which ships in the Gateway API v1.5 bundle carried by Envoy Gateway v1.8.2+. The operator does not add a fallback for older EG: it is foundational software pinned per NIC release, so NIC controls its EG version, and guarding against a mismatch that can't occur in the supported model would be dead defense.

Today's NIC platform is on EG v1.6.2 (verified on two live clusters: they carry the experimental xlistenersets.gateway.networking.x-k8s.io but not the standard kind used here). So this must not be released ahead of the platform EG upgrade (nebari-dev/nebari-infrastructure-core#496); once a cluster is on EG v1.8.2+, it works.

Cutover: reason-aware, remove-first (per NebariApp)

No user-facing strategy flag. The earlier draft of this PR described a staged hold (keep the legacy per-app listener until the ListenerSet reports Programmed); review and live testing on EG v1.8.2 showed that deadlocks an in-place migration. A ListenerSet detaches the hostname's routes from the shared Gateway the moment it is allowed to attach - regardless of whether it is ever Accepted or Programmed - so holding the legacy listener on the same (port, hostname) both takes the app offline and blocks the ListenerSet on a HostnameConflict it can never clear.

So shouldCutOver decides off the status Envoy Gateway actually reports:

  • set-level Programmed=True -> cut over.
  • our listener reports Conflicted=True/HostnameConflict with refs resolving -> our own legacy listener is the only blocker, so remove it first and let the ListenerSet leave the conflict and program.
  • otherwise (no status yet, Accepted=False/NotAllowed, unresolved refs) -> stay on the legacy listener. NotAllowed means the Gateway refuses the attachment (e.g. spec.allowedListeners unset), so removing the legacy listener would strand the app.

Owns(&ListenerSet{}) drives the reconcile when the status flips. App-namespace TLS plumbing (cert/secret co-location, owner refs, routing parentRef threading) is adapted from the per-app-Gateway prototype in #167.

User-provided secret and TLS-disabled

  • routing.tls.secretName stays on the legacy shared-Gateway listener (the secret lives in the Gateway namespace; the app-namespace copy is TODO(#168)). Switching to a user secret deletes any ListenerSet a prior cert-manager reconcile cut over to, so it stops claiming the hostname; there is a brief (~seconds) reprogram window on that switch.
  • Disabling TLS tears down both the ListenerSet and the legacy listener, so a cut-over app that turns TLS off does not keep terminating HTTPS and detaching the route.

Validation

Validated live on a k3d NIC bed upgraded to EG v1.8.2, driving nebari-landing (a real app with a real backend) through the full flow with the operator built from this branch: the ListenerSet reaches Programmed, the HTTPRoute reparents to kind: ListenerSet, the legacy listener is removed, and the served TLS cert flips from the Gateway-namespace cert to the app-namespace cert. Then the user-secret and TLS-disable transitions both tear the ListenerSet down and fall back correctly. Every step stayed HTTP 200 - no outage on any transition. Also confirmed the listenersets RBAC is load-bearing: without list/watch, Owns(&ListenerSet{}) fails and the controller silently stops reconciling.

Dependency / toolchain bump

  • sigs.k8s.io/gateway-api v1.4.1 -> v1.5.1 (stable ListenerSet), github.com/envoyproxy/gateway v1.6.3 -> v1.8.2. These pull k8s libs to 0.36 / controller-runtime 0.24, which raise the Go floor to 1.26 (matches NIC's go 1.26.5). Carried here as a result: Dockerfile base golang:1.25 -> golang:1.26, README Go badge/prereq to 1.26+, and golangci-lint via install-mode: goinstall (no released golangci binary is built with go >= 1.26 yet, so binary mode fails its built-with guard).
  • Lint: SA1019 deprecations from controller-runtime 0.24 (old events API, scheme.Builder) are excluded in .golangci.yml; the real migration is tracked in Migrate off deprecated controller-runtime events API (GetEventRecorderFor) and scheme.Builder #171. goconst excluded on _test.go.
  • RBAC: added listenersets (+/status). gateways keeps update;patch because the reason-aware cutover still upserts/removes the shared listener on the pre-cutover and user-secret paths; it can drop to read-only once those go away.

TEMPORARY (remove before approval)

dev/scripts/services/install.sh installed EG v1.2.4 (no ListenerSet CRD), so e2e could not exercise this path. Bumped to v1.8.2 so e2e runs the real cutover. The dev/e2e EG version should track NIC's pin rather than be hardcoded here, so reconcile against nebari-dev/nebari-infrastructure-core#496 before merging.

Test plan

  • Unit: shouldCutOver decision matrix (programmed / no-status / NotAllowed / hostname-conflict with refs resolved and unresolved); remove-first cutover vs stay-on-legacy; and the off-path teardown (user-secret and TLS-disabled both delete the ListenerSet). tls/routing/auth suites pass.
  • E2E: new test/e2e/tls_listenerset_test.go (Serial) drives the real cutover and both transitions against EG v1.8.2, asserting Programmed, route reparent, legacy-listener removal, and teardown. It enables the operator TLS_CLUSTER_ISSUER_NAME + the Gateway allowedListeners in BeforeAll and reverts both in AfterAll, since the rest of the suite assumes no per-app cert-manager TLS. (Correcting the earlier draft: the previous e2e did not exercise the cutover at all - the default app used the shared https listener - so this spec is what adds that coverage.)
  • Reviewer: worth a manual pass on a cluster that already has nebari-dev/nebari-infrastructure-core#496 applied.

Known follow-ups

  • Loud-fail startup precondition: on a cluster missing the standard ListenerSet CRD (EG < v1.8.2), the operator presents as 1/1 Running / Ready while Owns(&ListenerSet{}) blocks cache sync and nothing reconciles. It should fail fast at startup with a clear error instead. Not built here.
  • Peer hostname conflict (feat(tls): adopt per-app ListenerSet for TLS ownership (ADR-0011, Option 2) #168 scope 5): two apps sharing a hostname is indistinguishable from an own-legacy conflict by condition type/status/reason (only the condition message names the culprit), so the loser silently stays put without a surfaced conflict signal. Restoring a surfaced condition and discriminating the peer case is deferred; this is also where the old ReasonGatewayListenerConflict signal should be re-established.
  • routing.tls.secretName via ListenerSet: needs the app-namespace secret copy so cert-manager adopts it without reissuing (TODO(#168)); until then this path stays on the legacy listener.
  • ObservedGeneration: shouldCutOver reads status without an observedGeneration == generation guard. EG does populate it (verified on v1.8.2), so it is a clean future hardening; omitted here because a hostname edit reconciled fine without it in testing.
  • Deprecated controller-runtime events API migration: Migrate off deprecated controller-runtime events API (GetEventRecorderFor) and scheme.Builder #171.

Required for the stable ListenerSet type (gateway.networking.k8s.io/v1,
GA in Gateway API v1.5) and to align the SecurityPolicy types with the
Envoy Gateway v1.8.x runtime. Pulls k8s.io libs to 0.36 and
controller-runtime to 0.24; build, vet, and unit tests pass unchanged.
Introduce the per-app ListenerSet primitive (ADR-0011 Option 2): the
operator creates a ListenerSet in the NebariApp's own namespace, attached
to the shared Gateway via spec.parentRef, carrying the app's HTTPS
listener with a co-located, owner-referenced Certificate.

Staged, status-gated migration: the ListenerSet is always reconciled, but
the legacy shared-Gateway listener keeps serving until the ListenerSet
reports Accepted+Programmed. Only then do HTTPRoutes retarget to the
ListenerSet and the shared listener is removed. On an Envoy Gateway that
does not reconcile ListenerSet (pre-v1.8) the conditions never flip, so
the operator stays on the legacy path and per-app TLS is unaffected.

- TLSResult gains UseListenerSet; routing parentRef targets the
  ListenerSet (app namespace) once cut over, else the shared Gateway.
- reconcileCertificate/isCertificateReady/checkUserProvidedSecret are
  namespace-parameterized; the ListenerSet cert is owner-referenced.
- RBAC: add listenersets (+status); Owns(ListenerSet) so a Programmed
  transition drives the cutover reconcile.
- Regenerated CRDs/RBAC.

Closes #168.
Add unit tests for reconcileListenerSet (app-namespace object, parentRef to
the shared Gateway, same-namespace cert ref, owner reference),
isListenerSetProgrammed (Accepted+Programmed gating), the staged-migration
attachment (stays on the legacy shared listener until Programmed, cuts over
and removes it once Programmed), and route parentRef target selection.
Document the ListenerSet ownership model and the routing.tls.secretName
namespace change in docs/reconcilers/routing.md.
@oldsj oldsj self-assigned this Aug 6, 2026
@oldsj

oldsj commented Aug 6, 2026

Copy link
Copy Markdown

@viniciusdc I'm happy to test this on my cluster, will do that and report results here later

@viniciusdc

Copy link
Copy Markdown
Collaborator Author

Self-review from a full pass over this. Flagging what it turned up so nothing gets lost; I'll work through these before it leaves draft.

Broken:

  • CI is red on formatting. gofmt wants the gatewayv1 import moved in nebariapp_controller.go:34 (it's mid-block in the controller-runtime group). "Lint passed" wasn't true, I only ran build/vet/test locally.
  • The feat(tls): adopt per-app ListenerSet for TLS ownership (ADR-0011, Option 2) #168 scope-5 conflict path isn't actually done. listenerset.go:146-155 collapses Accepted and Programmed into one bool, so a hostname/listener conflict (which now surfaces as Accepted=False) just reads as "not programmed" and the app pins to legacy forever. ReasonGatewayListenerConflict never fires, and containsListenerConflict at :459 still matches the old Gateway-Update error string that never happens on this path.

Risky / needs a call before merge:

  • Cert double-issuance. reconciler.go:141 always reconciles the app-ns Certificate while :169 keeps the Gateway-ns one until cutover, so on an EG that doesn't program ListenerSet (v1.6.2, i.e. until nebari-infrastructure-core#496 lands) every app carries two LE certs for the same hostname indefinitely. And cutover isn't latched, so a Programmed: true->false flap deletes+recreates the Gateway-ns cert each swing (listenerset.go:186-201 + reconciler.go:165-173) -> LE rate-limit territory. The secret-copy step (feat(tls): adopt per-app ListenerSet for TLS ownership (ADR-0011, Option 2) #168 scope-4) that avoids this is deferred.
  • Silent dependency on the shared Gateway setting spec.allowedListeners (gateway-api v1.5.1 rejects ListenerSet attachment otherwise). We correctly don't set it, but it's undocumented and there's no status signal when the ListenerSet is rejected or never programs. Cutover reason is "TLSConfigured" identically before and after (reconciler.go:188), only breadcrumb is a V(1) log. Need to confirm nebari-infrastructure-core#496 configures allowedListeners, else the path silently never activates and looks healthy.

Smaller, fixing in the same pass:

  • listenerset.go:146-155 should gate on ObservedGeneration, else stale True conditions from a prior generation can trigger cutover after a hostname edit before the listener reprograms.
  • Hand-rolled status loop reinvents meta.IsStatusConditionTrue; swapping to it plus the gatewayv1.ListenerSetCondition* constants drops ~16 lines.
  • User-provided-secret apps can never cut over (reconciler.go:233-240, the TODO(#168)) because the secret isn't copied into the app namespace. Those stay on legacy for now.
  • No ReconcileTLS-level cutover test and no staged-sequence / Programmed->NotProgrammed regression test, which is the riskiest logic here.

Listener/routing/GC wiring itself I'm happy with; it's the migration edges that need another lap.

@oldsj

oldsj commented Aug 6, 2026

Copy link
Copy Markdown

I'll wait until ready but I have a cluster we can test this on whenever

@viniciusdc

Copy link
Copy Markdown
Collaborator Author

@oldsj pushed a fmt fix (b233c2f), so CI should go green now. Go ahead and give it a spin on your cluster whenever.

One setup gotcha so the cutover actually gets exercised: the shared nebari-gateway needs spec.allowedListeners set, otherwise gateway-api v1.5 rejects the ListenerSet attachment, it stays Accepted=False, and the app just keeps serving on the legacy listener. That looks healthy but isn't testing the new path. With that set on EG v1.8.x you should see the per-app ListenerSet go Programmed, the HTTPRoute retarget to it, and the legacy listener on nebari-gateway drop.

Still draft, so the edges from the self-review above are expected: the double cert while it hasn't cut over, and no status breadcrumb for the pending/stuck state. What I'd most want out of a real cluster: does the cutover actually happen and serve the app's own SNI cert, and how does an app behave when the ListenerSet never programs. Thanks for taking it.

@viniciusdc
viniciusdc marked this pull request as ready for review August 6, 2026 18:56
@viniciusdc
viniciusdc marked this pull request as draft August 6, 2026 18:59
…ions and test goconst

- build golangci-lint from source (install-mode goinstall) so it runs on the
  go 1.26 module (no released binary is built with go>=1.26 yet)
- exclude SA1019 deprecations (controller-runtime 0.24 old events API,
  scheme.Builder) in cmd/main.go and api/groupversion_info.go; migration tracked separately
- exclude goconst on _test.go fixture strings
- align go.mod to go 1.26.5
The e2e/dev foundational stack installed Envoy Gateway v1.2.4, which does not
ship the standard gateway.networking.k8s.io/v1 ListenerSet CRD, so the per-app
ListenerSet reconcile fails on 'no matches for kind ListenerSet' and TLS never
becomes Ready. Bump to v1.8.2 so e2e exercises the real ListenerSet path.

Marked TEMPORARY: the operator is version-pinned alongside NIC, so its
foundational Envoy Gateway version should track NIC's pin rather than being
hardcoded here. Reconcile before approval.
@viniciusdc

viniciusdc commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

E2E was red because dev/scripts/services/install.sh installed EG v1.2.4, which has no ListenerSet CRD at all, so the reconcile failed on "no matches for kind ListenerSet" and TLS never went Ready. I bumped that install to v1.8.2 so e2e exercises the real ListenerSet path. It's marked TEMPORARY in the script: the dev/e2e EG version should track NIC's pin, not be hardcoded here, so reconcile it against nebari-dev/nebari-infrastructure-core#496 before this merges.

@oldsj when you get to reviewing, it would be worth testing this on a cluster that already has nebari-dev/nebari-infrastructure-core#496 applied (EG v1.8.2). That's where the actual cutover happens: the per-app ListenerSet reaches Programmed and the legacy shared-Gateway listener is retired. On the current v1.6.2 platform it just stays on the shared listener.

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Docker Images Built

Images pushed to Quay.io for branch feat-per-app-listenerset:

Image Tag Platforms
Operator quay.io/nebari/nebari-operator:feat-per-app-listenerset linux/amd64 + linux/arm64

Test the operator:

kubectl apply -k https://github.com/nebari-dev/nebari-operator.git/config/default?ref=feat/per-app-listenerset
kubectl set image deployment/nebari-operator-controller-manager manager=quay.io/nebari/nebari-operator:feat-per-app-listenerset -n nebari-operator-system

@oldsj

oldsj commented Aug 7, 2026

Copy link
Copy Markdown

I ran this on jamesolds-dev (EKS, Kubernetes v1.34, selfsigned-issuer; Envoy Gateway v1.6.2 for T1–T2, upgraded to v1.8.2 mid-run for everything after) with one TLS-enabled NebariApp and no secretName. I tested db08ddc, built for linux/amd64 before I noticed CI publishes quay.io/nebari/nebari-operator:feat-per-app-listenerset — I'll use that next round. It differs from 7518e7c only in dev/scripts/services/install.sh, so the binary is equivalent.

Headline: the mechanism works — a brand-new app cuts over and serves its own SNI cert (T4b) — but the migration path deadlocks and 404s the app it's migrating (T4); the only way through is a manual delete/recreate procedure with a downtime window (T4c). The T4/T7/T9 failures turn out to be one mechanism, not three bugs, and the PR's stated motivation — gateway-config going Synced — did not materialize here (T5). You reached the merge-ordering conclusion yourself in the rewritten description, so T2 is confirmation there; the part I'd still push back on is the "dead defense" argument.

T1 — never-programs path: pass, zero interruption

The operator created nebari-system/nebari-landing-listeners as specified:

spec:
  parentRef:                       # shared Gateway, cross-namespace
    group: gateway.networking.k8s.io
    kind: Gateway
    name: nebari-gateway
    namespace: envoy-gateway-system
  listeners:
  - name: tls-nebari-landing-nebari-system
    hostname: jamesolds-dev.example.com
    port: 443
    protocol: HTTPS
    tls:
      mode: Terminate
      certificateRefs:
      - {group: "", kind: Secret, name: nebari-landing-nebari-system-tls}   # no namespace -> in-namespace, no ReferenceGrant
    allowedRoutes:
      namespaces: {from: Same}

generation: 1, and ownerReferences carries controller: true pointing at the NebariApp, so GC is wired.

Because EG v1.6.2 does not reconcile it, the status stayed at the CRD default — note the epoch-0 timestamp, which is the tell that no controller ever wrote it:

status:
  conditions:
  - {type: Accepted,   status: Unknown, reason: Pending, message: "Waiting for controller", lastTransitionTime: "1970-01-01T00:00:00Z"}
  - {type: Programmed, status: Unknown, reason: Pending, message: "Waiting for controller", lastTransitionTime: "1970-01-01T00:00:00Z"}

isListenerSetProgrammed reads that correctly and keeps the app on the legacy path. The shared Gateway and HTTPRoute are untouched:

gateway nebari-gateway  spec.listeners:  http  https  tls-nebari-landing-nebari-system
httproute nebari-landing-route  spec.parentRefs:
  [{kind: Gateway, name: nebari-gateway, namespace: envoy-gateway-system,
    sectionName: tls-nebari-landing-nebari-system}]

curl returned 200 throughout, and the served certificate stayed on serial C1245EE285B0F4E22A0AE399D1F3A625 — identical to the Gateway-namespace secret — so the legacy listener is provably still the one terminating TLS.

I also checked that Owns(&ListenerSet{}) isn't causing write amplification: the operator logs ListenerSet reconciled … operation: updated on every reconcile, which looks like a hot loop, but resourceVersion held at 1094299 and generation at 1 across 30s of polling. It's just the startup reconcile burst, not a write loop.

Two known issues reproduce:

  • Double issuance: two Ready=True Certificates for jamesolds-dev.example.com exist — the existing Gateway-namespace certificate and a new, unused app-namespace certificate. It is harmless with selfsigned-issuer, but on a real cluster this is a second ACME issuance per app while the cluster is pre-v1.8.
  • No status breadcrumb: NebariApp conditions are byte-identical to the alpha.20 baseline — Ready=True/ReconcileSuccess, TLSReady=True/TLSConfigured, RoutingReady=True/HTTPRouteReady, AuthReady=True/AuthConfigured. TLSConfigured while a ListenerSet sits unprogrammed is the part I'd change: nothing in status or events says the app is mid-migration.

allowedListeners is also a hard requirement for the cutover, and it isn't documented. gateway-api v1.5.1 is explicit — AllowedListeners godoc says "The default value is to allow no ListenerSets", and ListenerNamespaces.From carries +kubebuilder:default=None. NIC's nebari-gateway doesn't set the field at all today (allowedListeners: null on my cluster). Until NIC adds the field, the Gateway refuses every ListenerSet, leaving each app silently on the legacy listener, indistinguishable from a healthy pre-cutover cluster — T3 below confirms exactly that on v1.8.2, with the field deliberately left unset.

T2 — missing ListenerSet CRD: the unmet dependency is invisible

I deleted listenersets.gateway.networking.k8s.io and restarted the operator. It did not crash; it became silently inert:

  • Pod 1/1 Running, ready=true, zero restarts; ArgoCD still reported Synced / Healthy.

  • Starting workers never appeared. Every 10 seconds controller-runtime logged:

    if kind is a CRD, it should be installed before calling Start
    {"kind":"ListenerSet.gateway.networking.k8s.io",
     "error":"no matches for kind \"ListenerSet\" in version \"gateway.networking.k8s.io/v1\""}
    
  • Annotating the NebariApp produced no reconcileID log lines for 20 seconds: nothing reconciled.

Two separate claims, to be precise about which evidence supports which: the source.Kind error together with Starting workers never appearing is what indicates the Owns() watch is blocking cache sync and preventing the controller from starting. Re-applying the CRD restored everything within about 25 seconds without a restart, which establishes the missing CRD as the trigger and shows it self-heals once the CRD appears.

This agrees with your hard-dependency conclusion, and my cluster is a data point for it rather than a counterexample. The standard CRD here was hand-applied during unrelated LBC work, not shipped by the EG chart — creationTimestamp: 2026-08-06T03:08:25Z, gateway.networking.k8s.io/bundle-version: v1.5.1 on the experimental channel, and no labels or helm ownership metadata, unlike every CRD the chart installs. So this isn't a normal pre-v1.8 state, which matches what you found on the two clusters you checked.

Where I'd push back is this:

The operator does not add a fallback for older EG: it is foundational software pinned per NIC release, so NIC controls its EG version, and guarding against a mismatch that can't occur in the supported model would be dead defense.

I agree a cross-version fallback is not worth building. But the mismatch can occur outside the supported model — the v1.2.4 e2e run is an instance of exactly that, and so is anyone upgrading the operator ahead of NIC — and when it does, the result is not a visible failure. It is 1/1 Running, ready=true, zero restarts, ArgoCD Synced / Healthy, and nothing reconciling. "Dead defense" assumes an unmet dependency announces itself; here it presents as a healthy cluster whose operator has stopped doing anything, with no alert to hang a runbook on. The v1.2.4 run surfaced as TLS never becoming Ready, which is the same incompatibility seen through one app.

So my ask isn't a fallback, it's a loud failure. In order of preference:

  1. Make EG v1.8.2 / the standard ListenerSet CRD an explicit precondition checked at startup, and fail fast with a clear error instead of reporting Ready while the controller is blocked. This is your stated design, just made observable.
  2. Drop Owns() and rely on the existing requeue to observe Programmed transitions — costs cutover latency, not correctness, and removes the startup dependency entirely.
  3. Gate Owns(&ListenerSet{}) on API discovery and fall back to For(&NebariApp{}) when the CRD is absent. Listed for completeness; this is the fallback you've argued against, and I don't think it's needed if 1 is done.

Option 1 is the one I'd take. Either way the precondition should be visible in the deployment docs, not only in the PR description.

The EG v1.8.2 upgrade itself: clean

Bumped this cluster v1.6.2 → v1.8.2. Gateway API CRDs moved to bundle v1.5.1, nebari-gateway returned Accepted=True Programmed=True, and the NLB was preserved — same hostname, no Service or load-balancer rotation, no dataplane interruption. The hand-applied listenersets CRD was adopted by the chart without a sync conflict. That retires the Service-identity migration risk ADR-0011 flagged, at least for the 1.6→1.8 hop on EKS/NLB.

T3 — allowedListeners unset on EG v1.8.2: rejected cleanly, invisibly

Accepted=False/NotAllowed:   ListenerSet attachment from namespace nebari-system
                             not allowed by Gateway envoy-gateway-system/nebari-gateway
Programmed=False/NotAllowed: Not Programmed

App unaffected, serial unchanged, 200s throughout. At that same moment the NebariApp read TLSReady=True/TLSConfigured: TLS certificate is ready and the listener is configured, and events showed only GatewayListenerAdded. So the "no status breadcrumb" gap has a sharper consequence than I thought: a cluster where NIC simply hasn't set allowedListeners is indistinguishable, from the NebariApp's point of view, from one that has cut over.

T4 — the cutover deadlocks for an app that already has a legacy listener, and 404s it

Setting allowedListeners.namespaces.from: All did not produce a cutover. It produced a deadlock plus a 3m49s outage.

listener tls-nebari-landing-nebari-system (in the ListenerSet):
  Conflicted=True/HostnameConflict
  Accepted=False/HostnameConflict
  Programmed=False/HostnameConflict
  message: All listeners for a given port must use a unique hostname, conflicting listeners:
    envoy-gateway-system/nebari-gateway/tls-nebari-landing-nebari-system,
    nebari-system/nebari-landing-listeners/tls-nebari-landing-nebari-system

reconcileTLSAttachment keeps the legacy shared-Gateway listener until the ListenerSet reports Accepted+Programmed. The ListenerSet can't reach Programmed while the legacy listener holds the same (port, hostname). Neither side can move first.

It isn't stuck-but-safe. EG's conflict resolution detached the route from the listener that had been serving:

HTTPRoute nebari-landing-route:
  Accepted=False/NoMatchingListenerHostname
  "There were no hostname intersections between the HTTPRoute and this parent ref's Listener(s)."

gateway nebari-gateway status.listeners:
  tls-nebari-landing-nebari-system  attachedRoutes=0    (was 1)

The Gateway's own listener still self-reports Accepted=True Programmed=True, so the Gateway looks healthy while serving nothing for that hostname.

Measured on a 1s probe: first failure at 13:44:25 — the same second the Gateway change applied — last at 13:48:14, 182 of 236 samples HTTP 404, ending only because I reverted. TLS never broke; the served serial stayed C1245EE285B0F4E22A0AE399D1F3A625 for all 236 samples, which also proves no cutover occurred — the app-namespace secret's serial (081F783E82E44089ABF5C602A9C4C8DE) was never served. Recovery after revert: ~15s.

T4b — a brand-new app cuts over correctly

I first assumed from reconcileTLSAttachment that new apps would deadlock too, since the not-programmed branch creates the legacy listener. That was wrong, and the difference is instructive.

With allowedListeners: All already live, I created t4b-newapp (hostname t4b.jamesolds-dev.example.com, TLS on, no secretName). It worked end to end:

  • t4b-newapp-listenersAccepted=True Programmed=True in ~3s; route parented to kind: ListenerSet.
  • Served its own SNI cert: 404 → 200 with the served serial moving from 937EAD6B8B51FC293B23438B9B0DA72E (the shared https listener's nebari-gateway-tls) to 9D3927BC03947A51778F061BED5BD426 — its own app-namespace secret.
  • No legacy listener persisted; the Gateway never carried tls-t4b-newapp-nebari-system.

Events show the staged sequence ran, just fast enough to win the race: GatewayListenerRemoved … tls-t4b-newapp-nebari-system followed by CertificateDeleted.

So the deciding factor is which listener claims (port, hostname) first. New app: the ListenerSet wins and the transient legacy listener is removed. Migrated app: the legacy listener is already Accepted=True Programmed=True, EG gives the incumbent precedence, the ListenerSet is Conflicted, and the operator never removes the incumbent.

Both states coexisted on one cluster, which is the cleanest demonstration I can offer:

nebari-landing-listeners   Accepted=False  ListenersNotValid   (migrating app: deadlocked, 404)
t4b-newapp-listeners       Accepted=True   Accepted            (new app: cut over, own cert, 200)

So the design is sound and the mechanism works — but the migration path, which is the entire reason for the staged gate, cannot complete in place, and attempting it takes the app down. The gate as written waits for a condition that its own precondition prevents. There is a manual escape — delete and recreate the app, T4c below — but it bypasses the gate rather than completing it, and has its own downtime window.

Breaking the cycle needs the operator to stop treating the legacy listener as safe to hold: remove it first and accept a short gap while the ListenerSet programs, or don't create it at all when the standard CRD is present and treat the pre-Programmed window as ordinary startup latency (which is what effectively happens for new apps today). I don't think a distinct migration hostname is available, since SNI needs the real one.

Sequencing consequence

allowedListeners is a NIC-side change #170 requires, and the two cannot land independently in either order:

  • NIC adds allowedListeners first → every existing per-app-TLS app 404s until something is rolled back.
  • Operator ships first without it → ListenerSets sit NotAllowed, nothing cuts over, and nothing says so (T3).

T4c's procedure bounds the damage rather than removing the bind: even landing both together, every existing per-app-TLS app 404s from the moment allowedListeners applies until the delete/recreate procedure is run on it. Worth coordinating explicitly with nic#496 rather than as two independent merges.

T4c — there is a migration procedure, and it isn't obvious

Deleting and recreating the NebariApp works, but only if the legacy listener is fully gone before the recreate. My first attempt let ArgoCD recreate after 14s and it went straight back to deadlocked. Suspending auto-sync, deleting, confirming the Gateway was down to http https, then re-enabling gave a clean cutover in ~14s: ListenerSet Programmed=True, route parented to it, served serial moved to 081F783E82E44089ABF5C602A9C4C8DE (the app-namespace secret), Gateway left with zero operator-owned listeners, and the Gateway-namespace Certificate cleaned up so only one remains.

So the feature does reach its intended end state on a migrating app — via a manual procedure with a downtime window, not automatically. If the code doesn't change, that procedure needs documenting including the "wait for the listener to clear" step, because doing it too fast silently re-deadlocks.

T5 — the OutOfSync motivation is not satisfied

This is the PR's headline justification, so worth being precise. After cutover, with the Gateway carrying only http and https:

  • Live Gateway spec matches Git exactly, metadata and labels included.
  • kubectl diff --server-side -f gateway.yaml → empty.
  • argocd app diff gateway-config → empty.
  • An explicit argocd app sync succeeded (serverside-applied).

And gateway-config still reports OutOfSync on nebari-gateway. Adding dcmcand's compare-options=ServerSideDiff=true from nic#484 didn't clear it either.

Removing the co-ownership demonstrably did not clear the symptom here. The residual drift is not the operator's listeners — those are gone and provably so. I don't know what ArgoCD is still comparing, so I'd treat "fixes the permanent OutOfSync" as unproven rather than false; it just shouldn't read as a settled benefit.

T6 — the flap is safe, and cheaper than feared

From the cut-over state I removed allowedListeners, driving the ListenerSet to Accepted=False/NotAllowed. The operator fell back correctly — legacy listener recreated, route reparented, serving restored — with one probe sample lost (~1s).

And no certificate was reissued: the Gateway-namespace secret returned with the same serial C1245EE285B0F4E22A0AE399D1F3A625. The reason is the T10 bug below — the Secret is never GC'd, so cert-manager re-adopted the existing keypair rather than issuing. (No contradiction with T1's double-issuance: that is the app-namespace Certificate, a genuinely new issuance with no pre-existing Secret to adopt; this is the Gateway-namespace one re-adopting the Secret T10 orphans.) So the "flap → repeated ACME orders → rate limit" worry doesn't reproduce while that stale Secret survives. Worth confirming against a real ACME issuer, but the mechanism that would cause it isn't firing.

Note the asymmetry: falling back is clean because a NotAllowed ListenerSet doesn't claim the hostname; cutting over deadlocks because an accepted-but-conflicted one does.

T7 — hostname conflict: your self-review is too pessimistic in one way, too optimistic in another

Two apps on one hostname, created 20s apart.

ReasonGatewayListenerConflict does fire, contrary to "never fires". The losing app reported:

TLSReady=False/GatewayListenerConflict: Multiple NebariApps cannot share hostname …
Ready=False/Failed: … Gateway "nebari-gateway" is invalid: spec.listeners:
  Invalid value: "array": Combination of port, protocol and hostname must be unique

containsListenerConflict still matches, because the legacy reconcileGatewayListener path is what detects it — the API server rejects the duplicate Gateway listener. You were right that ListenerSet-level Accepted=False isn't wired up; the reason still fires via the old path.

But the winner ends up worse than the loser. t7-b claimed the Gateway listener and reported Ready=True, TLSReady=True/TLSConfigured — while its own ListenerSet was Conflicted=True/HostnameConflict and the hostname served 404. Of two colliding apps, one errors correctly and the other reports full health while broken.

T9 — secretName confirms the TODO, and also 404s

ListenerSet reached Accepted=True but Programmed=False:

ResolvedRefs=False/InvalidCertificateRef:
  No valid secrets exist: certificate refs 0: Secret nebari-system/t9-user-secret does not exist.

Exactly TODO(#168) — the secret is never copied into the app namespace, and it correctly never cuts over. But it also serves 404, because the accepted-though-unprogrammed ListenerSet listener claims the hostname and detaches the route, while the NebariApp reports TLSReady=True/UserProvidedSecretReady. A transient Ready=False concurrency error appeared and cleared on its own.

One mechanism behind T4, T7 and T9

These aren't three bugs. In every case, a ListenerSet listener that is merely allowed to attach for a hostname detaches that hostname's routes from the shared Gateway's listener, whether or not it is ever Accepted or Programmed (T4 and T7 sat at Accepted=False; T9 at Accepted=True). T4 (conflicted, never programs), T7 (conflicted winner), T9 (accepted, unresolvable cert) all end identically: HTTPRoute Accepted=False/NoMatchingListenerHostname, attachedRoutes=0, HTTP 404, NebariApp claiming health.

That falsifies the premise the staged gate rests on — "the legacy per-app listener on the shared Gateway serves in the meantime". It doesn't. Creating the ListenerSet is itself the disruptive act, so a fix probably has to avoid both objects claiming the hostname at once rather than sequencing on Programmed.

T8 — hostname edit: fine in practice

Editing spec.hostname on a cut-over app moved cleanly. observedGeneration tracked generation=2 immediately, the new hostname served 200, the old 404'd, and I saw no premature-cutover window at 10s sampling. The missing ObservedGeneration check is still a real smell, but I couldn't make it bite.

T10 — GC leaves the TLS Secret behind

Deleting an app removed the ListenerSet, HTTPRoute and Certificate and left no orphan Gateway listener — but the TLS Secret survived, with no ownerReferences, since cert-manager owns it and Certificate deletion doesn't cascade. Each deleted app leaves a stale keypair, and a recreated app of the same name adopts it. "Garbage-collect with the app" holds for the Certificate, not the Secret. Note this bug is also what currently prevents cert churn on a flap (T6), so the two are worth thinking about together.

Smaller

The CertificateDeleted event message reads "after switch to user-provided secret" on the normal cutover path, where no user-provided secret exists.

What I couldn't test

  • Let's Encrypt. Self-signed issuer on a non-public domain, so no ACME at all. The mechanism questions are answered; the rate-limit consequence isn't. Needs a real domain against LE staging.
  • An automatic in-place cutover — that's the T4 deadlock; T4c covers the manual procedure instead.
  • allowedListeners with from: Selector. I used All throughout.

Cluster state

Left in the migrated end state: EG v1.8.2, allowedListeners: All, nebari-landing cut over and serving 200 on its own cert, Gateway down to http https, one Certificate, all conditions True. Happy to hand over exact repro steps for any of the above.

One last thing for NIC rather than this PR: from: All lets any namespace attach a listener to the shared Gateway, discarding the delegation boundary that was an argument for Option 2 over mergeGateways. Production probably wants a namespace Selector — and #170 depends on that field existing without documenting it or surfacing anything when it's absent (T3).


Appendix — raw 1s availability probes (state changes only)

Each probe curled the hostname roughly every second and read the served certificate serial with openssl s_client. Only samples where the (status, serial) pair changed for that hostname are shown. Note the probes overlap other tests, so the raw non-200 counts are not all attributable to the test named — see the note under each.

T4 — deadlock outage on the migrating app — 236 samples, 182 non-200

13:43:44 code=200 serial=C1245EE285B0F4E22A0AE399D1F3A625
13:44:25 code=404 serial=C1245EE285B0F4E22A0AE399D1F3A625
13:48:16 code=200 serial=C1245EE285B0F4E22A0AE399D1F3A625

All 182 non-200s are the outage itself: it begins at 13:44:25, the second allowedListeners applied, and ends only when I reverted.

T4b — new app cutting over (both hostnames) — 220 samples, 136 non-200

14:04:45 jamesolds-dev.example.com  code=200 serial=C1245EE285B0F4E22A0AE399D1F3A625
14:04:45 t4b.jamesolds-dev.example.com  code=404 serial=937EAD6B8B51FC293B23438B9B0DA72E
14:05:13 jamesolds-dev.example.com  code=404 serial=C1245EE285B0F4E22A0AE399D1F3A625
14:05:45 t4b.jamesolds-dev.example.com  code=200 serial=9D3927BC03947A51778F061BED5BD426
14:08:34 t4b.jamesolds-dev.example.com  code=404 serial=937EAD6B8B51FC293B23438B9B0DA72E
14:09:06 jamesolds-dev.example.com  code=200 serial=C1245EE285B0F4E22A0AE399D1F3A625

t4b goes 404 -> 200 at 14:05:45 on its own cert. The 404s on jamesolds-dev.example.com from 14:05:13 are the pre-existing app still deadlocked from T4 — the two states side by side. t4b returns to 404 at 14:08:34 when I deleted it for the GC test.

T4c — delete/recreate migration — 475 samples, 146 non-200

14:25:30 code=404 serial=C1245EE285B0F4E22A0AE399D1F3A625
14:25:37 code=404 serial=081F783E82E44089ABF5C602A9C4C8DE
14:25:38 code=404 serial=937EAD6B8B51FC293B23438B9B0DA72E
14:25:41 code=404 serial=C1245EE285B0F4E22A0AE399D1F3A625
14:28:17 code=404 serial=937EAD6B8B51FC293B23438B9B0DA72E
14:28:35 code=200 serial=081F783E82E44089ABF5C602A9C4C8DE

The app is already down at the start (T4 deadlock, still 404). The procedure runs at 14:25:38; the first attempt lets ArgoCD recreate too fast and stays 404. The successful run completes at 14:28:35 -> 200 on the app-namespace cert. So the non-200s are mostly pre-existing breakage, not caused by the procedure.

T6 — flap back to legacy — 331 samples, 127 non-200

14:37:47 code=200 serial=081F783E82E44089ABF5C602A9C4C8DE
14:37:55 code=404 serial=C1245EE285B0F4E22A0AE399D1F3A625
14:37:56 code=200 serial=C1245EE285B0F4E22A0AE399D1F3A625
14:40:57 code=404 serial=C1245EE285B0F4E22A0AE399D1F3A625
14:43:30 code=404 serial=081F783E82E44089ABF5C602A9C4C8DE
14:43:31 code=404 serial=937EAD6B8B51FC293B23438B9B0DA72E
14:43:35 code=404 serial=C1245EE285B0F4E22A0AE399D1F3A625
14:43:36 code=200 serial=081F783E82E44089ABF5C602A9C4C8DE

The flap costs exactly one sample: 404 at 14:37:55, 200 again at 14:37:56, with the serial reverting to the legacy cert. The 404s from 14:40:57 onward are a separate event — I re-enabled allowedListeners to set up T8, which re-deadlocked this app per T4. Don't read those against the flap.

Serial legend:

C1245EE285B0F4E22A0AE399D1F3A625  legacy cert  envoy-gateway-system/nebari-landing-nebari-system-tls
081F783E82E44089ABF5C602A9C4C8DE  app-ns cert  nebari-system/nebari-landing-nebari-system-tls
937EAD6B8B51FC293B23438B9B0DA72E  shared https listener  nebari-gateway-tls
9D3927BC03947A51778F061BED5BD426  t4b-newapp app-ns cert

@viniciusdc

Copy link
Copy Markdown
Collaborator Author

Thanks, @oldsj, for the incredible, in-depth review and testing!!!

@viniciusdc

Copy link
Copy Markdown
Collaborator Author

@oldsj this is a genuinely great testing pass. It changed my mind on the mechanism, so let me lay out where I landed.

You're right that the staged gate rests on a false premise. Creating the ListenerSet detaches the hostname's routes the moment it's allowed to attach, and holding the legacy listener is exactly what keeps it stuck on HostnameConflict so it never programs. The "legacy listener keeps serving in the meantime" line in the description is just wrong. Creating the ListenerSet is itself the disruptive act, so the hold is the bug, not a safety net.

I wanted to see the condition semantics directly before rewriting anything, so I upgraded a local k3d NIC bed to EG v1.8.2 and reproduced your T4 with a hand-crafted ListenerSet colliding with an existing legacy listener. The set-level Accepted reason is the tell, and it splits cleanly three ways:

  • allowedListeners unset -> Accepted=False/NotAllowed, no per-listener status at all
  • allowedListeners: All with the legacy listener still holding the hostname -> Accepted=False/ListenersNotValid, per-listener Conflicted=True/HostnameConflict, ResolvedRefs=True
  • legacy listener removed -> Accepted=True/Programmed in under 6s, Conflicted=False/NoConflicts

So the rewrite is a reason-aware remove-first, not a Programmed-gated hold. Remove the legacy listener when the block is our own incumbent (ListenersNotValid / per-listener HostnameConflict), and keep it when it's NotAllowed, because there the gateway genuinely refuses the attachment and pulling the legacy listener just strands the app. That's your T3, and it's the one case where holding is correct. This also lines up exactly with your NotAllowed vs ListenersNotValid coexistence table.

On the user-provided-secret path (T9): agreed, and I'm pulling it back to the legacy shared-Gateway listener until the secret-copy work lands, so it stops creating a ListenerSet that claims a hostname it can't serve. The per-app ListenerSet cutover for secretName waits on #168.

On the dead-defense pushback, you convinced me. A missing/forbidden CRD presenting as 1/1 Running and Healthy while nothing reconciles is worse than a crash. I'm going with your option 1: a startup precondition that fails loud instead of sitting Ready and inert. Tracking that separately from the cutover fix.

One extra thing I hit that isn't in your notes: after a clean cutover EG raises OverlappingTLSConfig=True/OverlappingCertificates because the shared https wildcard listener overlaps SNI with the per-app listener. It's non-blocking (Programmed still True), but I'll make sure the status logic reads it as benign and doesn't collapse it into a failure.

Still an open question for the NIC side: allowedListeners with a namespace Selector vs All. All drops the delegation boundary that was half the argument for Option 2, like you said. I'll take that to nebari-infrastructure-core#496 rather than bake an assumption in here.

I'll push the reworked cutover shortly and ping you when it's ready for another spin.

Rework the per-app ListenerSet cutover (ADR-0011 Option 2) after review and live
testing on Envoy Gateway v1.8.2 showed the Programmed-gated staged hold deadlocks
an in-place migration: a ListenerSet detaches the hostname's routes from the
shared Gateway the moment it is allowed to attach, so holding the legacy listener
both takes the app offline and blocks the ListenerSet on a HostnameConflict it
can never clear.

Replace isListenerSetProgrammed with a reason-aware shouldCutOver that keys off
the status EG actually reports: cut over when the set-level Programmed condition
is True, or when our own listener reports HostnameConflict with refs resolving
(our legacy listener is the only blocker, so remove it first and let the
ListenerSet program); otherwise stay on the legacy listener. NotAllowed keeps the
legacy listener, since removing it on a Gateway that refuses the attachment would
strand the app. A conflict with a peer app's ListenerSet on the same hostname is
indistinguishable by condition and is left to the conflict-handling rework (#168).

Route the user-provided-secret path back onto the legacy shared-Gateway listener
(the secret lives in the Gateway namespace; the app-namespace secret copy is
TODO #168), and delete any ListenerSet a prior cert-manager reconcile left
behind. Tear down the ListenerSet and legacy listener when TLS is disabled, so a
cut-over app that turns TLS off does not keep terminating HTTPS and detaching the
route.

Add unit coverage for the decision matrix and the off-path teardown, and a Serial
e2e spec that drives the real cutover and both transitions against EG v1.8.2.
@github-actions

Copy link
Copy Markdown

Docker Images Built

Images pushed to Quay.io for branch feat-per-app-listenerset:

Image Tag Platforms
Operator quay.io/nebari/nebari-operator:feat-per-app-listenerset linux/amd64 + linux/arm64

Test the operator:

kubectl apply -k https://github.com/nebari-dev/nebari-operator.git/config/default?ref=feat/per-app-listenerset
kubectl set image deployment/nebari-operator-controller-manager manager=quay.io/nebari/nebari-operator:feat-per-app-listenerset -n nebari-operator-system

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(tls): adopt per-app ListenerSet for TLS ownership (ADR-0011, Option 2)

3 participants