@@ -251,6 +251,22 @@ computed from the snapshot Envoy Gateway passes in plus policy read from
251251Kubernetes (cached; see [ Sourcing
252252Policy] ( #sourcing-policy-how-the-extension-server-knows-what-to-inject ) ).
253253
254+ #### Invariant: mutations to shared resources must be globally unique
255+
256+ Because Envoy Gateway merges all customer gateways into one shared
257+ configuration, several resources the Extension Server mutates are themselves
258+ shared — most notably the HTTP listener's route configuration, whose
259+ virtual-host domains form a single global namespace. Envoy enforces that these
260+ domains are unique and rejects the entire xDS snapshot if any two collide. Any
261+ identifier the Extension Server adds to a shared resource — a virtual-host
262+ domain above all — must therefore be globally unique across the whole fleet, not
263+ merely unique within one connector or gateway. The Connector satisfies this by
264+ deriving a synthetic per-connector domain rather than reusing the backend host
265+ (which is frequently a non-unique value such as ` localhost ` ). This invariant is
266+ load-bearing under the fail-closed delivery posture: a single duplicate value
267+ does not degrade one gateway, it NACKs the snapshot and freezes configuration
268+ delivery for every gateway (see [ High Availability] ( #high-availability ) ).
269+
254270### Extension Mechanism: Which Hook and Why
255271
256272Envoy Gateway's
@@ -443,15 +459,18 @@ following controls apply:
443459 only. mTLS already authenticates the caller; the `NetworkPolicy` shrinks the
444460 attack surface so unauthorized peers cannot even open a connection to attempt
445461 the handshake.
446- - **Least-privilege Kubernetes access.** The Extension Server reads policy and
447- status from Kubernetes but never writes; its ServiceAccount holds
448- **read-only**
449- [RBAC](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) on
450- exactly the resource types it consumes (Traffic Protection and Connector
451- policy and their status), and nothing more. This bounds the damage if the
452- process is compromised. The contrast with NSO's reconcilers, which hold write
453- access, is the reason the two run as separate processes — see [Deployment
454- Topology](#deployment-topology).
462+ - **Least-privilege Kubernetes access.** The Extension Server reads the policy
463+ and status it consumes (Traffic Protection and Connector policy and their
464+ status) and holds no write access to any of it; its
465+ [RBAC](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) grants
466+ read-only verbs on exactly those types. Its one write is the re-translation
467+ trigger : ` patch` on `Gateway` (see [Triggering Re-translation on Policy
468+ Change](#triggering-re-translation-on-policy-change)), scoped to that single
469+ resource and verb and used only to set a trigger annotation — it cannot mutate
470+ Gateway spec semantics, and it touches nothing else. This bounds the damage if
471+ the process is compromised. The contrast with NSO's reconcilers, which hold
472+ broad write access, is the reason the two run as separate processes — see
473+ [Deployment Topology](#deployment-topology).
455474- **Hardened pod.** Run as non-root with a read-only root filesystem, all
456475 capabilities dropped, and `allowPrivilegeEscalation : false` — the standard
457476 hardened posture for a control-plane workload.
@@ -544,14 +563,58 @@ carried in `.status.conditions[Ready]`, updated on Lease expiry or renewal; stat
544563updates do not increment the generation. The `resources` trigger therefore does
545564not cover Connector online/offline transitions.
546565
547- For this case, the Connector controller touches an annotation on each downstream
548- ` Gateway` the Connector serves when the `Ready` condition flips. EG's
549- ` metadataPredicate` for `Gateway` includes `AnnotationChangedPredicate`, so the
550- annotation change fires a full re-translation and the extension server re-applies
551- the correct routing config from its cache. This is the deliberate choice for
552- status-driven transitions : it uses the predicate already in place for Gateway
553- events, requires no new EG configuration, and places trigger logic in the
554- controller that detects the Lease-driven state change.
566+ On the edge, the Connector's liveness arrives in the
567+ ` networking.datumapis.com/upstream-status` annotation — Karmada propagates a
568+ member object's metadata, but not its `status` subresource, so the connector's
569+ ` Ready` condition and `connectionDetails` are mirrored into an annotation by the
570+ replicator and the extension server reads its routing decision from there. EG
571+ does not watch this annotation (the `Connector` is registered with a
572+ generation-only predicate via `resources`), so a freshly online connector's
573+ liveness lands in the extension server's cache while the data plane keeps serving
574+ the stale — usually offline — program until some unrelated rebuild happens to
575+ fire.
576+
577+ A **dedicated re-translation controller**, co-located with the extension server
578+ and sharing its informer cache, closes this gap. It watches Connectors and, when
579+ a connector's liveness changes, patches a trigger annotation onto every Gateway
580+ backed by an `HTTPProxy` that references the connector. EG re-translates on
581+ Gateway annotation changes, so the patch forces a fresh `PostTranslateModify`
582+ call, and the extension server re-applies the correct routing config from cache.
583+
584+ Three properties make this safe and cheap :
585+
586+ - **It runs at the edge, against the same cache EG translates against.** This is
587+ the reason the controller is co-located with the extension server rather than
588+ placed in the project-side Connector controller. The annotation is touched only
589+ *after* the new liveness is already in the shared cache, so the re-translation
590+ it provokes is guaranteed to read fresh data — eliminating the cross-cluster
591+ ordering race a project-side trigger would have, where the Gateway touch could
592+ reach the edge before the connector's own status annotation does and re-translate
593+ against a stale cache.
594+ - **It only reconciles on liveness changes.** The watch predicate admits creates
595+ (so connectors already online at startup get stamped) and only those updates
596+ that change the `(online, nodeID)` the extension server keys on; routine
597+ heartbeat churn that does not affect routing is ignored. The annotation value
598+ encodes that same `(online, nodeID)`, so a `connectionDetails` change (e.g. the
599+ tunnel endpoint moves) re-translates too, not only `Ready` flips.
600+ - **The Gateway patch is idempotent.** It is a merge patch with no preceding Get;
601+ an unchanged value is a no-op at the API server (no `resourceVersion` bump, no
602+ EG event), so the controller never provokes a spurious re-translation. A missing
603+ Gateway is ignored — EG translates a Gateway when it is created, reading the
604+ already-fresh cache, so there is nothing to nudge yet.
605+
606+ The connector→Gateway mapping stays local : the Connector, its `HTTPProxy`, and the
607+ Gateway share a namespace, and the Gateway is named after the `HTTPProxy`. The
608+ controller needs `get;patch` on `gateways.gateway.networking.k8s.io` in addition
609+ to the extension server's read-only policy access.
610+
611+ One coverage edge remains : the controller watches Connectors, but the
612+ connector→Gateway association is resolved through the `HTTPProxy`. A change that
613+ newly links an existing Gateway to an already-online Connector — an `HTTPProxy`
614+ created or repointed after the Connector's liveness has settled — produces no
615+ Connector event, so the Gateway is not stamped until an unrelated rebuild fires.
616+ Closing this fully requires the controller to additionally watch `HTTPProxy` and
617+ map back to the affected Gateway.
555618
556619The alternative considered was having the replicator write a monotonic nonce into
557620the downstream Connector's `spec` when the `Ready` condition flips, which would
@@ -582,17 +645,27 @@ The Envoy AI Gateway project
582645on all replicas because EG's calls are the bottleneck under load. NSO takes the
583646split one step further : the Extension Server ships as its **own Deployment**,
584647built from the same Go module and image as NSO but running as a distinct
585- workload with its own ServiceAccount. This is what gives it the read-only RBAC,
586- the dedicated `NetworkPolicy`, and the independent horizontal scaling the rest
587- of this design depends on — none of which it could have as a serving path inside
588- the leader-elected reconciler process. It is **horizontally scalable and
648+ workload with its own ServiceAccount. This is what gives it the tightly scoped
649+ RBAC, the dedicated `NetworkPolicy`, and the independent horizontal scaling the
650+ rest of this design depends on — none of which it could have as a serving path
651+ inside the leader-elected reconciler process. It is **horizontally scalable and
589652stateless**, sized by EG's call rate rather than by reconcile load.
590653
591654The Extension Server runs in NSO's namespace, owned by NSO, and is reached by
592655Envoy Gateway at a stable in-cluster FQDN (the
593- ` extensionManager.service.fqdn` ). NSO ownership is what makes the read-only RBAC,
594- the `NetworkPolicy`, and TLS SAN scoping coherent : one team owns the workload,
595- its identity, and the policy that fronts it.
656+ ` extensionManager.service.fqdn` ). NSO ownership is what makes the RBAC, the
657+ `NetworkPolicy`, and TLS SAN scoping coherent : one team owns the workload, its
658+ identity, and the policy that fronts it.
659+
660+ The re-translation controller (see [Triggering Re-translation on Policy
661+ Change](#triggering-re-translation-on-policy-change)) runs **inside** this
662+ process. It is the one writer in an otherwise read-only workload, and it runs on
663+ every replica without leader election : its only write is an idempotent merge patch
664+ of a trigger annotation, so concurrent replicas converge on the same value and the
665+ redundant patches are no-ops at the API server. Co-locating it here is deliberate
666+ — it must observe the same informer cache the extension server translates against,
667+ which is what removes the cross-cluster ordering race a separate, project-side
668+ trigger would have.
596669
597670# ## High Availability
598671
@@ -622,10 +695,30 @@ Envoy Gateway is configured with:
622695 Server recovers, which is exactly why the two-replica, probe-gated,
623696 retry-backed posture above is mandatory rather than optional.
624697
698+ Fail-closed protects against the hook *erroring*, but not against the hook
699+ returning a response Envoy Gateway accepts and pushes yet Envoy itself then
700+ rejects (for example, a malformed or colliding resource — see the uniqueness
701+ invariant above). Envoy applies each xDS snapshot atomically, so one rejected
702+ resource discards the whole update and freezes configuration for every gateway
703+ at once. This failure is invisible to Kubernetes state : Gateway and Route status
704+ stay `Programmed=True` because translation succeeded. The only signals are
705+ Envoy's `*.update_rejected` counters (LDS/RDS/CDS) and Envoy Gateway's
706+ translation-error logs. Alerting on these xDS rejection metrics — RDS and CDS
707+ rejections as well as LDS — is required; without it a fleet-wide config freeze is
708+ silent.
709+
625710Latency must be monitored : per-build hook latency and error rate are
626711platform-health metrics. Policy reads stay off the synchronous build path via
627712the informer cache described above.
628713
714+ Operability note : because the Extension Server embeds controller-runtime (the
715+ policy cache and the re-translation controller), it must install a logger at
716+ process startup. controller-runtime suppresses all of its own and its
717+ controllers' log output until a logger is set, so an Extension Server that skips
718+ this step runs without controller or hook logs — removing the second of the only
719+ two diagnostic signals (logs and xDS metrics) precisely when an incident needs
720+ them.
721+
629722# ## Reference Implementation
630723
631724A working prototype of the Extension Server has been built and validated
@@ -848,6 +941,13 @@ configuration updates simultaneously; an EPP misconfiguration affects only one
848941gateway's EPP object. This is the primary operational tradeoff (see
849942[High Availability](#high-availability) for mitigations).
850943
944+ **Fleet-wide config freeze is invisible to Kubernetes status.** A single
945+ malformed or colliding resource in a shared config makes Envoy reject the whole
946+ atomic snapshot, freezing updates for every gateway while Gateway/Route status
947+ still reads `Programmed=True`. Detection depends on Envoy xDS-rejection metrics
948+ and EG translation logs rather than Kubernetes conditions (see
949+ [High Availability](#high-availability)).
950+
851951**Coupling to EG's extension hook API.** A supported, upstream-recommended
852952mechanism, but an additional API surface to track across EG version upgrades.
853953
0 commit comments