MastertheMesh
ASM · Cloud Service Mesh · GKE · Solo Enterprise Istio · ambient · waypoint · ztunnel · Vault
Live · Runs on GKE

Anthos Service Mesh to Enterprise Istio Ambient, on GKE

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

You run Google Anthos Service Mesh, you want ambient, and ASM will not take you there: the control plane is Google's, the Istio version is Google's, and an in-place upgrade is not on offer. So you do not upgrade it. You stand up a new GKE cluster running Solo Enterprise Istio, bridge the two with native Istio primitives while the migration is in flight, and move namespaces across one at a time with a load generator watching every cut. This lab is that migration on real GKE, including the Vault backed CA most estates actually have, the GKE platform requirements that decide whether the install works first time, and the step ordering that decides whether the L7 cutover is clean or an outage.

ASM 1.20.8 source Solo Istio 1.30.3-solo GKE 1.34 Enterprise agentgateway Vault + istio-csr measured under load

The story: an estate runs its mesh on Google Anthos Service Mesh, managed, with the control plane off-cluster. It works, but the control plane is not theirs. The Istio version is chosen for them, the CRD versions the API server serves are older than a current Istio expects, and an in-place upgrade to ambient is not on the table. The way out is not an upgrade at all: build a new cluster with a mesh you control, keep ASM running untouched, and drain across.

On the name. Google has rebranded Anthos Service Mesh as Cloud Service Mesh. Everything in the cluster still says ASM, which is what this lab uses: the revision is asm-managed, the config map is env-asm-managed, and the tag on these clusters is 1.20.8-asm.88. Read ASM and Cloud Service Mesh as the same product throughout.

That is a longer road than a label flip, and it has more places to trip. This lab walks all of it: the target cluster, the sidecar-first install, the ingress, the certificate authority, the ambient switch, the per-namespace migrations, and the bridge that lets a service in the new cluster call a service in the old one while both are live. Every number below came off a real run on GKE.

In ambient there is no sidecar. ztunnel does L4 for every enrolled pod, meaning mTLS, L4 authorization and TCP telemetry. Everything the sidecar did at L7 moves to a waypoint. A namespace with only L4 concerns needs no waypoint. A namespace with any L7 policy needs one before it is enrolled. That single decision drives the whole migration, and getting its ordering wrong is the most expensive mistake in this lab.

How to use this guide. Everything up to Run the migration is background: the shape of the environment, why each piece is there, and the YAML explained. When you want to do it, go to Run the migration and work down the numbered green STEP blocks. Amber notes and collapsed YAML are there to read, not to run.
STEP — run this amber note — read only (FYI)

Why this is a migration and not an upgrade

Two things make this urgent, and both come from Google's own published positions.

The version underneath is already off the supported list. The clusters in this lab report 1.20.8-asm.88. Google's version page now lists 1.27, 1.28 and 1.29 as the supported in-cluster lines, most recently 1.29.5-asm.12 released on 15 July 2026, and puts the whole 1.20 line under unsupported in-cluster versions. Anything on 1.20 is several lines behind the floor, and Google sets the pace at which that gap widens.

The provisioning path many estates were onboarded through is gone. Provisioning managed Cloud Service Mesh with asmcli on Google Cloud clusters was deprecated on 22 August 2024 and support for it ended in February 2025. Existing deployments were not switched off, and clusters outside Google Cloud carry on with asmcli as before, but the onboarding route is closed and the supported paths are gcloud and the console.

Add the thing this lab is actually about. ASM does not offer you ambient, the control plane that decides your Istio version belongs to Google, and its API server already serves older CRD versions than a current Istio expects, which you can see in the table further down. There is no in-place step from here to ambient. So the move is sideways: a new cluster, a mesh you own, and a drain.

Sources: Cloud Service Mesh versions, Provision managed Cloud Service Mesh with asmcli. Both read on 10 August 2026.

Two moves, not one

This is the thing to get straight before any of the commands make sense, because a migration off ASM is two separate changes and people run them together and then cannot tell which one broke.

The moveWhat changesWhere it happens
1. Change cluster
ASM cluster to the new cluster
The workload is deployed on the new cluster and its callers are repointed. The ASM copy is drained and scaled to zero. The mesh is still sidecars at this point if you want it to be. Deploy on the new cluster, repoint callers, drain the old. The east-west bridge only exists to cover the window where a service has moved and some callers have not.
2. Change dataplane
sidecar to ambient
The namespace swaps istio-injection for istio.io/dataplane-mode=ambient, the pods roll without sidecars, and any L7 policy moves to a waypoint. Entirely inside one cluster. Nothing outside it is aware this happened.

They are independent, and you can run them in either order. This lab does cluster first, dataplane second: the workloads are deployed onto the new cluster in sidecar mode, proven there against the same tests they pass on ASM, and only then migrated to ambient. That order is deliberate. If you flip the dataplane on a cluster where the app has never run, a failure has two possible causes and you get to guess which. Doing it this way, each step has one variable.

It also means the two clusters have very different workloads. Steps 1 to 11 below all happen on the new cluster. The ASM cluster is touched in exactly one step, and only if you need the bridge.

The environment

Two clusters already run ASM and are never modified at the control-plane level: no version change, no mesh-wide policy change, no fleet reconfiguration. Everything this lab builds lands on new clusters. That boundary keeps the exercise repeatable, and it is the boundary you want on a real migration too: experiments stay off the clusters carrying production traffic.

The environment: ASM never changes ASM control plane, Google-managed off-cluster, revision asm-managed, tag 1.20.8-asm.88 ASM cluster 1 sidecar mesh CRDs at v1beta1 ASM cluster 2 sidecar mesh CRDs at v1beta1 target cluster (new GKE) Solo Enterprise Istio 1.30.3-solo ambient: ztunnel + waypoint Enterprise agentgateway ingress its own control plane, CRDs at v1 not registered into the fleet east-west internal LB What crosses the boundary One route per exposed service, one ServiceEntry per call. Each side uses the gateway it already runs. No remote secret, no watch on the other cluster's Kubernetes API, so no CRD-version dependency. Internal LoadBalancer, not external: same-VPC traffic to an external L4 LB can silently time out.

There is a fourth cluster in this lab that is not in the diagram, because it is not part of the architecture: a plain community Istio cluster, identical in every other respect, used purely as a control. The migration runs twice, once on each, and the two results are compared at the end. That comparison is what settles the mixed-fleet interop question.

Do not register the new cluster into the fleet

Check the fleet's default member config before you create anything:

