MastertheMesh
Solo · agentgateway · versioned routing · JWT · Gateway API · kind
Application Lab · Part 2 · Runs on kind

Versioned Cluster Routing with Solo Enterprise for agentgateway

TO
Tom O'Rourke
EMEA Field CTO · Solo.io

Part 2 of the versioned-routing lab. Same use case, same three kind clusters, same seven scenarios as the kgateway part. The one change is the data plane: this runs on agentgateway, the Rust data plane, so you can see where the CRD surface differs and confirm the behaviour is the same.

Solo Enterprise for agentgateway 2.3 EnterpriseAgentgatewayPolicy CEL transformation · phase: PreRouting AgentgatewayBackend kind

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

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.

RequestServed byWhy
no header, no tokenlatestthe default rule
x-version-override: v2v2explicit client header, highest precedence
client sends x-tenant-version: v2latestthe transformation clears this header before routing
JWT version=v2v2claim projected into the header and re-routed
JWT version=latestlatestclaim routed
JWT version=v2 + x-version-override: latestlatestexplicit override beats the claim
invalid JWT401mode: Optional still rejects a bad token
Same spoof-safe result as kgateway, by a different route. When a client sends 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 CRDEnterpriseKgatewayTrafficPolicyEnterpriseAgentgatewayPolicy
JWT fieldspec.entJWT.beforeExtAuthspec.traffic.jwtAuthentication
tokenless allowedvalidationPolicy: AllowMissingmode: Optional
claim to headerfirst-class claimsToHeadersCEL transformation.set, value: "jwt.version"
runs before routingimplicitexplicit traffic.phase: PreRouting
inline JWKSjwks.local.keyjwks.inline
static backendBackend, 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

Versions

Built and verified on both editions:

OSS
agentgateway (OSS)v1.3.0
Gateway APIv1.5.1
Enterprise
Solo Enterprise for agentgatewayv2.3.4
Gateway APIv1.5.1