Skip to content

Commit da95124

Browse files
privateipclaude
andcommitted
fix(containerlab): give ns30/ns40 two attachments, not one shared
Rebased onto the updated fix/vrf-shared-per-vpc-2-cnibgp (which picked up my own rebase-and-reconcile of that branch after fix/vrf-shared-per-vpc-1-core moved out from under it) after that branch's history moved out from under this one -- same ripple as #306 -> #307 -> #311 -> #312 -> #313. No conflicts: this PR only touches containerlab docs/scripts/ manifests, none of which overlapped with the Go source changes further up the stack. Applied cleanly. Verification: task lint, task build (all 8 binaries) pass on the rebased tree. No Go source changed in this PR, so task test:unit is unaffected. tests/e2e and the containerlab lab itself not run in this sandbox (no Kind cluster / root), same caveat as every PR in this stack. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 663c444 commit da95124

12 files changed

Lines changed: 279 additions & 70 deletions

File tree

deploy/containerlab/README.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,15 @@ deploy/containerlab/
138138
│ ├── galactic-router/ # galactic-router DaemonSet + BGP CRs (dfw, iad, sjc)
139139
│ ├── galactic-control/iad/ # galactic-router RR + BGP CRs (iad-control)
140140
│ └── tenants/ # test VPCs — one shared base/ (Namespace + netshoot
141-
│ ├── base/ # Deployment), each tenant patching its namespace,
142-
│ ├── ns50/ # default-network annotation, and (ns30/ns40) replica
143-
│ ├── ns10/ # count; per-site dirs hold each site's NAD(s):
144-
│ ├── ns20/ # ns50 (IPv4, 3-site), ns10 (IPv6-only, 3-site),
145-
│ ├── ns30/ # ns20 (dual-stack, 3-site), ns30 (IPv6-only,
146-
│ └── ns40/ # dfw only, 2 pods), ns40 (IPv4-only, iad only, 2 pods)
141+
│ ├── base/ # Deployment), each tenant patching its namespace and
142+
│ ├── ns50/ # default-network annotation; per-site dirs hold each
143+
│ ├── ns10/ # site's NAD(s): ns50 (IPv4, 3-site), ns10 (IPv6-only,
144+
│ ├── ns20/ # 3-site), ns20 (dual-stack, 3-site), ns30 (IPv6-only,
145+
│ ├── ns30/ # dfw only, 2 attachments), ns40 (IPv4-only, iad only,
146+
│ └── ns40/ # 2 attachments) — ns30/ns40's two attachments are each
147+
│ # their own NAD+Deployment (distinct vpcattachment,
148+
│ # same vpc), not one NAD scaled to replicas: 2 — see
149+
│ # docs/tenants.md for why.
147150
├── node_files/
148151
│ ├── dfw/ config.yaml
149152
│ ├── iad/ config.yaml

deploy/containerlab/docs/tenants.md

Lines changed: 75 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -19,32 +19,54 @@ reachability across sites.
1919

2020
They differ only in scope and addressing:
2121