bashthe trap worth five minutes of checking
gcloud container fleet mesh describe --project "$PROJECT"

If fleetDefaultMemberConfig.mesh.management reads MANAGEMENT_AUTOMATIC, then registering the new cluster into that fleet makes Google start managing a mesh on it too. You would install Solo Istio onto a cluster that Google is simultaneously handing an ASM install, which is the exact CRD collision the whole migration exists to escape, self-inflicted on the new cluster instead of inherited on the old one. Simplest answer: do not register the target cluster into the fleet at all. Fleet registration is opt-in later if you specifically want fleet-scoped observability.

What is configured where

Start with what you can skip. If you can move a service and everything that calls it in the same window, you need nothing on the ASM side at all. Move the namespace, repoint its callers, done. The east-west bridge below exists for one situation: a service has moved and some of its callers have not, so a call has to cross while both clusters are live. Plenty of estates have that situation and some do not, so treat the whole left-hand column as conditional.

When you do need it, the ASM side gets one namespace holding an east-west gateway and one ServiceEntry per crossing call. No remote secret, no injection change, no mesh-wide policy edit, nothing that touches a running sidecar, and nothing at all at the control-plane level.

Every resource, per cluster Existing ASM cluster Never modified. No version, policy or fleet change. Google-managed control plane, revision asm-managed existing ingress, PeerAuthentication STRICT, all sidecars OPTIONAL. Only if calls must cross while both clusters are live istio-gateways (new namespace) Deployment istio-eastwestgateway (istio/gateway chart) Service LoadBalancer + load-balancer-type: Internal Gateway crosscluster-http-gateway :8080 HTTP VirtualService to hop.hop-a.svc.cluster.local each app namespace that calls the new cluster ServiceEntry catalog.crosscluster.svc to internal LB pod annotation proxy.istio.io/config (DNS capture) apiVersion rewritten to v1beta1 / v1alpha3 so it applies no remote secret, no istioctl create-remote-secret New GKE cluster, all of it new istio-system the mesh base + istiod 1.30.3-solo profile=ambient istio-cni + ztunnel global.platform=gke agentgateway-system east-west, same gateway Gateway agw-eastwest class enterprise-agentgateway infrastructure.annotations load-balancer-type: Internal HTTPRoute catalog.crosscluster.svc to Service catalog no second gateway stack, no istio-gateways namespace agentgateway-system north-south ingress enterprise-agentgateway + CRDs v2026.5.1 Gateway agw-ingress to external LoadBalancer ns label istio.io/dataplane-mode=ambient PeerAuthentication PERMISSIVE (required, see below) cert-manager / vault the mesh CA cert-manager v1.21.1 + istio-csr v0.16.0 Issuer istio-ca to Vault pki_int/sign/istio-ca petstore / petstore-data / petstore-legacy ns labels istio.io/dataplane-mode + use-waypoint Gateway waypoint gatewayClassName: istio-waypoint AuthorizationPolicy targetRefs to Service catalog (L7) AuthorizationPolicy selector on redis (L4, unchanged) ServiceEntry hop-a.crosscluster.svc to ASM internal LB HTTPRoute petstore.example.com to Service catalog Service label istio.io/ingress-use-waypoint=true HTTP VPC only clients

Two load balancers, doing different jobs. The east-west pair are internal LoadBalancers, one per cluster, reachable only inside the VPC, carrying migration-window traffic between the two meshes. The north-south ingress on the new cluster is a separate, external LoadBalancer, and it is what you eventually point real client traffic at. Keep them apart. Putting east-west traffic on an external LB is the hairpin problem described above, and putting client traffic on the internal one means nobody outside the VPC can reach you.

Why an Istio gateway on the ASM side and agentgateway on the new one

The two sides get different gateways, and not for consistency's sake. On the new cluster there is no reason to run a second gateway stack: Enterprise agentgateway is already installed as the ingress, so the east-west listener is just another Gateway on the same enterprise-agentgateway class, with the internal-LB annotation set through Gateway API's own spec.infrastructure.annotations and an HTTPRoute per exposed service. One gateway product, one control plane, two listeners.

On the ASM cluster it has to be an Istio gateway, for two reasons. First, that cluster has no Gateway API CRDs at all: kubectl get gatewayclass answers the server doesn't have a resource type "gatewayclass", so a Gateway-API gateway cannot even be expressed there without installing CRDs and a controller. Second, and more to the point, an Istio gateway is programmed by the control plane that is already running. Its logs read connected to upstream XDS server: meshconfig.googleapis.com:443, which is ASM's own managed control plane configuring it. Nothing new gets installed into the cluster you promised not to touch, and that is also precisely why the bridge needs no remote secret: the gateway talks to its own local control plane, exactly as every sidecar on that cluster already does.

What GKE requires

Three of the things that cost time on the first pass are decided by GKE, not by Istio. Each one fails in a way that points at the wrong culprit, so they are worth having in front of you before you start.

  1. The Gateway API CRDs are a separate install, and they go first. Waypoints are Gateway API Gateways and the Enterprise agentgateway ingress is a GatewayClass, so both fail without them. GKE does not ship them by default in this configuration. Install gateway-api v1.5.1 standard-install.yaml before the first Helm chart, on every cluster. Make it a fixed first step.
  2. global.platform=gke on the cni and ztunnel charts. Without it the CNI install fails with insufficient quota to match these scopes: [{PriorityClass In [system-node-critical …]}], and there is no ResourceQuota object anywhere that explains it, because it is GKE itself restricting those priority classes to its own managed namespaces. Moving the chart into kube-system gets past that and straight into a second failure, open /host/opt/cni/bin/istio-cni.tmp…: read-only file system, because the default CNI binary path does not match Container-Optimized OS. The flag fixes both at once: it enables the chart's bundled GKE platform profile, which creates the quota GKE wants and detects the right CNI path, and it lets the chart stay in istio-system where you wanted it.
  3. East-west traffic wants an internal LoadBalancer. A pod calling another cluster's external LoadBalancer IP from inside the same VPC can silently time out: GCP's legacy target-pool Network Load Balancers have a hairpin limitation for exactly that shape of traffic. Internal LBs do not, and for traffic that should never leave the VPC they are the right choice anyway. Set the annotation on the first apply. Annotating an already-provisioning external LB afterwards does not cleanly switch it, because the finalizer stays with the external-LB controller, and you end up deleting and recreating the Service.

Where ASM's older CRD versions bite

The ASM control plane in this environment serves Istio 1.20.8. Ask its API server what it actually serves and the picture is uneven:

