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.
The automated way: ClusterLink¶
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
Reconciling this:
- Allocates a transit IP for the leg port — starting at the transit
subnet's
.3(.1is conventionally the gateway cluster's own router leg,.2the network's DHCP port; both are skipped by construction, and any address already claimed by anotherClusterLinkor occupied on the network is skipped too), unlessspec.transitIPpins one explicitly. - Creates (or adopts — see below) the leg port on the operator's
transit network, tagged for this
ClusterLink. - Attaches that port to
spec.serviceClusterRouter(a router name or ID — resolved at reconcile time). - Adds the route on the gateway cluster's own router (the operator's
configured
routerID):spec.serviceClusterCIDRvia 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 a ClusterLink¶
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.
Cross-tenant ClusterLinks (a service cluster in another OpenStack project)¶
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. |
The ClusterLink¶
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:
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-
TransitNetbookkeeping 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 (.1is the gateway cluster's own router leg,.2the DHCP port, both skipped by every allocator). Setting--gatewayexplicitly at subnet-create time is what guarantees that, rather than however OpenStack happens to pick a default. - Never
--share.openstack network create --share(oropenstack network set --shareafterward) 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:
- 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.
- 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. - 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 viakubectl 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. - The escape hatch: set
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.
-
Create a port on the transit network for the service cluster's router, with a fixed IP starting at
.3or higher..1is conventionally the gateway cluster's own router;.2is typically the network's DHCP port — pointing anything at.2collides with DHCP and should be avoided. -
Attach that port to the service cluster's router:
-
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>--routeappends, and it will happily point at the wrong portOn some OpenStack client versions,
--routeappends 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 withopenstack router show -c routes <router>afterward, and never point a route at the transit network's DHCP port. -
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.
-
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.
-
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'sstatus.serviceCIDRvia 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.0this is exactly whatClusterLinkautomates for everyreverseExposures-bearing gateway in the estate — see Reverse-route automation above; only do this by hand if you are deliberately staying offClusterLinkfor 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 clustersvc01. - Transit leg: a port on
vpn-transit, fixed IP10.254.0.3, attached tosvc01's router. - Route:
vpn01's router got10.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
IPsecGatewayexposure onvpn01targeting100.64.0.11:8080was reached through a live tunnel and landed on a pod on ansvc01worker, through the same tunnel already carryingvpn01'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
ClusterLinkCR (serviceClusterRouter: shoot--services--svc01,serviceClusterCIDR: 10.251.0.0/16,spec.transitIP: 10.254.0.3to 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.