Skip to content

Commit 22275a0

Browse files
authored
Merge pull request #213 from datum-cloud/docs/extension-server-edge-retranslation
docs(extension-server): document edge re-translation for connector liveness
2 parents 012e769 + fb0a3ff commit 22275a0

1 file changed

Lines changed: 124 additions & 24 deletions

File tree

  • docs/enhancements/envoy-gateway-extension-server

docs/enhancements/envoy-gateway-extension-server/README.md

Lines changed: 124 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,22 @@ computed from the snapshot Envoy Gateway passes in plus policy read from
251251
Kubernetes (cached; see [Sourcing
252252
Policy](#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

256272
Envoy 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
544563
updates do not increment the generation. The `resources` trigger therefore does
545564
not 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

556619
The alternative considered was having the replicator write a monotonic nonce into
557620
the downstream Connector's `spec` when the `Ready` condition flips, which would
@@ -582,17 +645,27 @@ The Envoy AI Gateway project
582645
on all replicas because EG's calls are the bottleneck under load. NSO takes the
583646
split one step further: the Extension Server ships as its **own Deployment**,
584647
built 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
589652
stateless**, sized by EG's call rate rather than by reconcile load.
590653

591654
The Extension Server runs in NSO's namespace, owned by NSO, and is reached by
592655
Envoy 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+
625710
Latency must be monitored: per-build hook latency and error rate are
626711
platform-health metrics. Policy reads stay off the synchronous build path via
627712
the 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

631724
A working prototype of the Extension Server has been built and validated
@@ -848,6 +941,13 @@ configuration updates simultaneously; an EPP misconfiguration affects only one
848941
gateway'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
852952
mechanism, but an additional API surface to track across EG version upgrades.
853953

0 commit comments

Comments
 (0)