API groupVersions served on the ASM clusterConsequence
networking.istio.io
VirtualService, DestinationRule, ServiceEntry, Gateway, Sidecar, WorkloadEntry
v1alpha3, v1beta1. No v1. Manifests written for a current Istio fail to apply until the apiVersion is rewritten.
security.istio.io
AuthorizationPolicy
v1 is served. Works unchanged. The groups do not all lag equally, so check each one.
security.istio.io
PeerAuthentication, RequestAuthentication
v1beta1. Applies fail, and so does istioctl x describe pod, which assumes v1.
telemetry.istio.io, extensions.istio.io v1alpha1. Same rewrite needed for Telemetry and WasmPlugin resources.

This is also why the new cluster's control plane must not be pointed at the ASM cluster's Kubernetes API. A remote secret makes istiod watch the other cluster's registry, and on this skew that watch fails outright: failed to list *v1.WorkloadEntry: the server could not find the requested resource. Worse, while a broken remote secret is installed, istiod's readiness probe fails and stays failed. A single bad remote cluster takes the whole control plane's readiness down, not just discovery for that one cluster. Check what your API server serves before you write a manifest:

bashwhat does this cluster actually serve
for c in virtualservices destinationrules serviceentries peerauthentications authorizationpolicies; do
  printf '%-24s %s\n' "$c" \
    "$(kubectl get crd ${c}.networking.istio.io ${c}.security.istio.io \
        -o jsonpath='{.items[*].spec.versions[*].name}' 2>/dev/null)"
done

The order that matters

Note. A namespace with an L7 AuthorizationPolicy needs a waypoint before it is enrolled, and the old selector-based policy has to be converted to a targetRefs policy that the waypoint enforces. The conversion itself is mechanical:

yamlselector to targetRefs, the whole transform
# before, enforced by the pod's own sidecar
selector:
  matchLabels: { app: catalog }

# after, enforced on the waypoint
targetRefs:
  - kind: Service
    group: ""
    name: catalog

The timing of the delete is the part that catches people. The instinct, and what most runbooks say, is to delete it last so the rule is never absent. Put a continuous load generator across the whole sequence instead of sampling after each step and that instinct turns out to be wrong, expensively:

OrderResult across the cutover, 25 qps, 4 connections
Delete the old selector policy last, after the pods are ambientCode 200 : 7341 (97.9 %), Code 503 : 159 (2.1 %). A 6.5 second window of total failure, ending at the instant the policy is deleted. 163 sockets used where 4 would be perfect.
Delete the old selector policy before the namespace is enrolledCode 200 : 7500 (100.0 %), zero error cases, 4 sockets. And DELETE still returns 403 at every single gate.

The reason is that a selector-based policy attaches to the workload, so once the workload is ambient it is ztunnel that has to evaluate it. ztunnel is L4. It cannot evaluate an HTTP method rule, so it fails closed, and the waypoint in front returns 503 URX,UC upstream_reset_before_response_started{connection_termination} for every request until you get to the delete. I confirmed it in isolation instead of inferring it from the migration: with the workload already fully ambient and stable at 100%, re-applying that one selector policy produced 995 errors out of 2248 requests in the 42 seconds it was present, and removing it restored 100% immediately. A plain rolling restart of the same pods with no policy change at all scored 3748 (100.0 %), so the pod roll is not the cause.

The fix is to move one step earlier. The waypoint is already enforcing the converted policy from the moment it is programmed and the namespace carries istio.io/use-waypoint, so deleting the old one before enrolment leaves no gap on the Solo distribution. The waypoint's own access log shows it serving requests ten seconds before the first ambient pod existed, while every catalog pod still had a sidecar, and DELETE returned 403 throughout.

Waypoint-first: the old policy goes before enrolment operator Kubernetes API waypoint ztunnel 1. apply the waypoint Gateway 2. provision, wait for Programmed: True 3. apply targetRefs policy 4. label use-waypoint 5. delete the old selector policy 6. label dataplane-mode=ambient 7. roll the pods, sidecars shed, ztunnel takes the L4 path Step 5 sits before step 6 on purpose. Do it after step 7 instead and every request fails until you get there.
On community Istio the same order leaves a real gap. Run these identical steps on upstream Istio and step 5 does leave a hole: the moment the old policy goes, DELETE starts returning 200 to a caller that still has a sidecar, because on community images a sidecar caller does not route through the destination's waypoint. The waypoint's policy is live and correct; that caller just never reaches it. On the Solo distribution the same caller is routed through the waypoint, so the rule applies to it and DELETE stays 403.

What gets deployed, and what each piece is for

NamespaceWhat is in itWhat it proves
petstorecatalog v1 and v2 behind one Service, a DestinationRule with subsets plus a VirtualService canary, and a method-based AuthorizationPolicyThe L7 case. Needs a waypoint before enrolment, and its policy needs converting.
petstore-dataRedis over plain TCP with an identity-based L4 AuthorizationPolicyThe L4 case. No waypoint at all, and the policy does not change.
petstore-legacycheckout, a curl client, and fortio, the load generatorThe estate that never migrates. It is the mixed-fleet caller and the measuring instrument at the same time.
pki-checkOne sidecar pod issued by the Vault-backed CAThe RSA baseline whose certificate must not change while everything else does.
cross-cluster-testA curl client with DNS capture on and a ServiceEntryThe migration-window bridge back to ASM.

Keeping fortio and checkout in a namespace that never migrates is deliberate. The measurement comes from outside whatever is being changed, and there is always a genuine sidecar caller in the fleet to test policy against. Verify that it really is one, with the full pod JSON, and read both container lists:

bashthe only sidecar check worth trusting
kubectl -n petstore-legacy get pod "$POD" -o json | python3 -c "
import json,sys
p=json.load(sys.stdin)
print('containers    :', [c['name'] for c in p['spec']['containers']])
print('initContainers:', [(c['name'], c.get('restartPolicy')) for c in p['spec'].get('initContainers',[])])
for c in p['status'].get('containerStatuses',[]) + p['status'].get('initContainerStatuses',[]):
    print(' ', c['name'], 'ready=', c.get('ready'))
"

On the 1.30.3 line, both the Solo distribution and community Istio inject istio-proxy as a Kubernetes native sidecar: an init container with restartPolicy: Always, whose live status lives in status.initContainerStatuses. A check that only reads spec.containers and status.containerStatuses reports no sidecar at all and sends you chasing an injection bug that is not there. jsonpath range expressions and -o custom-columns both produced that false negative on earlier passes. The full-JSON version above never has.

