Customer question (same as part 1). "We run a public API, one deployment per region. Some of our customers are pinned to an older version of the application, and those versions run on their own separate clusters. We want a gateway in front of all of them that sends each request to the right versioned cluster, and the gateway has to live outside those clusters. How can we route a request to the correct version, whether that version comes from a header the client sends or from something in the caller's identity, and fall back to the latest version when there is nothing to go on?"
Part 1 answered this on kgateway, the Envoy data plane. This part answers the exact same question on agentgateway, the Rust data plane, on the same three clusters. The flow and the seven scenarios are identical. What changes is how you express the policy, and this part is about seeing that difference and confirming the result holds.
What you'll build
edge · kgw-edge
The gateway
agentgateway installed into agentgateway-system, alongside
the part-1 kgateway install in kgateway-system. They
coexist in one cluster.
app · app-latest
latest
The same echo app as part 1, reporting servedBy: latest,
reached on a NodePort.
app · app-v2
v2
The pinned older version, reporting servedBy: v2, reached on
a NodePort.
agentgateway reaches each app cluster as an AgentgatewayBackend
whose static target is the app cluster's node IP and NodePort. The two
enterprise gateways share two auxiliary CRD groups
(extauth.solo.io and ratelimit.solo.io); part 1
already owns them, so the install skips re-creating them. The lab uses neither,
so nothing is lost.
Prerequisites
- The same toolchain as part 1:
kind,kubectl,helm,docker,openssl,jq, and an authenticatedgcloudfor the public chart registry. - A Solo Enterprise agentgateway license key in
AGENTGATEWAY_LICENSE_KEY(export it, or pointSECRETS_FILEat a sourceable file). - Optional: part 1's clusters already up. This lab reuses them; if they are not present it creates them.
Steps
Step 1 — Bring it up
One command ensures the three clusters, installs agentgateway, deploys the apps and wires the routing. It is idempotent.
bash quick.sh up
# This lab needs ONE secret: a Solo Enterprise agentgateway license key.
export AGENTGATEWAY_LICENSE_KEY="your-license-key"
./scripts/quick.sh up
# or keep it in a sourceable file (export AGENTGATEWAY_LICENSE_KEY=...):
SECRETS_FILE=/path/to/secrets.sh ./scripts/quick.sh up
Step 2 — The clusters (reused from part 1)
The three kind cluster configs are the same as part 1. If part 1 is already up, this step reuses the clusters as-is; otherwise it creates them with the Gateway API CRDs on the edge.
Step 3 — Install agentgateway on the edge
Two charts from the public OCI registry. agentgateway chart versions carry a
v prefix. Because part 1's kgateway already owns the shared
extauth / ratelimit CRDs, the CRDs chart installs
with those two toggled off. Creating the Gateway with
gatewayClassName: enterprise-agentgateway auto-provisions the
agentgateway proxy.
bash 02-agentgateway.sh (the helm calls)
REG=oci://us-docker.pkg.dev/solo-public/enterprise-agentgateway/charts
# shared CRDs already present from part 1 -> skip them
helm upgrade --install enterprise-agentgateway-crds \
$REG/enterprise-agentgateway-crds --version v2.3.4 \
--namespace agentgateway-system --create-namespace \
--set installExtAuthCRDs=false --set installRateLimitCRDs=false --wait
helm upgrade --install enterprise-agentgateway \
$REG/enterprise-agentgateway --version v2.3.4 \
--namespace agentgateway-system \
--set licensing.licenseKey="$AGENTGATEWAY_LICENSE_KEY" --wait
yaml yaml/edge/gateway.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: agentgateway-proxy
namespace: agentgateway-system
spec:
gatewayClassName: enterprise-agentgateway
listeners:
- name: http
protocol: HTTP
port: 80
allowedRoutes:
namespaces:
from: All
Step 4 — The versioned echo apps
The same echo app as part 1, deployed into both app clusters with a different
CLUSTER_VERSION and exposed on NodePort 30080. If part 1 already
deployed them, this reuses them.
Step 5 — Wire the Backends, the policy and the route
On agentgateway the static backend is a single host and
port, not a list. The host and port are the app cluster's node IP
and NodePort, filled in at setup.
yaml yaml/edge/backends.yaml
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
name: app-latest
namespace: agentgateway-system
spec:
static:
host: 192.168.97.15 # app-latest node IP
port: 30080 # NodePort
---
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
name: app-v2
namespace: agentgateway-system
spec:
static:
host: 192.168.97.16 # app-v2 node IP
port: 30080
This is the policy that differs most from kgateway. agentgateway has no
claimsToHeaders. Instead, jwtAuthentication validates
the token and a CEL transformation copies the version
claim into x-tenant-version. phase: PreRouting runs
both before route selection, so the HTTPRoute can match the
header. mode: Optional is the agentgateway equivalent of
kgateway's AllowMissing: tokenless requests pass, a bad token is
rejected. The JWKS is inline, so the lab runs offline.
yaml yaml/edge/jwt-policy.yaml
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
name: jwt-version
namespace: agentgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: agentgateway-proxy
traffic:
phase: PreRouting
jwtAuthentication:
mode: Optional
providers:
- issuer: versioned-routing-lab
audiences:
- public-api
jwks:
inline: |
{ "keys": [ { "kty": "RSA", "use": "sig", "alg": "RS256",
"kid": "versioned-routing-key", "n": "...", "e": "AQAB" } ] }
transformation:
request:
set:
- name: x-tenant-version
value: "jwt.version" # CEL: the 'version' claim, no Inja
The route is the same idea as part 1: match the explicit override header
first, then the JWT-derived header, then default to latest. The backend group
is agentgateway.dev / AgentgatewayBackend.
yaml yaml/edge/httproute.yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: versioned-routing
namespace: agentgateway-system
spec:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: agentgateway-proxy
rules:
- matches: # 1. explicit override
- headers:
- name: x-version-override
value: v2
type: Exact
backendRefs:
- group: agentgateway.dev
kind: AgentgatewayBackend
name: app-v2
- matches: # 2. JWT-claim-derived
- headers:
- name: x-tenant-version
value: v2
type: Exact
backendRefs:
- group: agentgateway.dev
kind: AgentgatewayBackend
name: app-v2
- matches: # 3. default to latest
- path:
type: PathPrefix
value: /
backendRefs:
- group: agentgateway.dev
kind: AgentgatewayBackend
name: app-latest
Run the demo
./scripts/quick.sh demo port-forwards the agentgateway proxy and
runs the same seven requests as part 1. Every row is observed against the
running gateway.
| Request | Served by | Why |
|---|---|---|
| no header, no token | latest | the default rule |
x-version-override: v2 | v2 | explicit client header, highest precedence |
client sends x-tenant-version: v2 | latest | the transformation clears this header before routing |
JWT version=v2 | v2 | claim projected into the header and re-routed |
JWT version=latest | latest | claim routed |
JWT version=v2 + x-version-override: latest | latest | explicit override beats the claim |
| invalid JWT | 401 | mode: Optional still rejects a bad token |
x-tenant-version itself, agentgateway's
PreRouting transformation overwrites it before routing, even with no token, so
it lands on latest rather than the client's chosen v2. kgateway gets there
because its JWT filter owns the claim header; agentgateway gets there because
the transformation runs on every request in the PreRouting phase. Either way a
client cannot fake its version, and the explicit override stays on its own
header.
kgateway and agentgateway, side by side
Both data planes produce the identical seven outcomes. The difference is the policy surface.
| kgateway (part 1) | agentgateway (part 2) | |
|---|---|---|
| policy CRD | EnterpriseKgatewayTrafficPolicy | EnterpriseAgentgatewayPolicy |
| JWT field | spec.entJWT.beforeExtAuth | spec.traffic.jwtAuthentication |
| tokenless allowed | validationPolicy: AllowMissing | mode: Optional |
| claim to header | first-class claimsToHeaders | CEL transformation.set, value: "jwt.version" |
| runs before routing | implicit | explicit traffic.phase: PreRouting |
| inline JWKS | jwks.local.key | jwks.inline |
| static backend | Backend, static.hosts[] | AgentgatewayBackend, static.host+port |
Keeping the tenant-to-version map current
The answer is the same as part 1, and it is the point of doing it at the
gateway. Keep the map in the IdP as a claim, keyed off the tenant. A version
rollover is an identity change: update the claim the IdP issues for that
tenant, and the gateway config does not move. Clients with no version claim,
and ops doing a forced test, use the explicit x-version-override
header.
Issuing the version claim from a real IdP — Frontegg
Part 1 shows
how to issue the version claim from Frontegg, a hosted CIAM
provider: you set it as per-user metadata, and Frontegg surfaces it in the
access token under a nested userMetadata claim. That IdP side is
identical here, so a token minted for Alice carries the same shape (sample
host acme.frontegg.com):
json Alice's Frontegg access token (decoded)
{
"sub": "<client-id>",
"email": "alice@acme.io",
"userMetadata": { "version": "v2" },
"tenantId": "acme",
"iss": "https://acme.frontegg.com",
"aud": "<environment-id>"
}
Two things change on agentgateway, because the policy surface is different from kgateway. Both were checked against the running gateway.
1. Read the nested claim with a CEL transformation, not
claimsToHeaders. agentgateway has no
claimsToHeaders; the claim goes into a header through the same CEL
transformation as the lab, and CEL reaches into the nested object
with a dot path. Minting a token with userMetadata.version = v2
and projecting it with value: "jwt.userMetadata.version" routed to
v2 and the backend saw the header. (jwt.userMetadata["version"]
works too.)
yaml EnterpriseAgentgatewayPolicy · read the nested Frontegg claim
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
name: jwt-version
namespace: agentgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: agentgateway-proxy
traffic:
phase: PreRouting
jwtAuthentication:
mode: Optional
providers:
- issuer: versioned-routing-lab
audiences:
- public-api
jwks:
inline: |
{ "keys": [ { "kty": "RSA", "use": "sig", "alg": "RS256",
"kid": "versioned-routing-key", "n": "...", "e": "AQAB" } ] }
transformation:
request:
set:
- name: x-tenant-version
value: "jwt.userMetadata.version" # CEL dot-path into the nested claim
2. Remote JWKS goes through a backendRef, not a bare URL.
kgateway took a jwks.remote.url straight to Frontegg's well-known
endpoint. agentgateway's jwks.remote instead wants a
jwksPath plus a backendRef to something in-cluster,
so the external IdP is modelled as a backend. Represent Frontegg as an
AgentgatewayBackend and point the provider at it. The hop to
Frontegg is HTTPS, configured with backend TLS (system CA) per the
agentgateway backend docs.
yaml yaml/edge/jwt-policy.yaml · Frontegg
apiVersion: agentgateway.dev/v1alpha1
kind: AgentgatewayBackend
metadata:
name: frontegg-idp
namespace: agentgateway-system
spec:
static:
host: acme.frontegg.com
port: 443
---
apiVersion: enterpriseagentgateway.solo.io/v1alpha1
kind: EnterpriseAgentgatewayPolicy
metadata:
name: jwt-version
namespace: agentgateway-system
spec:
targetRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: agentgateway-proxy
traffic:
phase: PreRouting
jwtAuthentication:
mode: Optional
providers:
- issuer: https://acme.frontegg.com
audiences:
- <environment-id> # Frontegg aud = the environment / client id
jwks:
remote:
jwksPath: /.well-known/jwks.json
backendRef:
group: agentgateway.dev
kind: AgentgatewayBackend
name: frontegg-idp
transformation:
request:
set:
- name: x-tenant-version
value: "jwt.userMetadata.version"
The route doesn't change: x-tenant-version is still what it
matches. So the only real edits from the lab's self-signed setup are the CEL
dot-path for the nested claim and swapping the inline JWKS for a Frontegg
backendRef.
Teardown
bash quick.sh teardown
./scripts/quick.sh teardown # remove agentgateway, KEEP the clusters + part 1
./scripts/quick.sh teardown-clusters # delete all three kind clusters (also removes part 1)
See also
- Part 1 — the same lab on Solo Enterprise for kgateway
- Solo Enterprise for agentgateway docs
- Per-team LLM token budgets at the gateway — more EnterpriseAgentgatewayPolicy
Versions
Built and verified on both editions:
v1.3.0v1.5.1v2.3.4v1.5.1