Skip to content

Connecting the gateway cluster to service clusters

Gateway pods live on a dedicated transit network (a vpn-transit-style network, not on any service cluster's own fabric). Reaching a new service cluster's Pod/Service network from a tunnel means giving that cluster's router a leg on the transit network and adding one static route on the gateway cluster's own router. This is one-time per service cluster, not per tunnel or per exposure: once a service cluster is plumbed in, every IPsecGateway on the gateway cluster can add exposures reaching into it with no further fabric changes.

ClusterLink (v0.2.0)

The fabric this page describes — a transit-network leg plus a gateway- router route — is illustrated by the worked example at the bottom of this page. As of v0.2.0, the ClusterLink CRD below is the reconciler-driven way to set it up.

ClusterLink (ipsec.bnerd.net/v1alpha1, cluster-scoped — it manages estate-level fabric, not tenant state) is the primary way to plumb in a service cluster: one CR does what the manual method below does by hand, and keeps doing it — it's a normal reconciler, so it self-heals if a leg port or route is ever removed out-of-band.

apiVersion: ipsec.bnerd.net/v1alpha1
kind: ClusterLink
metadata:
  name: svc01-link
spec:
  serviceClusterRouter: shoot--services--svc01
  serviceClusterCIDR: 10.251.0.0/16
kubectl apply -f svc01-link.yaml
kubectl get clusterlink svc01-link -o yaml | yq '.status'

Reconciling this:

  1. Allocates a transit IP for the leg port — starting at the transit subnet's .3 (.1 is conventionally the gateway cluster's own router leg, .2 the network's DHCP port; both are skipped by construction, and any address already claimed by another ClusterLink or occupied on the network is skipped too), unless spec.transitIP pins one explicitly.
  2. Creates (or adopts — see below) the leg port on the operator's transit network, tagged for this ClusterLink.
  3. Attaches that port to spec.serviceClusterRouter (a router name or ID — resolved at reconcile time).
  4. Adds the route on the gateway cluster's own router (the operator's configured routerID): spec.serviceClusterCIDR via the allocated transit IP.

No extra route is needed for reply traffic on a connection the tunnel side initiated — once step 3 has the transit-network leg, the service cluster gets a connected route back to the transit network for free. That is a different thing from the service cluster initiating traffic toward a gateway's reverseExposures virtual IPs (see Reverse-route automation below) — the service cluster's router has no route to a gateway's status.serviceCIDR at all until something puts one there.

The target-side security group is still your responsibility, exactly as under the manual method: the service being exposed has to admit ingress from the transit network's CIDR on the exposed ports — see the Networking guide's "Exposure target reachability" section. ClusterLink only plumbs the fabric; it never touches a target's security group.

Adopting a pre-existing, hand-made leg

If a service cluster was already connected the manual way — a leg port that exists but was never tagged by this operator — applying a ClusterLink with the matching serviceClusterRouter and (if it isn't already sitting at .3) the matching spec.transitIP adopts that port instead of creating a duplicate or failing on an address conflict: an untagged port already attached to the target router, at the candidate transit IP, is re-tagged and taken over. Deleting the ClusterLink afterwards deletes that port — an adopted-then-tagged port is this ClusterLink's to delete, the same as one it created outright. See the CRD Reference for the exact adoption rule and every status field/condition.

Deleting the CR unwinds everything it owns, in reverse order: the gateway- router route, then the router attachment, then the leg port itself. Do this only once nothing still depends on reaching that service cluster — every IPsecGateway exposure targeting it will break as soon as the route is gone.

v0.5.0

Everything above this section is a same-tenant link (spec.credentialsSecretRef unset, the default) and is unchanged, byte for byte, from every earlier version. This section covers the cross-tenant shape only: a ClusterLink whose service cluster lives in a different OpenStack project than the operator's own credentials (e.g. a client's own tenant, on the same OpenStack region).

A cross-tenant link needs a second OpenStack credential — the customer's own — because of two Neutron facts: a router only accepts an interface whose port lives in the router's own project, so the leg port on the customer's router has to be created with the customer's credential; and a project may only create a port on a network it doesn't own if that network is shared with it, so the operator has to share its transit network with the customer project first. The gateway-cluster-side route stays on the operator's own credential, same as a same-tenant link.

One ClusterLink per customer router