The mesh CA, if yours is RSA-only

Most estates that have run a mesh for years have an external CA and an RSA policy. Sidecars send RSA CSRs, so nothing has ever had to care. ztunnel only generates ECDSA P-256 keys. The first time a pod is enrolled into ambient, the CA is asked to sign something it has never been asked for, and if the signing role is locked to RSA it says no:

textthe rejection, from the CertificateRequest's own status
Ready = False | Failed | Vault failed to sign certificate: failed to sign certificate by vault:
  Error making API request.
  URL: POST http://vault.vault.svc:8200/v1/pki_int/sign/istio-ca
  Code: 400. Errors:
  * role requires keys of type rsa

The fix is one vault write to set key_type=any on that role, and it pays to be precise with whoever owns the PKI about its scope. It relaxes a single signing path and leaves the rest of the CA alone. ECDSA P-256 is a strength upgrade over RSA-2048. And the RSA workloads keep getting RSA certificates from the same chain, which you can show by capturing a sidecar's leaf certificate serial before and after.

Never set key_type=ec while a sidecar is still running. Nothing fails at the moment you do it. Existing certificates keep working until they expire, so what you have set up is a mesh-wide sidecar outage on a one hour timer. any is the migration posture, and it stays any until the last sidecar is gone.

One more ordering rule in this area, because it cost a run: after pointing an already running istiod at a new CA, restart it immediately. The MutatingWebhookConfiguration's caBundle does not refresh from the Helm upgrade alone, so injection breaks with x509: certificate signed by unknown authority the next time a pod starts. A cold install never hits this, which is exactly why it is missing from most install guides.

Which parts of this apply to you

Not all of it will. Four of the twelve build steps are conditional, and skipping the ones you do not need is the difference between a week and an afternoon.

PartSkip it whenCost if you need it
Vault, cert-manager and istio-csr (step 5, step 8)The mesh CA is istiod's built-in one. Most greenfield clusters, and plenty of mature ones.Two steps, and the signing role has to allow EC before anything is enrolled.
The east-west bridge (step 12)Every service moves together with its callers, so no call crosses while both clusters are live.One gateway per cluster and one ServiceEntry per cluster pair.
The community-Istio control cluster (step 11)Always, unless you specifically want the interop comparison reproduced in your own environment.A second cluster, built once, that runs no production traffic.
Waypoints (step 10)A namespace has no L7 policy or routing. Those namespaces are two labels and a rolling restart.One Gateway per namespace, plus the selector to targetRefs conversion.

The unconditional core is smaller than the page length suggests: build the cluster, install the mesh in sidecar mode, deploy and prove the workloads, turn on ambient, then migrate namespace by namespace. On this run the per-namespace work was the fast part. An L4 namespace took 3 seconds of wall clock to cut over, the full waypoint-first L7 sequence took about 40 seconds of actual operations, and a rollback took 17 seconds. The load windows in the steps below are long because they are there to catch failures, not because the work is slow.

Run the migration

Every step carries a badge saying which cluster it runs on. Almost all of them are the new cluster: steps 1 to 11 build it, migrate it and prove north-south, step 12 is the community-Istio control if you want the comparison, step 13 is the only one that applies anything to the ASM cluster, and step 14 is the rollback you should practise before you need it. If your services and their callers can move together, you never run step 13 at all.

Set these once. Everything below assumes them, and every command should carry an explicit --context so a step never lands on the wrong cluster.

bashenvironment
export PROJECT=your-project-id
export REGION=us-central1
export TARGET=ambient-target          # the Solo Enterprise cluster
export CONTROL=oss-istio-ab           # the community Istio control cluster
export SOURCE=fleet-cluster-1         # the existing ASM cluster
export USE_GKE_GCLOUD_AUTH_PLUGIN=True

# your Solo Istio and agentgateway licence keys
export SOLO_ISTIO_LICENSE_KEY=...
export AGENTGATEWAY_LICENSE_KEY=...
STEP 1 new target cluster

Create the target cluster

A regular GKE cluster on a version inside Istio 1.30's supported range, on the same VPC as the source clusters so the east-west bridge later has somewhere to go, and deliberately not fleet-registered.

bashcreate
gcloud container clusters create "$TARGET" \
  --project "$PROJECT" --region "$REGION" --node-locations "${REGION}-a" \
  --cluster-version 1.34.9-gke.1065000 \
  --machine-type e2-standard-4 --num-nodes 3 \
  --network default --subnetwork default

gcloud container clusters get-credentials "$TARGET" --region "$REGION" --project "$PROJECT"

a RUNNING cluster in about 5 to 10 minutes, and no fleet membership.

STEP 2 new target cluster

Install the Gateway API CRDs, before any Helm chart

Fixed first step. Waypoints and the agentgateway GatewayClass both need these.

bashgateway-api v1.5.1
kubectl apply --server-side -f \
  https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml

kubectl get crd gateways.gateway.networking.k8s.io \
  -o jsonpath='{.metadata.annotations.gateway\.networking\.k8s\.io/bundle-version}{"\n"}'

v1.5.1.

STEP 3 new target cluster

Install Solo Enterprise Istio in sidecar mode

Sidecar first, on purpose. The target cluster has to be able to run the workloads exactly as they run today before anything about the dataplane changes, otherwise a later failure has two possible causes instead of one. No operator is needed for this path.

bashbase + istiod
helm install istio-base oci://us-docker.pkg.dev/soloio-img/istio-helm/base \
  --version 1.30.3-solo -n istio-system --create-namespace --wait

helm install istiod oci://us-docker.pkg.dev/soloio-img/istio-helm/istiod \
  --version 1.30.3-solo -n istio-system \
  --set global.hub=us-docker.pkg.dev/soloio-img/istio \
  --set global.tag=1.30.3-solo \
  --set license.value="$SOLO_ISTIO_LICENSE_KEY" \
  --set meshConfig.accessLogFile=/dev/stdout \
  --wait

kubectl -n istio-system get pods

istiod 1/1 Running.

STEP 4 new target cluster

Install the Enterprise agentgateway ingress

The ingress for the new cluster. Two things that are easy to get wrong: the CRDs chart is separate, and the licence value path is licensing.licenseKey, not a flat licenseKey.

bashagentgateway v2026.5.1
helm install agentgateway-crds \
  oci://us-docker.pkg.dev/solo-public/enterprise-agentgateway/charts/enterprise-agentgateway-crds \
  --version v2026.5.1 -n agentgateway-system --create-namespace --wait