22-
| VPC | Sites | Address families | VRF interface | Notes |
23-
|--------|---------------------------|-------------------|-------------------|-------|
24-
| `ns50` | dfw, sjc, iad (3-site) | IPv4 + public IPv6 ptp | `G000000050050V` | Also defines a `public` NAD with an IPv6 IPAM pool for external-connectivity testing. |
25-
| `ns10` | dfw, sjc, iad (3-site) | IPv6-only (fd20 ULA) | `G000000010010V` | No `ipv4_subnet` at all. |
26-
| `ns20` | dfw, sjc, iad (3-site) | Dual-stack (fd20 ULA + IPv4) | `G000000020020V` | Both families active; exercises the dual-stack IPAM path. |
27-
| `ns30` | dfw only, 2 replicas | IPv6-only (fd20 ULA) | `G000000030030V` | Both pods land on `dfw-worker` and share one VRF — same-node connectivity, no cross-site hop. |
28-
| `ns40` | iad only, 2 replicas | IPv4-only | `G000000040040V` | Both pods land on `iad-worker` (not `iad-worker-control`, which is tainted for the route-reflector role) and share one VRF. |
29-
30-
The VRF interface name follows `G<vpc, zero-padded to 9><vpcattachment,
31-
zero-padded to 3>V` on every worker — e.g. `ns20` (`vpc="20"`,
32-
`vpcattachment="20"`) is `G000000020020V`.
22+
| VPC | Sites | Address families | VRF interface | Notes |
23+
|--------|------------------------------|-------------------|-------------------|-------|
24+
| `ns50` | dfw, sjc, iad (3-site) | IPv4 + public IPv6 ptp | `G000000050V` | Also defines a `public` NAD with an IPv6 IPAM pool for external-connectivity testing. |
25+
| `ns10` | dfw, sjc, iad (3-site) | IPv6-only (fd20 ULA) | `G000000010V` | No `ipv4_subnet` at all. |
26+
| `ns20` | dfw, sjc, iad (3-site) | Dual-stack (fd20 ULA + IPv4) | `G000000020V` | Both families active; exercises the dual-stack IPAM path. |
27+
| `ns30` | dfw only, 2 attachments | IPv6-only (fd20 ULA) | `G000000030V` | Two distinct attachments (`private`/`private-b`, distinct `vpcattachment` values, same `vpc`), each its own single-replica Deployment, both land on `dfw-worker` and share one VRF — same-node connectivity, no cross-site hop. |
28+
| `ns40` | iad only, 2 attachments | IPv4-only | `G000000040V` | Two distinct attachments (`private`/`private-b`), each its own single-replica Deployment, both land on `iad-worker` (not `iad-worker-control`, which is tainted for the route-reflector role) and share one VRF. |
29+
30+
The VRF interface name follows `G<vpc, zero-padded to 9>V` on every worker —
31+
e.g. `ns20` (`vpc="20"`) is `G000000020V`. Unlike the host/guest veth
32+
interface names (`G<vpc><vpcattachment>H`/`G<vpc><vpcattachment>G`, still
33+
per-attachment), the VRF carries no `vpcattachment` segment: it's shared by
34+
every attachment on that VPC on a given node, not created fresh per
35+
attachment — see [ns30's `vpcattachment` note](#ns30ns40-use-two-distinct-vpcattachment-values)
36+
below for why `ns30`/`ns40` deliberately use two attachments instead of one.
37+
38+
### `ns30`/`ns40` use two distinct `vpcattachment` values
39+
40+
`ns30` and `ns40` each define **two** NADs (`private` and `private-b`) with
41+
the same `vpc` but different `vpcattachment` values, each backing its own
42+
single-replica Deployment — not one NAD scaled to `replicas: 2`. This is
43+
required, not a style choice: `galactic-cni` derives its host/guest veth
44+
interface names from `(vpc, vpcAttachment)` alone, so two pods sharing one
45+
`vpcattachment` on the same node would collide on that name, and
46+
`internal/cni/veth`'s "stale veth" self-heal would delete whichever pod's
47+
veth got there second — silently breaking it without either pod's own CNI
48+
ADD ever reporting a failure. Two distinct `vpcattachment` values avoid the
49+
collision while still landing both pods in the one shared VRF their common
50+
`vpc` gets on this node (see the VRF interface table above).
3351

3452
### SRv6 USID Argument allocation
3553

3654
Each site's tenant node advertises its own `/56` SRv6 locator block into the
3755
fabric. The low hextet of a pod's USID is `(Function << 12) | Argument`
3856
(`uFMT 48+16`, `internal/plumbing/ebpf/uformat`): `Function` is the constant
3957
`0xE` (`FunctionEndDT46`) for every plain L3 VRF attachment, and `Argument` is
40-
a 12-bit value `galactic-router` allocates per-node as the lowest unused slot
41-
in `[0x001, 0xFFF]` among that node's existing `BGPVRFInstance` CRDs
42-
(`allocateArgument`, `internal/cnibgp/bgp.go`) — **not** a decode of the NAD's
43-
`vpc`/`vpcattachment` values. Concretely, expect hextets in the
44-
`0xe001``0xefff` range; the exact value depends on allocation order (`ns50`
45-
is provisioned first in `task deploy`, then `ns10`, `ns20`, `ns30`, `ns40` in
46-
that order, each consuming the next free slot on each node it lands on).
47-
Always confirm the live value rather than trusting a table:
58+
a 12-bit value `galactic-router` allocates per **(VPC, node)** — not per
59+
attachment — as the lowest unused slot in `[0x001, 0xFFF]` among that node's
60+
existing `BGPVRFInstance` CRDs (`allocateArgument`, `internal/cnibgp/bgp.go`)
61+
**not** a decode of the NAD's `vpc`/`vpcattachment` values. Every attachment
62+
sharing a VPC on a node (`ns30`'s and `ns40`'s two apiece) resolves to the
63+
same `BGPVRFInstance` and therefore the same Argument, so each VPC still
64+
consumes exactly one slot per node regardless of how many attachments land on
65+
it. Concretely, expect hextets in the `0xe001``0xefff` range; the exact
66+
value depends on allocation order (`ns50` is provisioned first in `task
67+
deploy`, then `ns10`, `ns20`, `ns30`, `ns40` in that order, each consuming the
68+
next free slot on each node it lands on). Always confirm the live value
69+
rather than trusting a table:
4870

4971
```bash
5072
docker exec dfw-control-plane kubectl get bgpvrfinstances -A
@@ -63,14 +85,19 @@ docker exec dfw-control-plane kubectl get bgpvrfinstances -A
6385
| dfw | `ns20` | `fd20:20:ff01::/48` | `172.21.1.0/24` ||
6486
| sjc | `ns20` | `fd20:20:ff02::/48` | `172.21.20.0/24` ||
6587
| iad | `ns20` | `fd20:20:ff03::/48` | `172.21.10.0/24` ||
66-
| dfw | `ns30` | `fd20:30:ff01::/48` | none ||
67-
| iad | `ns40` | none | `172.40.10.0/24` ||
88+
| dfw | `ns30` (`private`) | `fd20:30:ff01::/48` | none ||
89+
| dfw | `ns30` (`private-b`) | `fd20:30:ff02::/48` | none ||
90+
| iad | `ns40` (`private`) | none | `172.40.10.0/24` ||
91+
| iad | `ns40` (`private-b`) | none | `172.40.20.0/24` ||
6892

6993
`ns50`'s IPv4 pools and `ns20`'s are deliberately from distinct `/16` blocks
7094
(`172.20.0.0/16` vs. `172.21.0.0/16`) so the two VPCs' addressing never
71-
overlaps; `ns40`'s `172.40.0.0/16` is separate again. The IPv6 pools for
72-
`ns10`/`ns20`/`ns30` share the `fd20` ULA prefix, distinguished by the second
73-
hextet (`10`/`20`/`30`).
95+
overlaps; `ns40`'s `172.40.0.0/16` is separate again, split further into a
96+
`.10.0/24`/`.20.0/24` pair between its two attachments (same pattern `ns20`
97+
uses per-site). The IPv6 pools for `ns10`/`ns20`/`ns30` share the `fd20` ULA
98+
prefix, distinguished by the second hextet (`10`/`20`/`30`); `ns30`'s two
99+
attachments split further into `ff01`/`ff02` (same pattern `ns10`/`ns20` use
100+
per-site).
74101

75102
## Prerequisites
76103

@@ -119,14 +146,17 @@ docker exec iad-control-plane kubectl get pods -n <namespace> -o wide
119146
```
120147

121148
For the 3-site VPCs (`ns50`, `ns10`, `ns20`), expect one `Running` pod per
122-
site. For the single-site VPCs, expect **two** `Running` pods, both on the
123-
one site's worker (`dfw-worker` for `ns30`, `iad-worker` for `ns40` — not
149+
site. For the single-site VPCs, expect **two** `Running` pods — one per
150+
attachment (`private`/`private-b`, distinct Deployments) — both on the one
151+
site's worker (`dfw-worker` for `ns30`, `iad-worker` for `ns40` — not
124152
`iad-worker-control`, which carries the route-reflector taint).
125153

