@@ -443,15 +443,18 @@ following controls apply:
443443 only. mTLS already authenticates the caller; the `NetworkPolicy` shrinks the
444444 attack surface so unauthorized peers cannot even open a connection to attempt
445445 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).
446+ - **Least-privilege Kubernetes access.** The Extension Server reads the policy
447+ and status it consumes (Traffic Protection and Connector policy and their
448+ status) and holds no write access to any of it; its
449+ [RBAC](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) grants
450+ read-only verbs on exactly those types. Its one write is the re-translation
451+ trigger : ` patch` on `Gateway` (see [Triggering Re-translation on Policy
452+ Change](#triggering-re-translation-on-policy-change)), scoped to that single
453+ resource and verb and used only to set a trigger annotation — it cannot mutate
454+ Gateway spec semantics, and it touches nothing else. This bounds the damage if
455+ the process is compromised. The contrast with NSO's reconcilers, which hold
456+ broad write access, is the reason the two run as separate processes — see
457+ [Deployment Topology](#deployment-topology).
455458- **Hardened pod.** Run as non-root with a read-only root filesystem, all
456459 capabilities dropped, and `allowPrivilegeEscalation : false` — the standard
457460 hardened posture for a control-plane workload.
@@ -544,14 +547,50 @@ carried in `.status.conditions[Ready]`, updated on Lease expiry or renewal; stat
544547updates do not increment the generation. The `resources` trigger therefore does
545548not cover Connector online/offline transitions.
546549
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.
550+ On the edge, the Connector's liveness arrives in the
551+ ` networking.datumapis.com/upstream-status` annotation — Karmada propagates a
552+ member object's metadata, but not its `status` subresource, so the connector's
553+ ` Ready` condition and `connectionDetails` are mirrored into an annotation by the
554+ replicator and the extension server reads its routing decision from there. EG
555+ does not watch this annotation (the `Connector` is registered with a
556+ generation-only predicate via `resources`), so a freshly online connector's
557+ liveness lands in the extension server's cache while the data plane keeps serving
558+ the stale — usually offline — program until some unrelated rebuild happens to
559+ fire.
560+
561+ A **dedicated re-translation controller**, co-located with the extension server
562+ and sharing its informer cache, closes this gap. It watches Connectors and, when
563+ a connector's liveness changes, patches a trigger annotation onto every Gateway
564+ backed by an `HTTPProxy` that references the connector. EG re-translates on
565+ Gateway annotation changes, so the patch forces a fresh `PostTranslateModify`
566+ call, and the extension server re-applies the correct routing config from cache.
567+
568+ Three properties make this safe and cheap :
569+
570+ - **It runs at the edge, against the same cache EG translates against.** This is
571+ the reason the controller is co-located with the extension server rather than
572+ placed in the project-side Connector controller. The annotation is touched only
573+ *after* the new liveness is already in the shared cache, so the re-translation
574+ it provokes is guaranteed to read fresh data — eliminating the cross-cluster
575+ ordering race a project-side trigger would have, where the Gateway touch could
576+ reach the edge before the connector's own status annotation does and re-translate
577+ against a stale cache.
578+ - **It only reconciles on liveness changes.** The watch predicate admits creates
579+ (so connectors already online at startup get stamped) and only those updates
580+ that change the `(online, nodeID)` the extension server keys on; routine
581+ heartbeat churn that does not affect routing is ignored. The annotation value
582+ encodes that same `(online, nodeID)`, so a `connectionDetails` change (e.g. the
583+ tunnel endpoint moves) re-translates too, not only `Ready` flips.
584+ - **The Gateway patch is idempotent.** It is a merge patch with no preceding Get;
585+ an unchanged value is a no-op at the API server (no `resourceVersion` bump, no
586+ EG event), so the controller never provokes a spurious re-translation. A missing
587+ Gateway is ignored — EG translates a Gateway when it is created, reading the
588+ already-fresh cache, so there is nothing to nudge yet.
589+
590+ The connector→Gateway mapping stays local : the Connector, its `HTTPProxy`, and the
591+ Gateway share a namespace, and the Gateway is named after the `HTTPProxy`. The
592+ controller needs `get;patch` on `gateways.gateway.networking.k8s.io` in addition
593+ to the extension server's read-only policy access.
555594
556595The alternative considered was having the replicator write a monotonic nonce into
557596the downstream Connector's `spec` when the `Ready` condition flips, which would
@@ -582,17 +621,27 @@ The Envoy AI Gateway project
582621on all replicas because EG's calls are the bottleneck under load. NSO takes the
583622split one step further : the Extension Server ships as its **own Deployment**,
584623built 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
624+ workload with its own ServiceAccount. This is what gives it the tightly scoped
625+ RBAC, the dedicated `NetworkPolicy`, and the independent horizontal scaling the
626+ rest of this design depends on — none of which it could have as a serving path
627+ inside the leader-elected reconciler process. It is **horizontally scalable and
589628stateless**, sized by EG's call rate rather than by reconcile load.
590629
591630The Extension Server runs in NSO's namespace, owned by NSO, and is reached by
592631Envoy 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.
632+ ` extensionManager.service.fqdn` ). NSO ownership is what makes the RBAC, the
633+ `NetworkPolicy`, and TLS SAN scoping coherent : one team owns the workload, its
634+ identity, and the policy that fronts it.
635+
636+ The re-translation controller (see [Triggering Re-translation on Policy
637+ Change](#triggering-re-translation-on-policy-change)) runs **inside** this
638+ process. It is the one writer in an otherwise read-only workload, and it runs on
639+ every replica without leader election : its only write is an idempotent merge patch
640+ of a trigger annotation, so concurrent replicas converge on the same value and the
641+ redundant patches are no-ops at the API server. Co-locating it here is deliberate
642+ — it must observe the same informer cache the extension server translates against,
643+ which is what removes the cross-cluster ordering race a separate, project-side
644+ trigger would have.
596645
597646# ## High Availability
598647
0 commit comments