helm install agentgateway \
  oci://us-docker.pkg.dev/solo-public/enterprise-agentgateway/charts/enterprise-agentgateway \
  --version v2026.5.1 -n agentgateway-system \
  --set licensing.licenseKey="$AGENTGATEWAY_LICENSE_KEY" \
  --set clusterName="$TARGET" \
  --wait

kubectl get gatewayclass

an enterprise-agentgateway GatewayClass, and a Gateway you create against it reaching PROGRAMMED: True.

STEP 5 new target cluster

Put the mesh CA on Vault, with an RSA-only signing role

An external CA with an RSA policy, which is what most estates of this age actually run. Vault in dev mode is fine for the lab; the role's shape is what matters.

bashcert-manager, Vault, istio-csr
helm install cert-manager jetstack/cert-manager -n cert-manager \
  --create-namespace --version v1.21.1 --set crds.enabled=true --wait

helm install vault hashicorp/vault -n vault --create-namespace \
  --version 0.34.0 --set "server.dev.enabled=true" --wait

# RSA-4096 root, RSA-4096 intermediate, a signing role locked to RSA-2048,
# and the Kubernetes auth role the cert-manager Issuer uses
./yaml/60-pki/25-vault-bootstrap.sh

# istio-csr's trust anchor is the Vault root certificate
kubectl -n vault exec vault-0 -- sh -c \
  'VAULT_TOKEN=root VAULT_ADDR=http://127.0.0.1:8200 vault read -field=certificate pki/cert/ca' > ca.pem
kubectl -n cert-manager create secret generic istio-root-ca --from-file=ca.pem=ca.pem

kubectl apply -f yaml/60-pki/20-vault-issuer.yaml
helm install cert-manager-istio-csr jetstack/cert-manager-istio-csr \
  -n cert-manager --version v0.16.0 -f yaml/60-pki/10-istio-csr-values.yaml --wait

# point istiod at the new CA, then restart it. The restart is not optional.
helm upgrade istiod oci://us-docker.pkg.dev/soloio-img/istio-helm/istiod \
  --version 1.30.3-solo -n istio-system --reuse-values \
  --set global.caAddress=cert-manager-istio-csr.cert-manager.svc:443 \
  --set pilot.env.ENABLE_CA_SERVER=false --wait
kubectl -n istio-system rollout restart deploy/istiod

a sidecar pod's real leaf certificate showing Issuer: CN=Lab Intermediate CA and Public Key Algorithm: rsaEncryption. Read the certificate, not the config.

bashverify from the leaf, not the config
istioctl -n pki-check proxy-config secret probe -o json \
  | jq -r '.dynamicActiveSecrets[] | select(.name=="default")
           | .secret.tlsCertificate.certificateChain.inlineBytes' \
  | base64 -d | openssl x509 -noout -serial -issuer -text | head -20
STEP 6 new target cluster

Deploy the workloads in sidecar mode and take the baseline

Deploy the catalog, then prove it works before you change anything. A deployment that rolls out successfully has told you nothing about whether the policies do their job.

bashdeploy
kubectl apply -f yaml/20-policies-sidecar/00-peerauth-strict.yaml
kubectl apply -f yaml/10-apps-sidecar/
kubectl apply -f yaml/20-policies-sidecar/

for ns in petstore petstore-data petstore-legacy; do
  kubectl -n $ns wait --for=condition=Available deploy --all --timeout=180s
done
bashthe four gates, run at every step from here on
CHECKOUT=$(kubectl -n petstore-legacy get pod -l app=checkout -o jsonpath='{.items[0].metadata.name}')
FORTIO=$(kubectl -n petstore-legacy get pod -l app=fortio -o jsonpath='{.items[0].metadata.name}')

# 1. L7: allowed method and denied method
kubectl -n petstore-legacy exec $CHECKOUT -c checkout -- \
  curl -s -o /dev/null -w 'GET %{http_code}\n' http://catalog.petstore/
kubectl -n petstore-legacy exec $CHECKOUT -c checkout -- \
  curl -s -o /dev/null -w 'DELETE %{http_code}\n' -X DELETE http://catalog.petstore/

# 2. L4: the allowed principal
kubectl -n petstore logs deploy/data-client -c client --tail=3

# 3. L4 negative: a principal that is not allowed
kubectl -n petstore-legacy exec $FORTIO -c fortio -- \
  fortio load -c 1 -n 1 -timeout 5s -quiet tcp://redis.petstore-data:6379

# 4. the zero-downtime gate
kubectl -n petstore-legacy exec $FORTIO -c fortio -- \
  fortio load -c 4 -qps 25 -t 15s -quiet http://catalog.petstore/

GET 200, DELETE 403, a PONG stream, tcp short read : 1 (100.0 %) with 0 bytes received on the negative, and Code 200 : 372 (100.0 %).

STEP 7 new target cluster

Make the mesh bilingual

Upgrade istiod to the ambient profile and add the two ambient charts. Nothing migrates here. Every pod keeps its sidecar, ztunnel comes up with nothing enrolled, and the load generator should not notice.

bashambient control plane, with the GKE platform flag from the start
helm upgrade istiod oci://us-docker.pkg.dev/soloio-img/istio-helm/istiod \
  --version 1.30.3-solo -n istio-system --reuse-values \
  --set profile=ambient --set istio_cni.enabled=true --wait

helm install istio-cni oci://us-docker.pkg.dev/soloio-img/istio-helm/cni \
  --version 1.30.3-solo -n istio-system \
  --set profile=ambient \
  --set global.hub=us-docker.pkg.dev/soloio-img/istio \
  --set global.tag=1.30.3-solo \
  --set global.platform=gke \
  --wait

helm install ztunnel oci://us-docker.pkg.dev/soloio-img/istio-helm/ztunnel \
  --version 1.30.3-solo -n istio-system \
  --set profile=ambient \
  --set hub=us-docker.pkg.dev/soloio-img/istio \
  --set tag=1.30.3-solo \
  --set global.platform=gke \
  --set env.LOG_FORMAT=json \
  --wait

istio-cni-node and ztunnel Ready on every node on the first attempt, no quota error, no crash loop, and the load generator still at 100%.

STEP 8 new target cluster

Open the signing role to EC before you enrol anything

One vault write, and it has to land before the first pod is enrolled into ambient. Skip it and the first workload you migrate is the one that finds out.

bashthe whole change
./yaml/60-pki/30-vault-role.sh any

key_type reads any. The RSA sidecar in pki-check keeps the certificate it already had: same serial, istio-proxy still at 0 restarts. Nothing else moves, because nothing is enrolled yet.