126154
### Inspect a pod's VPC interface
127155

128156
Every pod's VPC address lands on `eth0` (the `default-network` annotation
129-
replaces the pod's primary interface — see [Overview](#overview)):
157+
replaces the pod's primary interface — see [Overview](#overview)). For the
158+
3-site VPCs, `-l app=private` alone finds the (only) pod; for `ns30`/`ns40`,
159+
add `-l app=private-b` for the second attachment's pod:
130160

131161
```bash
132162
POD=$(docker exec dfw-control-plane kubectl get pods -n <namespace> -l app=private -o jsonpath='{.items[0].metadata.name}')
@@ -155,14 +185,17 @@ task verify:ns40 # same-node pod-to-pod, IPv4 (iad)
155185
`task verify` (via `task verify:scenarios`) runs all five. Use the scripts as
156186
the reference for how to resolve pod names/addresses by hand (`lib.sh`'s
157187
`pod_name`/`pod_ip4`/`pod_ip6`/`ping_pod` helpers) if you need to reproduce a
158-
step manually while debugging — e.g. to ping the `ns30` pods on dfw directly:
188+
step manually while debugging — e.g. to ping the `ns30` pods on dfw directly.
189+
`ns30`'s two pods are two distinct attachments/labels, not two replicas of
190+
one, so each is resolved by its own `pod_name` call rather than `pod_names`:
159191

160192
```bash
161193
source scripts/lib.sh
162194
NODE=$(control_plane dfw)
163-
mapfile -t PODS < <(pod_names "${NODE}" ns30 | tr ' ' '\n')
164-
IP=$(pod_ip6 "${NODE}" ns30 "${PODS[1]}")
165-
ping_pod "${NODE}" ns30 "${PODS[0]}" -6 "${IP}"
195+
POD_A=$(pod_name "${NODE}" ns30 app=private)
196+
POD_B=$(pod_name "${NODE}" ns30 app=private-b)
197+
IP_B=$(pod_ip6 "${NODE}" ns30 "${POD_B}")
198+
ping_pod "${NODE}" ns30 "${POD_A}" -6 "${IP_B}"
166199
```
167200

168201
## Troubleshooting
@@ -187,9 +220,11 @@ task deploy:ns50 # or whichever VPC's pods aren't getting IPs
187220

188221
### BGPAdvertisements not created
189222

190-
The CNI creates a `BGPAdvertisement` CRD per pod on attach. The 3-site VPCs
191-
get one advertisement per site; the single-site VPCs get **two** (one per
192-
pod, both on the same site):
223+
The CNI creates a `BGPAdvertisement` CRD per attachment on attach. The 3-site
224+
VPCs get one advertisement per site; the single-site VPCs get **two** (one
225+
per attachment, both on the same site) — but only **one** `BGPVRFInstance`
226+
each, since that CRD is shared by every attachment on a VPC/node rather than
227+
created per attachment (see [SRv6 USID Argument allocation](#srv6-usid-argument-allocation)):
193228

194229
```bash
195230
docker exec dfw-control-plane kubectl get bgpadvertisements -n galactic-system
@@ -223,12 +258,12 @@ docker exec dfw-worker dmesg | grep galactic
223258
interface name from the [Overview](#overview) table:
224259

225260
```bash
226-
docker exec dfw-worker ip -4 route show table G000000050050V # ns50
227-
docker exec dfw-worker ip -6 route show table G000000010010V # ns10
228-
docker exec dfw-worker ip -6 route show table G000000020020V # ns20 (IPv6 leg)
229-
docker exec dfw-worker ip -4 route show table G000000020020V # ns20 (IPv4 leg)
230-
docker exec dfw-worker ip -6 route show table G000000030030V # ns30
231-
docker exec iad-worker ip -4 route show table G000000040040V # ns40
261+
docker exec dfw-worker ip -4 route show table G000000050V # ns50
262+
docker exec dfw-worker ip -6 route show table G000000010V # ns10
263+
docker exec dfw-worker ip -6 route show table G000000020V # ns20 (IPv6 leg)
264+
docker exec dfw-worker ip -4 route show table G000000020V # ns20 (IPv4 leg)
265+
docker exec dfw-worker ip -6 route show table G000000030V # ns30 (both attachments, shared VRF)
266+
docker exec iad-worker ip -4 route show table G000000040V # ns40 (both attachments, shared VRF)
232267
```
233268

234269
An empty table for a family the VPC doesn't use (e.g. IPv4 on `ns10`/`ns30`,

deploy/containerlab/resources/tenants/ns30/base/kustomization.yaml

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
# ns30 is single-site (dfw only, see ../dfw/) — 2 replicas land two pods on
2-
# the same worker, attached to the same VPC/NAD, for same-node same-VPC
1+
# ns30 is single-site (dfw only, see ../dfw/). This is the first of its two
2+
# attachments — ../dfw/pod-b.yaml is the second, on its own NAD
3+
# (ns30/private-b). They deliberately use two distinct vpcattachment values
4+
# under the same vpc rather than one NAD scaled to replicas: 2: galactic-cni
5+
# derives its host/guest veth interface names from (vpc, vpcAttachment)
6+
# alone (internal/plumbing/intf), so two pods sharing one vpcAttachment on
7+
# the same node collide on that name — internal/cni/veth's "stale veth"
8+
# self-heal would delete whichever pod's veth got there second, silently
9+
# breaking it (see the incident this fixture layout replaced). Two distinct
10+
# vpcattachments avoids that collision and lands both pods in one shared VRF
11+
# (internal/plumbing/vrf is keyed by vpc alone) for same-node, same-VPC
312
# pod-to-pod connectivity testing rather than cross-site.
413
namespace: ns30
514
resources:
@@ -18,6 +27,3 @@ patches:
1827
metadata:
1928
annotations:
2029
v1.multus-cni.io/default-network: ns30/private
21-
replicas:
22-
- name: netshoot
23-
count: 2
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
resources:
22
- ../base
33
- nad.yaml
4+
- pod-b.yaml
5+
- nad-b.yaml
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
apiVersion: k8s.cni.cncf.io/v1
3+
kind: NetworkAttachmentDefinition
4+
metadata:
5+
name: private-b
6+
namespace: ns30
7+
spec:
8+
config: |-
9+
{
10+
"cniVersion": "1.0.0",
11+
"name": "private-b",
12+
"plugins": [
13+
{
14+
"type": "galactic-cni",
15+
"vpc": "30",
16+
"vpcattachment": "31",
17+
"namespace": "galactic-system",
18+
"ipam": {
19+
"type": "galactic-ipam",
20+
"ipv6_subnet": "fd20:30:ff02::/48",
21+
"address_families": ["ipv6"]
22+
}
23+
},
24+
{
25+
"type": "galactic-bgp",
26+
"vpc": "30",
27+
"vpcattachment": "31",
28+
"namespace": "galactic-system"
29+
}
30+
]
31+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# The second of ns30's two attachments — see ../base/kustomization.yaml's
2+
# doc comment for why this is a standalone Deployment on its own NAD
3+
# (nad-b.yaml, vpcattachment "31") rather than a second replica of ../base's
4+
# Deployment. Mirrors ../../base/pod.yaml (the shared tenant Deployment
5+
# template) directly rather than composing it through kustomize, since a
6+
# second copy needs a different name/label/annotation than kustomize's
7+
# nameSuffix + patch ordering can reliably guarantee.
8+
apiVersion: apps/v1
9+
kind: Deployment
10+
metadata:
11+
name: netshoot-b
12+
namespace: ns30
13+
labels:
14+
app: private-b
15+
spec:
16+
replicas: 1
17+
selector:
18+
matchLabels:
19+
app: private-b
20+
template:
21+
metadata:
22+
labels:
23+
app: private-b
24+
annotations:
25+
v1.multus-cni.io/default-network: ns30/private-b
26+
spec:
27+
affinity:
28+
nodeAffinity:
29+
requiredDuringSchedulingIgnoredDuringExecution:
30+
nodeSelectorTerms:
31+
- matchExpressions:
32+
- key: node-role.kubernetes.io/control-plane
33+
operator: DoesNotExist
34+
containers:
35+
- name: netshoot
36+
image: nicolaka/netshoot:latest
37+
imagePullPolicy: IfNotPresent
38+
command: ["sleep", "infinity"]

deploy/containerlab/resources/tenants/ns40/base/kustomization.yaml

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
# ns40 is single-site (iad only, see ../iad/) — 2 replicas land two pods on
2-
# iad-worker (the only untainted worker in that cluster), attached to the
3-
# same VPC/NAD, for same-node same-VPC pod-to-pod connectivity testing
4-
# rather than cross-site.
1+
# ns40 is single-site (iad only, see ../iad/), landing on iad-worker (the
2+
# only untainted worker in that cluster). This is the first of its two
3+
# attachments — ../iad/pod-b.yaml is the second, on its own NAD
4+
# (ns40/private-b). They deliberately use two distinct vpcattachment values
5+
# under the same vpc rather than one NAD scaled to replicas: 2 — see
6+
# ../../ns30/base/kustomization.yaml's doc comment for why (galactic-cni's
7+
# host/guest veth naming collides when two pods share one vpcattachment on
8+
# the same node). Two distinct vpcattachments avoids that collision and
9+
# lands both pods in one shared VRF for same-node, same-VPC pod-to-pod
10+
# connectivity testing rather than cross-site.
511
namespace: ns40
612
resources:
713
- ../../base
@@ -19,6 +25,3 @@ patches:
1925
metadata:
2026
annotations:
2127
v1.multus-cni.io/default-network: ns40/private
22-
replicas:
23-
- name: netshoot
24-
count: 2
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
resources:
22
- ../base
33
- nad.yaml
4+
- pod-b.yaml
5+
- nad-b.yaml
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
---
2+
apiVersion: k8s.cni.cncf.io/v1
3+
kind: NetworkAttachmentDefinition
4+
metadata:
5+
name: private-b
6+
namespace: ns40
7+
spec:
8+
config: |-
9+
{
10+
"cniVersion": "1.0.0",
11+
"name": "private-b",
12+
"plugins": [
13+
{
14+
"type": "galactic-cni",
15+
"vpc": "40",
16+
"vpcattachment": "41",
17+
"namespace": "galactic-system",
18+
"ipam": {
19+
"type": "galactic-ipam",
20+
"ipv4_subnet": "172.40.20.0/24",
21+
"address_families": ["ipv4"]
22+
}
23+
},
24+
{
25+
"type": "galactic-bgp",
26+
"vpc": "40",
27+
"vpcattachment": "41",
28+
"namespace": "galactic-system"
29+
}
30+
]
31+
}

0 commit comments

Comments
 (0)