Skip to content

Commit 7caeee7

Browse files
scotwellsclaude
andcommitted
docs(extension-server): document edge re-translation for connector liveness
Update the Envoy Gateway extension server enhancement to reflect that Connector online/offline transitions are now driven by an edge-local re-translation controller co-located with the extension server, rather than a project-side Connector controller touching the downstream Gateway. - Rewrite "Connector online/offline transitions" to describe the cache-sharing edge controller and why running it at the edge removes the cross-cluster ordering race the project-side trigger had. - Record the rejected project-side trigger alongside the existing rejected spec-nonce alternative. - Correct the security/topology sections: the workload is no longer strictly read-only — it holds a single narrow `patch` on Gateway for the trigger annotation, and the embedded controller runs on every replica safely via an idempotent merge patch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2f46ca8 commit 7caeee7

1 file changed

Lines changed: 83 additions & 24 deletions

File tree

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

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

Lines changed: 83 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -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
544547
updates do not increment the generation. The `resources` trigger therefore does
545548
not 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

556595
The alternative considered was having the replicator write a monotonic nonce into
557596
the downstream Connector's `spec` when the `Ready` condition flips, which would
@@ -564,6 +603,16 @@ loop. With modifications to preserve the nonce the loop can be bounded, but the
564603
required changes introduce an API anti-pattern (a platform bookkeeping counter in
565604
user-facing `spec`) and distribute EG trigger semantics across the wrong layer.
566605

606+
An earlier approach placed the trigger in the **project-side** Connector
607+
controller — touching the downstream Gateway annotation when the upstream `Ready`
608+
condition flipped. It was rejected for the ordering race named above: the Gateway
609+
annotation and the connector's status annotation propagate to the edge
610+
independently through Karmada, so the Gateway touch could arrive first and drive a
611+
re-translation that read stale liveness from the edge cache, leaving the data
612+
plane wedged on the old program with nothing to correct it. Detecting liveness at
613+
the edge, where the trigger and the cache it depends on are the same view, is what
614+
removes the race.
615+
567616
### Deployment Topology
568617

569618
The Extension Server runs as a **distinct process from NSO's reconcilers**. The
@@ -582,17 +631,27 @@ The Envoy AI Gateway project
582631
on all replicas because EG's calls are the bottleneck under load. NSO takes the
583632
split one step further: the Extension Server ships as its **own Deployment**,
584633
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
634+
workload with its own ServiceAccount. This is what gives it the tightly scoped
635+
RBAC, the dedicated `NetworkPolicy`, and the independent horizontal scaling the
636+
rest of this design depends on — none of which it could have as a serving path
637+
inside the leader-elected reconciler process. It is **horizontally scalable and
589638
stateless**, sized by EG's call rate rather than by reconcile load.
590639

591640
The Extension Server runs in NSO's namespace, owned by NSO, and is reached by
592641
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.
642+
`extensionManager.service.fqdn`). NSO ownership is what makes the RBAC, the
643+
`NetworkPolicy`, and TLS SAN scoping coherent: one team owns the workload, its
644+
identity, and the policy that fronts it.
645+
646+
The re-translation controller (see [Triggering Re-translation on Policy
647+
Change](#triggering-re-translation-on-policy-change)) runs **inside** this
648+
process. It is the one writer in an otherwise read-only workload, and it runs on
649+
every replica without leader election: its only write is an idempotent merge patch
650+
of a trigger annotation, so concurrent replicas converge on the same value and the
651+
redundant patches are no-ops at the API server. Co-locating it here is deliberate
652+
— it must observe the same informer cache the extension server translates against,
653+
which is what removes the cross-cluster ordering race a separate, project-side
654+
trigger would have.
596655

597656
### High Availability
598657

0 commit comments

Comments
 (0)