STEP 9 new target cluster

Migrate the L4-only namespace

petstore-data has no L7 policy, so it needs no waypoint. Two labels and a rolling restart, with load running across the whole thing.

bashthe whole L4 migration
# start the load first, in another terminal
kubectl -n petstore-legacy exec $FORTIO -c fortio -- \
  fortio load -c 4 -qps 25 -t 120s -quiet http://catalog.petstore/

kubectl label ns petstore-data istio.io/dataplane-mode=ambient istio-injection- --overwrite
kubectl -n petstore-data rollout restart deploy/redis
kubectl -n petstore-data rollout status deploy/redis

the redis pod comes back with a single container and no istio-proxy in either container list, the PONG stream never breaks, the non-allowed principal is still refused with tcp short read and 0 bytes, and the load generator reads Code 200 : 3000 (100.0 %) across the cut.

STEP 10 new target cluster

Migrate the L7 namespace, waypoint first, in this order

Seven steps, and the order decides the outcome. Keep load running across all of it and check GET and DELETE between every step, not just at the end. Sampling only at the end is what hid the outage on the previous pass.

bashthe corrected sequence
# 1. the waypoint
kubectl apply -f yaml/30-waypoints/petstore-waypoint.yaml

# 2. never enrol before this returns
kubectl -n petstore wait --for=condition=Programmed gateway/waypoint --timeout=180s

# 3. the converted policy, on the waypoint
kubectl apply -f yaml/40-policies-waypoint/10-catalog-l7-authz-targetref.yaml

# 4. route service traffic through the waypoint
kubectl label ns petstore istio.io/use-waypoint=waypoint --overwrite

# 5. remove the old selector policy BEFORE enrolment, not after
kubectl -n petstore delete authorizationpolicy catalog-get-only

# 6. enrol
kubectl label ns petstore istio.io/dataplane-mode=ambient istio-injection- --overwrite

# 7. shed the sidecars
kubectl -n petstore rollout restart deploy/catalog-v1 deploy/catalog-v2 deploy/data-client

GET 200 and DELETE 403 at every gate including step 5, catalog pods with an empty initContainers list at the end, and Code 200 : 7500 (100.0 %) with zero error cases and 4 sockets across the entire sequence.

STEP 11 new target cluster

Put client traffic through the ingress, and check your policy survived it

Everything so far has been tested from inside the mesh. North-south is where an ambient migration leaves a hole that in-cluster testing cannot see, so test it explicitly before you point real users at this cluster.

bashroute the app through the ingress
kubectl apply -f yaml/70-northsouth/10-catalog-ingress.yaml

IP=$(kubectl -n agentgateway-system get gateway agw-ingress \
       -o jsonpath='{.status.addresses[0].value}')
curl -s -o /dev/null -w 'GET    %{http_code}\n' -H 'Host: petstore.example.com' http://$IP:8080/
curl -s -o /dev/null -w 'DELETE %{http_code}\n' -X DELETE -H 'Host: petstore.example.com' http://$IP:8080/

GET 200 and DELETE 403. If DELETE comes back 200, the istio.io/ingress-use-waypoint label is missing from the Service and every request from outside the cluster is skipping your L7 policy.

Ambient does not put your ingress through the waypoint by default, and nothing warns you. An HTTPRoute sends the gateway straight at the backend pods, which is fine until those pods are ambient behind a waypoint, at which point north-south traffic bypasses the waypoint and its AuthorizationPolicy along with it. Measured here on the same service, one label apart: without istio.io/ingress-use-waypoint, DELETE returned 200 and the waypoint's access log recorded zero requests; with it, DELETE returned 403 and the waypoint logged them. Every in-mesh caller is enforced correctly in both cases, so this is invisible unless you deliberately test from outside. If you also lock the pods down at L4 so only the waypoint's identity may reach them, set this label first, or the ingress starts getting denied.
STEP 12 A/B control cluster

Run the identical migration on community Istio and compare

Same manifests, same order, upstream charts, no licence. The distribution is the only variable.

bashthe A/B
kubectl --context "$CONTROL" -n petstore-legacy exec $CHECKOUT -c checkout -- \
  curl -s -o /dev/null -w 'community  DELETE %{http_code}\n' -X DELETE http://catalog.petstore/
kubectl --context "$TARGET" -n petstore-legacy exec $CHECKOUT -c checkout -- \
  curl -s -o /dev/null -w 'Solo       DELETE %{http_code}\n' -X DELETE http://catalog.petstore/

community DELETE 200 and Solo DELETE 403, from a caller that is genuinely still on a sidecar on both clusters. Check that with the full-JSON sidecar check above before you believe either number.

STEP 13 new target clusterASM cluster

Bridge back to ASM for the migration window

While the migration is in flight, some calls have to cross. Each side exposes what the other needs, and each calling side declares one ServiceEntry per call. The gateway differs per side for the reasons above.

bashnew cluster: reuse the agentgateway that is already there
# the gateway must be in the mesh to reach ambient workloads,
# and needs a PERMISSIVE carve-out because its own inbound is plaintext
kubectl label ns agentgateway-system istio.io/dataplane-mode=ambient --overwrite

# Gateway + HTTPRoute + PeerAuthentication, all three in one file
kubectl apply -f yaml/50-crosscluster/40-agentgateway-eastwest.yaml

kubectl -n agentgateway-system get svc agw-eastwest \
  -o jsonpath='{.status.loadBalancer.ingress[0].ip}{"\n"}'
bashASM cluster: Istio's own gateway chart, programmed by ASM's control plane
helm install istio-eastwestgateway istio/gateway -n istio-gateways --create-namespace \
  -f yaml/50-crosscluster/10-eastwest-gateway-values.yaml --wait

kubectl apply -f yaml/50-crosscluster/20-exposing-side.yaml
kubectl -n istio-gateways get svc istio-eastwestgateway \
  -o jsonpath='{.status.loadBalancer.ingress[0].ip}{"\n"}'
basheach calling side: one ServiceEntry per cluster pair
# hosts is a list, so this is ONE object per cluster pair, not one per call.
# Adding a service to the bridge later is a one-line edit here plus an
# HTTPRoute on the exposing side.
kubectl apply -f yaml/50-crosscluster/30-calling-side.yaml

Do not try to shorten that to a wildcard. hosts: ["*.crosscluster.svc"] with resolution: STATIC does not resolve at all under DNS auto-allocation, and curl exits 6 with no useful error. Listing the hostnames also gives you a better failure mode: a name you have declared but not yet routed on the far side returns a 404 from the gateway rather than hanging.