Every leg port sits on the same transit subnet, and Neutron refuses a second router interface on a subnet the router already has an interface on. So a customer router can carry exactly one ClusterLink: a second link naming the same serviceClusterRouter gets its own leg port but fails to attach it (LegAttached=False/EnsureRouterHasPortFailed, Neutron's "router already has a port on subnet …"). Two service clusters in the same customer project are fine as long as each has its own router — one link each (both share the one RBAC share of the transit network).

The Secret contract

Item Value
Namespace The operator's one configured credentials namespace — chart clusterLink.credentialsNamespace (default bnerd-vpn-clusterlinks), env IPSEC_CLUSTERLINK_CREDENTIALS_NAMESPACE. A ref into any other namespace is rejected at reconcile time (SecretNamespaceNotAllowed) — this is operator configuration, not something the CRD schema can see, so it's checked here rather than at admission.
Name clusterlink-<ClusterLink metadata.name> — enforced: a ref naming any other Secret (for example another link's) is rejected at reconcile time with SecretNameNotAllowed, before the Secret is read. A ClusterLink can therefore only ever use the credential written for it.
Type Opaque
Keys OS_APPLICATION_CREDENTIAL_ID, OS_APPLICATION_CREDENTIAL_SECRET — required, application credentials only (username/password is refused as soon as authentication is attempted with it). The credential must be member-scoped: one whose token carries a privileged role — admin, service or advsvc, in any letter case — is refused with CredentialsPrivileged (an application credential inherits every role of whoever created it; under legacy OpenStack policy admin reaches every project, and Neutron treats service/advsvc as a service context that skips the per-project filter). Roles that merely contain the word, such as load-balancer_admin, are not affected; a typical member credential (_member_, load-balancer_member) passes. A token that carries no roles at all is refused as well (AuthFailed), since its privilege can't be checked. The roles are checked when the operator first authenticates with a Secret version, not again on each token renewal — an application credential's roles are fixed when it is created. A cloud whose policy makes other roles privileged (a customised context_is_admin or context_with_global_access) is not detected by this name check. OS_AUTH_URL, OS_REGION_NAME — optional; if present, must equal the operator's own endpoint/region exactly (trailing / ignored), or the Secret is rejected (CredentialsInvalid) — this stops the operator from ever being made to authenticate against an attacker-supplied endpoint.
Rotation Update the Secret in place; picked up at the next reconcile (≤ the periodic resync interval). The customer-scoped client is cached per ClusterLink and only rebuilt when the Secret's identity (UID/ResourceVersion) actually changes.
Values in the clear Never: Secret values never reach this operator's logs, Kubernetes Events, status, or condition messages — only secret <namespace>/<name> and OpenStack object IDs are named.

Written by infra (Flux), not by hq — the Secret is the only thing hq writes. A cross-tenant link names the customer project it expects, next to the attestation annotation, in the same commit:

apiVersion: ipsec.bnerd.net/v1alpha1
kind: ClusterLink
metadata:
  name: diehl01
  annotations:
    ipsec.bnerd.net/customer-openstack-api-access: "none"
spec:
  serviceClusterRouter: shoot--diehl--diehl01
  serviceClusterCIDR: 10.234.0.0/16
  credentialsSecretRef:
    name: clusterlink-diehl01
    namespace: bnerd-vpn-clusterlinks
  customerProjectID: <customer OpenStack project ID>

spec.customerProjectID is required whenever credentialsSecretRef is set (and refused without it — Validated=False/InvalidSpec; a same-tenant link can't carry it at all — the apiserver rejects that), and immutable once set (apiserver CEL rule: it can be neither changed nor removed). The operator compares the project the Secret's credential actually authenticates into against it before any OpenStack change — a mismatch is CredentialsResolved=False/CredentialsProjectMismatch, naming both project IDs, and nothing is shared, created or attached. A wrong credential pasted into the Secret can therefore never share the transit network with a foreign tenant; the project is never "whatever the first credential happened to be". A value equal to the operator's own project is refused as well (CredentialsAreOperatorProject).

spec.credentialsSecretRef may never be added to, or removed from, a live ClusterLink — the apiserver rejects that transition outright (a CEL rule on the CRD). The CRD would let the ref be re-pointed at another Secret, but the operator only accepts clusterlink-<name> (SecretNameNotAllowed), so rotate credentials by updating that Secret in place. Switching a link between same-tenant and cross-tenant is delete-and-recreate, not an in-place edit.

The attestation annotation, and why

Setting spec.credentialsSecretRef also requires the annotation:

metadata:
  annotations:
    ipsec.bnerd.net/customer-openstack-api-access: "none"

Without it, the condition TenantIsolationAttested reads False/NotAttested and no OpenStack call is made at all — not even to resolve the credential. This exists because of how sharing currently works: the operator's transit network is shared with each customer project as a whole network, not per-ClusterLink. Any tenant able to create ports or VMs on that shared network gets L3 reach, through the gateway router, to every other cluster already linked through it — not just its own. That is only acceptable while the customer has no OpenStack API access of their own into that project; the annotation is a platform-admin attestation that that's true for this link — never set by, or on behalf of, the customer themselves. A structural fix — a transit network scoped per customer project, so sharing a network only ever grants reach to that customer's own cluster — is tracked as a follow-up and not yet built.

Setting up the transit network for a new gateway cluster

This is a one-time, per-gateway-cluster OpenStack-admin step, done once when a new gateway cluster (like vpn02) is stood up — not per ClusterLink and not per customer. Same-tenant links need nothing beyond this (see the manual method above); cross-tenant links additionally need the network shared per customer project, below.

openstack network create vpn-transit
openstack subnet create --network vpn-transit \
  --subnet-range 10.254.0.0/24 --gateway 10.254.0.1 \
  vpn-transit-subnet
openstack router add subnet shoot--services--<gateway-cluster> vpn-transit-subnet
  • One subnet. The operator's own transit-IP allocator (same-tenant links) and the shared-TransitNet bookkeeping both assume a single subnet per transit network — see the CRD reference's transit-IP allocation section.
  • The gateway cluster's own router leg lands at .1 — the convention this whole guide already relies on (.1 is the gateway cluster's own router leg, .2 the DHCP port, both skipped by every allocator). Setting --gateway explicitly at subnet-create time is what guarantees that, rather than however OpenStack happens to pick a default.
  • Never --share. openstack network create --share (or openstack network set --share afterward) makes the network visible to every project in the cloud — the network-level equivalent of the wildcard RBAC rule, which is admin-only for exactly this reason. Sharing it that way would give every tenant on the cloud, not just linked customer projects, L3 reach through the gateway router. Cross-tenant sharing is always per-project, below.

Sharing the transit network with a customer project

The operator does this automatically, as part of reconciling a cross-tenant ClusterLink (EnsureNetworkSharedWithProject, condition TransitNetworkShared) — an RBAC access_as_shared rule targeting the customer project's ID, taken from the resolved credential's own Keystone token, which must equal spec.customerProjectID (see above) — so the operator only ever shares with the project the ClusterLink names, and only while it holds a working credential for it. The share happens only after the customer's router has been found with that credential: a link whose serviceClusterRouter can't be resolved in the customer project (LegAttached=False/RouterResolveFailed) shares nothing. If the operator's own credential isn't allowed to create that RBAC rule (TransitNetworkShared=False/TransitNetworkNotShared), the condition message names the one-time command an OpenStack admin runs instead:

openstack network rbac create --type network --action access_as_shared \
  --target-project <customer-project-id> <transit-network-id>

If such a rule already exists but belongs to someone else (for example an admin made it by hand), the operator's own list can't see it and Neutron answers the create with a conflict (409 DuplicateRbacPolicy). That is not an error: like a permissions refusal, the operator then checks with the customer credential whether the network is visible, carries on if it is (TransitNetworkShared=True/Shared), and reports TransitNetworkNotShared if it isn't.

Once shared (by the operator or by hand), the reconciler carries on as normal. This check re-runs on every reconcile of the link, not just its first — self-healing if the RBAC rule is ever removed out-of-band, but also the reason a decommissioned share silently comes back (see below) if a link still targeting that project reconciles again.

Conditions and reasons (cross-tenant only)

TenantIsolationAttested, CredentialsResolved and TransitNetworkShared appear only on a cross-tenant link. TransitNetworkShared, LegAttached, RouteInstalled and ReverseRoutesInstalled failure messages are prefixed customer credential (secret <ns>/<name>): or operator credential:, naming which credential failed the step; TenantIsolationAttested and CredentialsResolved name the Secret directly in their own message instead, since they are the credential-resolution step.

Condition True reason False reasons
TenantIsolationAttested Attested NotAttested — the annotation above is missing or not exactly "none". Terminal until it's set; zero OpenStack calls made.
CredentialsResolved Resolved SecretNotFound, SecretNamespaceNotAllowed, SecretNameNotAllowed (the ref's name isn't clusterlink-<ClusterLink name>; rejected before any Secret read), CredentialsInvalid (a required key is missing, or OS_AUTH_URL/OS_REGION_NAME don't match the operator's), AuthFailed, CredentialsAreOperatorProject (the Secret authenticates as — or spec.customerProjectID names — the operator's own project; refused outright) — fixed 15s requeue (AuthFailed also covers a token without any roles). CredentialsPrivileged (the credential's token carries admin, service or advsvc; use a member-scoped application credential) and CredentialsProjectMismatch (the credential authenticates into a different project than spec.customerProjectID; the message names both — decided before any OpenStack change) are refusals: the operator doesn't re-authenticate the same Secret version again, and re-checks at the resync interval; updating the Secret is picked up at the next reconcile. CredentialsProjectChanged — the credential's project doesn't match the one this link's leg was already built in (or a leg exists with no project ever pinned); since spec.customerProjectID, only reachable for an object that predates that field (a rotated wrong-project credential is CredentialsProjectMismatch instead); requeued at the resync interval, since the fix is a human restoring the right credential, not a retry.
TransitNetworkShared Shared EnsureNetworkSharedFailed (the share attempt failed for a reason other than a permissions refusal or a conflict with an existing rule owned by someone else); NetworkVisibilityCheckFailed (after a refusal or conflict, checking whether the customer can already see the network also failed); TransitNetworkNotShared (refused or conflicting, and the customer can't see it either — the message carries the one-time command above; requeued at the resync interval).
LegAttached Attached Same reasons as a same-tenant link (on a cross-tenant link RouterResolveFailed is checked before the transit network is shared, so it leaves no share behind), plus TransitIPMismatch — terminal: the leg port came back at a different address than status.transitIP (it vanished out-of-band and was recreated at a new, Neutron-allocated address). The old address is free in Neutron again and may be handed to another tenant's port, so the operator removes the gateway route via the old address right away (removing a route only takes reach away; a route left pointing there would send this link's traffic to whoever holds that address next) and deletes the port it just recreated; it never installs a route to the new address on its own. The link carries no traffic until a human recreates the ClusterLink — this needs a human, not a retry. If removing the route fails, the message says so (also failed to remove the gateway route … operator credential: …).
RouteInstalled, ReverseRoutesInstalled Same as a same-tenant link. Same as a same-tenant link.

Unbind order, and the abandon escape hatch

Deleting a cross-tenant ClusterLink unwinds in the same order as a same-tenant one, split by credential:

  1. The gateway-router route is removed first, always, on the operator's own credential — this cuts the data path even if the customer credential is completely unavailable.
  2. The customer-side unwind (reverse routes, router detach, leg port delete) runs on the customer credential, and only once it resolves to the same project the leg was actually built in (status.customerProjectID) and the one the link names (spec.customerProjectID) — a credential that now points at a different project is refused here (CredentialsProjectMismatch, kept as the held condition's reason), never used to "clean up" the wrong project.
  3. If the customer side can't be reached — the Secret is missing, invalid, or now scopes the wrong project — the finalizer is held, CredentialsResolved=False/CredentialsUnavailable, and the object stays around (still visible via kubectl get clusterlink) until either the Secret is fixed, or the escape hatch below is used. This is deliberate: an unreachable credential must never silently strand OpenStack resources by letting the Kubernetes object disappear anyway.
  4. The escape hatch: set
kubectl annotate clusterlink <name> \
  ipsec.bnerd.net/abandon-customer-resources=true

on a deleting link whose customer side can't be cleaned up. This releases the finalizer without touching the customer-side leg port, router attachment, or reverse routes — it logs a Warning Event and a log line naming exactly what's being left behind (the port ID and transit IP, if known; the router; every abandoned reverse route), so there's a durable record of what to clean up by hand later. Never set automatically — this always needs a human decision that the customer side is unreachable for good (project deleted, credential permanently revoked, etc.), not just temporarily broken.

The RBAC share is never removed automatically on delete — it may be hand-made, or still serve other ClusterLinks targeting the same customer project — see decommissioning below.

Decommissioning: removing the RBAC share by hand

Once no ClusterLink still targets a given customer project, the transit network's RBAC share for it can be removed:

openstack network rbac list --target-project <customer-project-id> \
  --type network --long -c ID -c "Object ID" -f value
openstack network rbac delete <rbac-policy-id>

Check first that no other cross-tenant link still needs it — kubectl get clusterlink -o jsonpath='{range .items[?(@.status.customerProjectID=="<customer-project-id>")]}{.metadata.name}{"\n"}{end}' lists every link currently pinned to that project. This matters because EnsureNetworkSharedWithProject runs on every reconcile of a cross-tenant link, not just its first — as long as even one link is still pinned to that project, its next periodic reconcile (at most the resync interval) silently re-creates the RBAC rule, undoing the removal. Removing the share only sticks once the last ClusterLink targeting that project is gone.

Reverse-route automation

v0.4.0

Before this version, the route this section describes had to be added by hand — a step the estate's own reference deployment only ever did once, out-of-band, and that silently stayed missing for every gateway onboarded afterward. There was no CRD field, condition, or drift detection covering it; it was invisible until someone tried to reach a reverse exposure from the service cluster side and it simply didn't work.

A reverseExposures entry (see the Networking guide's tiers table) makes our side reach into a client's services — but the traffic that triggers it is initiated from the fabric, toward a virtual IP inside the gateway's own status.serviceCIDR. If that fabric-side initiator lives on a service cluster reached through a ClusterLink rather than on the gateway cluster itself, the service cluster's own router needs a route back: status.serviceCIDR via the gateway's own transit-network port address (status.portFixedIP) — the mirror image of the forward route ClusterLink already installs on the gateway cluster's router, but on the other end of the link.

Every ClusterLink now converges this automatically, for every reverseExposures-bearing VPNGateway/IPsecGateway in the estate (any namespace, either CRD kind — not just the one that happened to be onboarded when the ClusterLink was created): reconciling a ClusterLink lists every such gateway, and ensures a route on its own spec.serviceClusterRouter for each one's status.serviceCIDR. A gateway gaining or losing its last reverseExposures entry, or being deleted, re-triggers every ClusterLink (estate-wide fan-out, not a targeted watch — a ClusterLink carries no reference back to the gateways that depend on it) so the route set stays converged without waiting for the periodic resync. ClusterLinkStatus carries the result as reverseRoutes[] (serviceCIDR, nextHop, gatewayRef), and the ReverseRoutesInstalled condition reports whether the latest convergence succeeded — independent of RouteInstalled (the forward route above): a broken reverse route for one gateway never reads as "this ClusterLink's forward path is down."

Deleting a ClusterLink unwinds every reverse route it owns, alongside the forward route/attachment/leg port it already unwound. Deleting a gateway (or just clearing its reverseExposures) is also covered without touching the ClusterLink at all: the gateway's own Cleanup step, on deletion, already sweeps every router in the project for a route whose next-hop is that gateway's tagged port's fixed IP — which is exactly this route's next-hop — so it disappears the moment the gateway itself is gone, and a ClusterLink's next reconcile simply prunes the now-stale entry from its own status.

The reverse-route reconciliation logic itself is operator-controller-only: it never touches a gateway pod's spec, image, or config. Whether upgrading to this release restarts gateway pods is a separate question, and it depends on how the estate pins gateway image tags — values.yaml's images.gateway/gatewayWireguard/statusAgent.tag all default to empty, which falls back to .Chart.AppVersion (charts/bnerd-vpn-operator/templates/deployment.yaml:58-63). An estate that leaves those tags unset gets a new default gateway image reference the moment the operator's own Deployment picks up the new AppVersion, and every existing gateway Deployment rolls on its next reconcile as a result — nothing to do with this feature's own code, purely a side effect of the chart's tag-defaults-to-AppVersion convention. An estate that pins images.gateway.tag/etc. explicitly in its own user-values (e.g. the reference deployment's 0.3.1 pin) is unaffected: the operator binary itself upgrades, but every gateway pod keeps running the pinned image, untouched. Check the target estate's user-values for an explicit pin before promising "no restart" for an upgrade to this release.

Verification recipe

Add (or reuse) an IPsecGateway exposure targeting a service in the newly connected cluster, then reach its virtual IP through a live tunnel from the client side. A successful check confirms a single gateway can reach exposures on multiple service clusters simultaneously once each is plumbed in, and that adding the exposure itself is a live reconcile with zero pod restarts, consistent with the operator's live-reload behavior for NAT/config changes (see Operations).

Ops note: Gardener admin kubeconfigs are time-boxed

If a service cluster's kubectl starts failing with remote error: tls: expired certificate while you're setting up a verification target (a test pod, a target-side security-group rule) in that cluster, the Gardener admin kubeconfig has simply expired — this is expected, not a fault in the cluster or the operator, and it has nothing to do with ClusterLink itself (reconciling a ClusterLink never touches the service cluster's Kubernetes API, only its OpenStack router). Refresh it via the Gardener virtual-garden shoots/<name>/adminkubeconfig request rather than treating it as an incident.

Manual method (background and troubleshooting)

This is the procedure ClusterLink automates. Useful for understanding exactly what the reconciler is doing, or for plumbing in a service cluster without applying a CR (e.g. a one-off, or before the operator is upgraded to a version that has ClusterLink).

Replace <gateway-cluster> / <service-cluster> with your own cluster names throughout.

  1. Create a port on the transit network for the service cluster's router, with a fixed IP starting at .3 or higher. .1 is conventionally the gateway cluster's own router; .2 is typically the network's DHCP port — pointing anything at .2 collides with DHCP and should be avoided.

    openstack port create --network vpn-transit \
      --fixed-ip subnet=vpn-transit-subnet,ip-address=<transit-leg-ip> \
      vpn-transit-<service-cluster>-leg
    
  2. Attach that port to the service cluster's router:

    openstack router add port shoot--services--<service-cluster> vpn-transit-<service-cluster>-leg
    
  3. Add a static route on the gateway cluster's own router, pointing the service cluster's node CIDR at the leg's IP:

    openstack router set --route destination=<service-cluster-node-CIDR>,gateway=<transit-leg-ip> \
      shoot--services--<gateway-cluster>
    

    --route appends, and it will happily point at the wrong port

    On some OpenStack client versions, --route appends to the router's existing route list rather than replacing it — it will not warn you about a stale or duplicate entry. Always check the result with openstack router show -c routes <router> afterward, and never point a route at the transit network's DHCP port.

  4. Open the target-side security group. The service being exposed — an internal LB VIP, a NodePort, whatever the exposure targets — has to admit ingress from the transit network's CIDR on the exposed ports; this is the same prerequisite the Networking guide's "Exposure target reachability" section already documents for same-cluster exposures, and it applies identically once the target lives in a different cluster.

  5. No extra route is needed for reply traffic on a tunnel-initiated connection. Once the router in step 2 has the transit-network leg, the service cluster gets a connected route back to the transit network for free — a reply from the exposed service to whatever exposure reached it needs nothing further.

  6. A reverseExposures[] gateway needs one more route, if the fabric-side initiator lives on this service cluster. Traffic the service cluster initiates toward a gateway's reverse-exposure virtual IPs is a different flow from step 5's replies — it needs its own route on this service cluster's router, to the gateway's status.serviceCIDR via the gateway's own transit-network port address (status.portFixedIP):

    openstack router set --route destination=<gateway-status.serviceCIDR>,gateway=<gateway-status.portFixedIP> \
      shoot--services--<service-cluster>
    

    As of v0.4.0 this is exactly what ClusterLink automates for every reverseExposures-bearing gateway in the estate — see Reverse-route automation above; only do this by hand if you are deliberately staying off ClusterLink for this link.

Worked example from our reference deployment

Concrete values below are from b'nerd's own reference deployment — worked example only, not requirements for your own estate.

  • Gateway cluster vpn01, service cluster svc01.
  • Transit leg: a port on vpn-transit, fixed IP 10.254.0.3, attached to svc01's router.
  • Route: vpn01's router got 10.251.0.0/16 via 10.254.0.3 (svc01's node CIDR, routed at the transit leg's IP).
  • Both are permanent infrastructure — kept after verification, unlike the scaffolding security-group rule and test pod used only to prove the path.
  • Verification: an IPsecGateway exposure on vpn01 targeting 100.64.0.11:8080 was reached through a live tunnel and landed on a pod on an svc01 worker, through the same tunnel already carrying vpn01's own same-cluster exposure — confirming a single gateway can reach exposures on multiple service clusters simultaneously once each is plumbed in.
  • This is exactly the shape a ClusterLink CR (serviceClusterRouter: shoot--services--svc01, serviceClusterCIDR: 10.251.0.0/16, spec.transitIP: 10.254.0.3 to match the existing leg) would take over via its adoption path, described above, without disturbing the tunnel or requiring the leg/route to be recreated.