ten consecutive clean calls in each direction, including a call from the ASM cluster that reaches a service on the new cluster which has already migrated to ambient behind its waypoint.

Enrolling a gateway namespace into ambient under mesh-wide STRICT takes the gateway down. Both halves of this bit me in order, and both fail somewhere other than where you changed something. Leave the gateway namespace out of the mesh and the gateway sends plaintext straight to the backend pod IPs, so ztunnel refuses it: the gateway reports 503 upstream call failed: SendRequest: connection error: Connection reset by peer (os error 104) and ztunnel logs explicitly denied by: istio-system/istio_converted_static_strict. Label the namespace ambient to fix that, and the gateway's own inbound is now subject to STRICT as well. Traffic from a LoadBalancer is plaintext by definition, so the north-south ingress stops answering completely and curl returns 000. Both are fixed by one PeerAuthentication: PERMISSIVE scoped to the gateway namespace, which is the standard carve-out for any ingress under STRICT and changes nothing east of the gateway, where it re-originates over HBONE under its own identity.
Traffic arriving over the bridge does not get your in-mesh canary. In-cluster, the catalog VirtualService weights 100% to subset v1 and that is exactly what happens: 20 calls, 20 v1 responses, on both distributions. The same service reached across the bridge came back 8 v1 and 12 v2, because the east-west gateway's own VirtualService is the routing authority for that path and it routes straight to the Service. If a canary weight matters during the migration window, replicate it on the gateway's VirtualService as well, or cross-cluster callers quietly get the unweighted split.
STEP 14 new target cluster

Roll a namespace back to sidecars

Practise this before you need it. The order is the forward sequence reversed, and it matters for the same reason: the workload must leave ambient before the selector-based L7 policy comes back, or ztunnel is holding an L7 rule it cannot evaluate.

bashrollback, in order
# 1. injection back on, out of ambient. Leave use-waypoint and the
#    targetRefs policy in place so enforcement never lapses.
kubectl label ns petstore istio-injection=enabled istio.io/dataplane-mode- --overwrite

# 2. roll the pods, sidecars come back
kubectl -n petstore rollout restart deploy/catalog-v1 deploy/catalog-v2 deploy/data-client

# 3. now the selector policy can safely return
kubectl apply -f yaml/20-policies-sidecar/30-l7-authz-catalog.yaml

# 4. and only now remove the waypoint
kubectl label ns petstore istio.io/use-waypoint-
kubectl -n petstore delete authorizationpolicy catalog-get-only-waypoint
kubectl -n petstore delete gateway waypoint

GET 200 and DELETE 403 at every step, pods back with istio-proxy in initContainers, and about 17 seconds of wall clock. Measured Code 200 : 4994 (99.9 %) with 6 503s. Put the policy back first instead and it is 4976 (99.5 %) with 24, the same fail-closed trap mirrored.

The rollback does not reach 100%, and the reason matters for your own apps. Going forward the waypoint sits in front of the whole roll and absorbs the endpoint churn, which is why the forward migration measured 7500/7500. Coming back, the pods leave ambient and regain sidecars, so for a few seconds the waypoint is balancing across a mix of ambient and sidecar-backed endpoints and a handful of in-flight connections get reset. The app here is http-echo, which has no graceful shutdown at all. Real services should carry a preStop hook and a PodDisruptionBudget, which is what closes that last 0.1% on any rolling restart, ambient or not.

What this run actually measured

GateResult
Baseline, sidecar mode, both clustersGET 200 / DELETE 403, PONG, Code 200 : 372 (100.0 %)
L4 namespace migrated under loadCode 200 : 3000 (100.0 %), redis single-container, negative case still denied at L4
L7 waypoint-first, delete old policy lastCode 200 : 7341 (97.9 %), Code 503 : 159 (2.1 %), one 6.5 second window of total failure
L7 waypoint-first, delete old policy before enrolmentCode 200 : 7500 (100.0 %), zero error cases, 4 sockets, DELETE 403 held at every gate
Control: plain pod roll in the ambient end stateCode 200 : 3748 (100.0 %), confirming the pod roll is not what caused the 503s
Control: selector L7 policy re-applied to an ambient workload995 errors in 2248 requests over the 42 seconds it was present, 100% again the moment it was removed
Interop A/B, never-migrated sidecar callerSolo DELETE 403, community DELETE 200, identical scenario both sides
CA transitionrejection reproduced verbatim, self-heal in about 45 seconds, RSA sidecar serial unchanged and istio-proxy at 0 restarts throughout
Cross-cluster bridge, Istio east-west gateway both sides10/10 each direction, including into an already-ambient service behind its waypoint
Cross-cluster bridge, agentgateway serving east-west on the new cluster10/10 from the ASM cluster into the same already-ambient service, once the gateway namespace was ambient-enrolled and given a PERMISSIVE carve-out
North-south without ingress-use-waypointGET 200 / DELETE 200, waypoint access log recorded zero requests: the L7 policy did not apply to ingress traffic
North-south with ingress-use-waypointGET 200 / DELETE 403, waypoint logged both requests
Rollback to sidecars, pods out of ambient firstCode 200 : 4994 (99.9 %), 6 503s, DELETE 403 held throughout, 17 seconds
Rollback with the selector policy restored firstCode 200 : 4976 (99.5 %), 24 503s, the forward trap mirrored
One ServiceEntry listing several hostnamesWorks. A declared hostname with no route on the far side returns 404 from the gateway, so it fails visibly
Wildcard *.crosscluster.svc ServiceEntryDoes not work with DNS auto-allocation: the client cannot resolve the name, curl exits 6

Field notes

  1. Measure across the cut, not after it. A 15 second fortio run after each step scores 100% and tells you nothing about the seconds in between. The 2.1% failure that exposed the ordering bug only showed up because one load generator ran across the whole sequence. Point samples cannot tell you a cutover was clean.
  2. Read the waypoint's access log, not just the status code. 503 URX,UC upstream_reset_before_response_started{connection_termination} with the upstream address attached is what pointed away from the plausible theory (rolling restart drain). Two control experiments then finished the wrong theory off.
  3. A selector policy follows the workload; a targetRefs policy follows the Service. That is where the ordering rule comes from. Once a workload is ambient, its selector-based policies are ztunnel's problem, and ztunnel cannot do L7. Audit for L7 selector policies before you enrol a namespace.
  4. Check for PeerAuthentication mode: DISABLE carve-outs before you enrol. STRICT and PERMISSIVE both carry over cleanly, but there is no ambient equivalent of DISABLE: in ambient the transport is HBONE and it is always mTLS. Resolve the carve-out first. You can prove the carve-out is real with a plaintext request from a genuinely non-mesh pod, which is the only check that answers the question. Config dumps do not: istioctl x describe pod fails outright against ASM's older control plane, and proxy-config listener comes back empty for both a DISABLE workload and a STRICT one, because modern sidecars consolidate inbound traffic through one virtual listener.
  5. In-mesh tests cannot see the north-south hole. Every caller inside the mesh is enforced correctly whether or not istio.io/ingress-use-waypoint is set, so a migration can pass every test you wrote and still be serving unenforced traffic to the internet. One curl from outside catches it. Add it to the per-namespace gate rather than trusting the in-cluster result.
  6. Use the gateway you already run, where you can. The new cluster does not need a second gateway stack for east-west: the Enterprise agentgateway serving north-south takes another Gateway on the same class, with the internal-LB annotation via spec.infrastructure.annotations so the Service provisions internal on the first apply. The ASM side is the exception, and for a concrete reason: no Gateway API CRDs, and an Istio gateway there is configured by the managed control plane that is already running, so nothing new gets installed.
  7. A gateway is not a remote cluster. The east-west design works because a gateway talks to its own local control plane over xDS, the same relationship every sidecar already has. Nothing in it watches the other cluster's Kubernetes API, so the CRD skew that breaks remote secrets never applies.
  8. The identity tradeoff on the bridge. This terminates and re-originates mTLS at the gateway rather than carrying the original caller's identity end to end. The identity-preserving alternative, AUTO_PASSTHROUGH SNI routing, only kicks in when the control plane has no known direct address for an endpoint, which is precisely how automatic remote-secret discovery forces traffic through a gateway. A hand-written ServiceEntry always carries a real address, so it connects directly and the gateway-redirect path never activates. That makes the plain HTTP pattern the right tool for a static, migration-window reference, but the identity tradeoff is one to agree up front.

Migration checklist

  1. Before you build anything
  2. Read the fleet's fleetDefaultMemberConfig.mesh.management. If it is MANAGEMENT_AUTOMATIC, keep the target cluster out of the fleet.
  3. List the CRD versions the ASM cluster's API server actually serves, per group. Do not assume they all lag equally.
  4. Confirm no pod CIDR overlap across every cluster that will talk to another.
  5. Find every PeerAuthentication with mode: DISABLE and decide what happens to it, because ambient has no equivalent.
  6. Find every L7 AuthorizationPolicy that uses selector. Each one needs converting to targetRefs and deleting in the right order.
  7. Per new cluster, in order
  8. Gateway API CRDs first, before any Helm chart.
  9. Mesh control plane in sidecar mode, and prove the workloads run on it before changing the dataplane.
  10. The real ingress, first time. Note that the agentgateway licence value path is licensing.licenseKey.
  11. CA work next if the mesh CA is external, and restart istiod immediately after pointing it at a new CA.
  12. global.platform=gke on cni and ztunnel, from the first attempt.
  13. Open the CA signing role to key_type=any before the first pod is enrolled into ambient.
  14. Per namespace
  15. Attach a load generator before the change and read it after. A namespace is done when its pass rate is 100%, or when every failure is explained and bounded.
  16. No L7 policy: two labels and a rolling restart. No waypoint.
  17. Any L7 policy: waypoint, Programmed: True, converted policy, use-waypoint, delete the old selector policy, enrol, roll.
  18. Verify the workload lost its sidecar by reading both containers and initContainers in the full pod JSON.
  19. Re-run the negative test as well as the happy path. A policy that stops denying is a worse outcome than one that stops allowing.
  20. Test the denied case from outside the cluster, not only from a pod. A waypoint's L7 policy does not apply to ingress traffic until the Service carries istio.io/ingress-use-waypoint, and in-mesh tests pass either way.
  21. Confirm enrolling this namespace disturbed nothing in the others: no unexpected restarts anywhere else.
  22. Drain, then decommission
  23. Bring the service up on the new cluster and prove it there before any client is moved: same GET and DELETE results, same load-generator pass rate, same negative test as the ASM-side baseline.
  24. Move clients team by team, not in one cut. A namespace is drained when the ASM-side load generator reads zero and the new cluster's reads the full volume, with no gap and no double-count in between.
  25. Watch both sides through each move. Two load generators, one per cluster, both running across the cutover.
  26. Scale the ASM-side Deployments to zero and leave them there for a full business cycle before deleting anything. A scaled-to-zero Deployment is a rollback that takes seconds; a deleted namespace is a redeploy.
  27. While they sit at zero, confirm nothing still calls them: no traffic in the ASM-side access logs, no DNS lookups for the old hostnames, and no ServiceEntry anywhere still pointing at them.
  28. Check what else referenced that service before you delete it: CI jobs, cron workloads, dashboards, alerts and any hard-coded cluster-local hostname in another team's config.
  29. Delete the app namespaces on the ASM side only once the scaled-to-zero period has passed cleanly.
  30. Remove the migration scaffolding last and in this order: the ServiceEntry resources on each calling side, then the Gateway and VirtualService, then the east-west gateway release and its internal LoadBalancer. Removing the gateway first breaks any call you missed, and breaks it silently.
  31. Confirm the internal LoadBalancers are actually gone from the project. A deleted Service with a stuck finalizer leaves the forwarding rule and the bill behind.
  32. Before you call it done
  33. Check the access-log shape. ztunnel's JSON L4 log is structurally different from an Envoy sidecar log, so any SIEM rule written against the old format silently stops matching.
  34. Re-check any canary or subset weighting for callers that arrive over the east-west bridge. They do not get the in-mesh VirtualService.
  35. Keep the signing role on any until the last sidecar in the estate is gone.
  36. Point north-south client traffic at the new cluster's external ingress and retire the old one only after the drain is complete.

Why the Enterprise distribution, on this specific migration

See also

Versions

Built and verified on both editions:

OSS
Gateway APIv1.5.1
Istio (upstream)1.30.3
Kubernetes (GKE)1.34.9-gke.1065000
Vault (chart)0.34.0
cert-managerv1.21.1
cert-manager-istio-csrv0.16.0
Enterprise
Enterprise agentgatewayv2026.5.1
Gateway APIv1.5.1
Kubernetes (GKE)1.34.9-gke.1065000
Solo Istio (distribution)1.30.3-solo
Vault (chart)0.34.0
cert-managerv1.21.1
cert-manager-istio-csrv0.16.0