@adviser/ovn-fabric 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -11
- package/bin/ovn-fabric.js +30 -12
- package/package.json +1 -2
- package/src/addressing.ts +0 -222
- package/src/cli.ts +0 -163
- package/src/define.ts +0 -187
- package/src/factories.ts +0 -444
- package/src/generate-netns.ts +0 -622
- package/src/generate-ovn.ts +0 -625
- package/src/types.ts +0 -408
package/src/generate-ovn.ts
DELETED
|
@@ -1,625 +0,0 @@
|
|
|
1
|
-
// src/generate-ovn.ts — emit ONE self-installing shell script per host
|
|
2
|
-
// that does everything: sets up OVS bridges/interfaces for each
|
|
3
|
-
// segment (no netplan — see below), configures the OVN logical
|
|
4
|
-
// topology (segments, uplink transfer links, backbone joins), and
|
|
5
|
-
// installs itself as a boot-time systemd unit so it re-applies on
|
|
6
|
-
// every reboot. Pure function: NetworkDefinition in, one script per
|
|
7
|
-
// host out. Never executes anything itself — see ADR 0001 §2. Copy the
|
|
8
|
-
// output to the target host and run it; everything is done.
|
|
9
|
-
//
|
|
10
|
-
// Order matters and is enforced here: OVS bridges/ports MUST exist
|
|
11
|
-
// before any ovn-nbctl localnet port can bind to them, so this script
|
|
12
|
-
// always emits the interface-setup section first, then the OVN config.
|
|
13
|
-
//
|
|
14
|
-
// Why no netplan for segment interfaces: confirmed live, this session
|
|
15
|
-
// — systemd-networkd actively fights OVS for ownership of an interface
|
|
16
|
-
// (it tries to set itself as netlink master alongside OVS and gets
|
|
17
|
-
// stuck reasserting control), and a netns-owned VLAN interface
|
|
18
|
-
// destroyed by netns teardown is NOT reliably recreated by netplan
|
|
19
|
-
// afterward even with a declared vlans: entry. This generator creates
|
|
20
|
-
// every VLAN subinterface and OVS bridge/port itself, imperatively.
|
|
21
|
-
//
|
|
22
|
-
// Scope: SEGMENTS and the backbone join to whichever uplink each
|
|
23
|
-
// segment currently resolves to, plus each uplink's OWN transfer-link
|
|
24
|
-
// OVN objects (needed for the backbone join to have something to join
|
|
25
|
-
// to). Uplinks' netns/dhclient/NAT supervision is explicitly out of
|
|
26
|
-
// scope — that's ovn_uplink_sync.py's territory, with its own
|
|
27
|
-
// independent lifecycle and config; this script enables that tool's
|
|
28
|
-
// systemd unit but does not duplicate its logic.
|
|
29
|
-
//
|
|
30
|
-
// Grouped by Host (not by abstract tier) because that's what actually
|
|
31
|
-
// matters operationally: everything one host needs, in the one file
|
|
32
|
-
// that gets copied there and run.
|
|
33
|
-
//
|
|
34
|
-
// The backbone join reflects each segment's UplinkSelector resolution
|
|
35
|
-
// AT GENERATION TIME — re-run after switching an uplink (e.g.
|
|
36
|
-
// ManualUplink.switchTo(...)) to regenerate the join against the new
|
|
37
|
-
// target.
|
|
38
|
-
//
|
|
39
|
-
// Idiom matched to how this project's OVN config was hand-built and
|
|
40
|
-
// verified live, all session long: every mutating command uses
|
|
41
|
-
// --may-exist / --if-exists, one logical operation per line, object
|
|
42
|
-
// names derived from the segment's/uplink's own name (sw-home, not
|
|
43
|
-
// sw-128), one comment header per block naming what produced it.
|
|
44
|
-
|
|
45
|
-
import { macFromV4, segmentBackboneNet, uplinkBackboneNet } from "./addressing.ts";
|
|
46
|
-
import {
|
|
47
|
-
backdoorBridge,
|
|
48
|
-
emitBackdoorNat,
|
|
49
|
-
emitUplinkNetns,
|
|
50
|
-
uplinkTransferBridge,
|
|
51
|
-
} from "./generate-netns.ts";
|
|
52
|
-
import type { NetworkDefinition } from "./define.ts";
|
|
53
|
-
import type { DhcpClient, Host, Segment, Uplink } from "./types.ts";
|
|
54
|
-
|
|
55
|
-
/** One (network_name, OVS bridge) pair that must appear in
|
|
56
|
-
* ovn-bridge-mappings — otherwise ovn-controller has no physical
|
|
57
|
-
* bridge to patch a localnet port into and it silently never carries
|
|
58
|
-
* traffic. Collected once per host across segments AND uplink
|
|
59
|
-
* transfer links, then emitted as a single external-ids write (the
|
|
60
|
-
* mapping is one comma-joined string, not additive across calls). */
|
|
61
|
-
interface BridgeMapping {
|
|
62
|
-
readonly networkName: string;
|
|
63
|
-
readonly bridge: string;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function v4Prefix(addrIndex: { ipv4: { to_string(): string } }): string {
|
|
67
|
-
return addrIndex.ipv4.to_string();
|
|
68
|
-
}
|
|
69
|
-
function v6Prefix(addrIndex: { ipv6: { to_string(): string } }): string {
|
|
70
|
-
return addrIndex.ipv6.to_string();
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Every logical router port that faces a localnet network (segment
|
|
74
|
-
// client-facing side, segment/uplink backbone-facing side, uplink
|
|
75
|
-
// transfer-link side) needs a gateway chassis assignment, or
|
|
76
|
-
// ovn-controller has no scheduling decision to act on for that port at
|
|
77
|
-
// all and never programs ARP-responder/patch flows for it — confirmed
|
|
78
|
-
// live, this session: gateway_chassis was "[]" on every lrp after a
|
|
79
|
-
// full topology apply, and `ovs-ofctl dump-flows br-int` had zero
|
|
80
|
-
// flows for the affected network, i.e. the very first hop (segment
|
|
81
|
-
// gateway IP) was unreachable. This mirrors what the original
|
|
82
|
-
// hand-built setup-ovn-topology.sh did unconditionally for every
|
|
83
|
-
// router port and the generator had dropped. $CHASSIS is resolved
|
|
84
|
-
// once, in the emitted shell script itself (see scriptForHost), not
|
|
85
|
-
// here — this generator never queries live state (ADR 0001 §2).
|
|
86
|
-
function emitGatewayChassis(lrp: string): string[] {
|
|
87
|
-
return [
|
|
88
|
-
`ovn-nbctl lrp-set-gateway-chassis ${lrp} "$CHASSIS" 100`,
|
|
89
|
-
`ovn-nbctl set logical_router_port ${lrp} options:reside-on-redirect-chassis=true`,
|
|
90
|
-
];
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ── interface setup: segment's real kernel interface -> OVS bridge ──
|
|
94
|
-
// No netplan — see header comment. Uplinks are NOT handled here; their
|
|
95
|
-
// real interface is owned by ovn_uplink_sync.py's netns, not an OVS
|
|
96
|
-
// bridge at all.
|
|
97
|
-
|
|
98
|
-
function emitSegmentInterface(s: Segment): string[] {
|
|
99
|
-
const br = `br-${s.name}`;
|
|
100
|
-
const lines: string[] = [`# --- segment interface: ${s.name} ---`];
|
|
101
|
-
|
|
102
|
-
if (s.if.kind === "physical") {
|
|
103
|
-
lines.push(
|
|
104
|
-
`ovs-vsctl --may-exist add-br ${br}`,
|
|
105
|
-
`ovs-vsctl set bridge ${br} fail-mode=standalone`,
|
|
106
|
-
`ovs-vsctl --may-exist add-port ${br} ${s.if.name}`,
|
|
107
|
-
);
|
|
108
|
-
} else if (s.if.kind === "vlan") {
|
|
109
|
-
// ifaceName override lets a segment reuse a VLAN tag that already
|
|
110
|
-
// has a differently-purposed subinterface elsewhere (e.g. VLAN 129
|
|
111
|
-
// already carries this host's own management IP on ens18.129,
|
|
112
|
-
// outside OVS) without colliding with it — see InterfaceKind,
|
|
113
|
-
// types.ts.
|
|
114
|
-
const vlanIface = s.if.ifaceName ?? `${s.if.vlanParent}.${s.if.vlanId}`;
|
|
115
|
-
lines.push(
|
|
116
|
-
`ip link show ${vlanIface} >/dev/null 2>&1 || ` +
|
|
117
|
-
`ip link add link ${s.if.vlanParent} name ${vlanIface} ` +
|
|
118
|
-
`type vlan id ${s.if.vlanId}`,
|
|
119
|
-
`ip link set ${vlanIface} up`,
|
|
120
|
-
`ovs-vsctl --may-exist add-br ${br}`,
|
|
121
|
-
`ovs-vsctl set bridge ${br} fail-mode=standalone`,
|
|
122
|
-
`ovs-vsctl --may-exist add-port ${br} ${vlanIface}`,
|
|
123
|
-
);
|
|
124
|
-
} else {
|
|
125
|
-
lines.push(
|
|
126
|
-
`# unsupported segment interface kind "${s.if.kind}" for ${s.name} ` +
|
|
127
|
-
`— skipped, see generate-ovn.ts`,
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
lines.push("");
|
|
132
|
-
return lines;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ── interface setup: uplink transfer-link veth -> OVS bridge ────────
|
|
136
|
-
// The OVN-side veth itself (veth-uplink-<name>) is created later, in
|
|
137
|
-
// the netns tier (generate-netns.ts emitUplinkNetns) — but the bridge
|
|
138
|
-
// it attaches to, and that bridge's entry in ovn-bridge-mappings, must
|
|
139
|
-
// exist before the OVN topology section runs (same ordering rule as
|
|
140
|
-
// segment interfaces above). The veth is added as a port to this
|
|
141
|
-
// bridge by emitUplinkNetns itself once it creates the veth.
|
|
142
|
-
|
|
143
|
-
function emitUplinkTransferInterface(u: Uplink): string[] {
|
|
144
|
-
const br = uplinkTransferBridge(u);
|
|
145
|
-
return [
|
|
146
|
-
`# --- uplink transfer-link interface: ${u.name} ---`,
|
|
147
|
-
`ovs-vsctl --may-exist add-br ${br}`,
|
|
148
|
-
`ovs-vsctl set bridge ${br} fail-mode=standalone`,
|
|
149
|
-
"",
|
|
150
|
-
];
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// ── interface setup: backdoor veth -> its OWN OVS bridge ────────────
|
|
154
|
-
// See Backdoor (types.ts). Same "bridge must exist before OVN config
|
|
155
|
-
// binds a localnet port to it" ordering rule as every other interface
|
|
156
|
-
// setup in this file — the veth itself is created later, in
|
|
157
|
-
// generate-netns.ts's emitBackdoorVethAndRoute (called from within
|
|
158
|
-
// emitUplinkNetns, since it IS the uplink's real interface — see
|
|
159
|
-
// realIfaceFor, generate-netns.ts).
|
|
160
|
-
|
|
161
|
-
function emitBackdoorInterface(u: Uplink): string[] {
|
|
162
|
-
if (u.backdoor === undefined) return [];
|
|
163
|
-
const br = backdoorBridge(u.backdoor);
|
|
164
|
-
return [
|
|
165
|
-
`# --- backdoor interface: ${u.name} -> ${u.backdoor.via.name} ---`,
|
|
166
|
-
`ovs-vsctl --may-exist add-br ${br}`,
|
|
167
|
-
`ovs-vsctl set bridge ${br} fail-mode=standalone`,
|
|
168
|
-
"",
|
|
169
|
-
];
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// ── tier 1: segment's own router/switch (OVN logical config) ────────
|
|
173
|
-
|
|
174
|
-
function emitSegment(s: Segment): string[] {
|
|
175
|
-
const sw = `sw-${s.name}`;
|
|
176
|
-
const router = `router-${s.name}`;
|
|
177
|
-
const lspLocalnet = `lsp-${s.name}-localnet`;
|
|
178
|
-
const lspRouter = `lsp-${s.name}-router`;
|
|
179
|
-
const lrp = `lrp-${s.name}`;
|
|
180
|
-
const mac = macFromV4(s.addresses[0].ipv4);
|
|
181
|
-
const networkName = `seg-${s.name}`;
|
|
182
|
-
|
|
183
|
-
const lines = [
|
|
184
|
-
`# --- segment: ${s.name} ---`,
|
|
185
|
-
`ovn-nbctl --may-exist ls-add ${sw}`,
|
|
186
|
-
`ovn-nbctl --may-exist lsp-add ${sw} ${lspLocalnet}`,
|
|
187
|
-
`ovn-nbctl lsp-set-type ${lspLocalnet} localnet`,
|
|
188
|
-
`ovn-nbctl lsp-set-addresses ${lspLocalnet} unknown`,
|
|
189
|
-
`ovn-nbctl lsp-set-options ${lspLocalnet} network_name=${networkName}`,
|
|
190
|
-
`ovn-nbctl --may-exist lr-add ${router}`,
|
|
191
|
-
`ovn-nbctl --may-exist lrp-add ${router} ${lrp} ${mac} ` +
|
|
192
|
-
`${v4Prefix(s.addresses[0])} ${v6Prefix(s.addresses[0])}`,
|
|
193
|
-
...emitGatewayChassis(lrp),
|
|
194
|
-
];
|
|
195
|
-
|
|
196
|
-
// SLAAC: clients on this segment self-configure their global IPv6
|
|
197
|
-
// address from Router Advertisements OVN sends on lrp's behalf. The
|
|
198
|
-
// advertised prefix is taken directly from lrp's own IPv6 network
|
|
199
|
-
// above — no separate DHCP_Options object needed (this is the
|
|
200
|
-
// opposite direction from an uplink's netns, which RECEIVES RA from
|
|
201
|
-
// the ISP — see ADR 0001 / transfer-links.md). Per-segment
|
|
202
|
-
// configurable (Segment.slaac) since not every segment should
|
|
203
|
-
// necessarily advertise — e.g. disabled for "home".
|
|
204
|
-
if (s.slaac) {
|
|
205
|
-
lines.push(
|
|
206
|
-
`ovn-nbctl set logical_router_port ${lrp} ipv6_ra_configs:address_mode=slaac`,
|
|
207
|
-
);
|
|
208
|
-
} else {
|
|
209
|
-
lines.push(
|
|
210
|
-
`ovn-nbctl remove logical_router_port ${lrp} ipv6_ra_configs address_mode`,
|
|
211
|
-
);
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
lines.push(
|
|
215
|
-
`ovn-nbctl --may-exist lsp-add ${sw} ${lspRouter}`,
|
|
216
|
-
`ovn-nbctl lsp-set-type ${lspRouter} router`,
|
|
217
|
-
`ovn-nbctl lsp-set-addresses ${lspRouter} router`,
|
|
218
|
-
`ovn-nbctl lsp-set-options ${lspRouter} router-port=${lrp}`,
|
|
219
|
-
"",
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
return lines;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// ── tier 3: uplink's own router/switch (transfer link, OVN side) ────
|
|
226
|
-
// NOT covered here: the netns side (dhclient/SLAAC, NAT, back-routes —
|
|
227
|
-
// kernel-side, not OVN config at all — see ovn_uplink_sync.py).
|
|
228
|
-
|
|
229
|
-
function emitUplinkTransfer(u: Uplink): string[] {
|
|
230
|
-
const sw = `sw-uplink-${u.name}-transfer`;
|
|
231
|
-
const lrp = `lrp-uplink-${u.name}-transfer`;
|
|
232
|
-
const lspLocalnet = `lsp-uplink-${u.name}-transfer-localnet`;
|
|
233
|
-
const lspRouter = `lsp-uplink-${u.name}-transfer-router`;
|
|
234
|
-
const router = `router-uplink-${u.name}`;
|
|
235
|
-
const ovnSide = u.addresses[0]; // host=1, see factories.ts
|
|
236
|
-
const netnsSide = u.addresses[1]; // host=2 — the netns peer, real next-hop
|
|
237
|
-
const mac = macFromV4(ovnSide.ipv4);
|
|
238
|
-
const networkName = `seg-uplink-${u.name}-transfer`;
|
|
239
|
-
|
|
240
|
-
return [
|
|
241
|
-
`# --- uplink: ${u.name} (transfer link, OVN side) ---`,
|
|
242
|
-
`ovn-nbctl --may-exist ls-add ${sw}`,
|
|
243
|
-
`ovn-nbctl --may-exist lsp-add ${sw} ${lspLocalnet}`,
|
|
244
|
-
`ovn-nbctl lsp-set-type ${lspLocalnet} localnet`,
|
|
245
|
-
`ovn-nbctl lsp-set-addresses ${lspLocalnet} unknown`,
|
|
246
|
-
`ovn-nbctl lsp-set-options ${lspLocalnet} network_name=${networkName}`,
|
|
247
|
-
`ovn-nbctl --may-exist lr-add ${router}`,
|
|
248
|
-
`ovn-nbctl --may-exist lrp-add ${router} ${lrp} ${mac} ` +
|
|
249
|
-
`${v4Prefix(ovnSide)} ${v6Prefix(ovnSide)}`,
|
|
250
|
-
...emitGatewayChassis(lrp),
|
|
251
|
-
`ovn-nbctl --may-exist lsp-add ${sw} ${lspRouter}`,
|
|
252
|
-
`ovn-nbctl lsp-set-type ${lspRouter} router`,
|
|
253
|
-
`ovn-nbctl lsp-set-addresses ${lspRouter} router`,
|
|
254
|
-
`ovn-nbctl lsp-set-options ${lspRouter} router-port=${lrp}`,
|
|
255
|
-
// Outbound default route: everything beyond the directly-connected
|
|
256
|
-
// transfer /28 and the backbone /16 must go to the netns peer —
|
|
257
|
-
// without this the uplink router has literally zero idea how to
|
|
258
|
-
// reach the internet at all. Confirmed live: a missing default
|
|
259
|
-
// route showed up as `ovn-nbctl lr-route-list router-uplink-<name>`
|
|
260
|
-
// coming back completely empty after a full topology apply — outbound traffic
|
|
261
|
-
// reaching this router had nowhere to go.
|
|
262
|
-
`ovn-nbctl --may-exist lr-route-add ${router} 0.0.0.0/0 ${netnsSide.ipv4.to_s()}`,
|
|
263
|
-
`ovn-nbctl --may-exist lr-route-add ${router} ::/0 ${netnsSide.ipv6.to_s()}`,
|
|
264
|
-
"",
|
|
265
|
-
];
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// ── backdoor: borrowed egress, OVN side ─────────────────────────────
|
|
269
|
-
// See Backdoor (types.ts). Structurally identical to emitUplinkTransfer
|
|
270
|
-
// above — a localnet-backed logical switch plus a router port with a
|
|
271
|
-
// gateway chassis — except the router port lands on the `via` uplink's
|
|
272
|
-
// ALREADY-EXISTING router, not a new one, and there's no default route
|
|
273
|
-
// to add here: `via`'s router already has its own (0.0.0.0/0 -> its own
|
|
274
|
-
// netns, from emitUplinkTransfer when `via` itself was emitted), and
|
|
275
|
-
// that's exactly the route this borrowed traffic should also use.
|
|
276
|
-
|
|
277
|
-
function emitBackdoor(u: Uplink): string[] {
|
|
278
|
-
if (u.backdoor === undefined) return [];
|
|
279
|
-
const bd = u.backdoor;
|
|
280
|
-
|
|
281
|
-
const sw = `sw-backdoor-${u.name}`;
|
|
282
|
-
const lrp = `lrp-backdoor-${u.name}`;
|
|
283
|
-
const lspLocalnet = `lsp-backdoor-${u.name}-localnet`;
|
|
284
|
-
const lspRouter = `lsp-backdoor-${u.name}-router`;
|
|
285
|
-
const viaRouter = `router-uplink-${bd.via.name}`;
|
|
286
|
-
const ovnSide = bd.addresses[0]; // host=1, lives on `via`'s router
|
|
287
|
-
const mac = macFromV4(ovnSide.ipv4);
|
|
288
|
-
const networkName = `seg-backdoor-${u.name}`;
|
|
289
|
-
|
|
290
|
-
return [
|
|
291
|
-
`# --- backdoor: ${u.name} -> ${bd.via.name} (borrowed egress, OVN side) ---`,
|
|
292
|
-
`ovn-nbctl --may-exist ls-add ${sw}`,
|
|
293
|
-
`ovn-nbctl --may-exist lsp-add ${sw} ${lspLocalnet}`,
|
|
294
|
-
`ovn-nbctl lsp-set-type ${lspLocalnet} localnet`,
|
|
295
|
-
`ovn-nbctl lsp-set-addresses ${lspLocalnet} unknown`,
|
|
296
|
-
`ovn-nbctl lsp-set-options ${lspLocalnet} network_name=${networkName}`,
|
|
297
|
-
`ovn-nbctl --may-exist lrp-add ${viaRouter} ${lrp} ${mac} ` +
|
|
298
|
-
`${v4Prefix(ovnSide)} ${v6Prefix(ovnSide)}`,
|
|
299
|
-
...emitGatewayChassis(lrp),
|
|
300
|
-
`ovn-nbctl --may-exist lsp-add ${sw} ${lspRouter}`,
|
|
301
|
-
`ovn-nbctl lsp-set-type ${lspRouter} router`,
|
|
302
|
-
`ovn-nbctl lsp-set-addresses ${lspRouter} router`,
|
|
303
|
-
`ovn-nbctl lsp-set-options ${lspRouter} router-port=${lrp}`,
|
|
304
|
-
"",
|
|
305
|
-
];
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// ── tier 2: backbone join (segment <-> currently-resolved uplink) ───
|
|
309
|
-
|
|
310
|
-
function emitSegmentBackboneJoin(s: Segment): string[] {
|
|
311
|
-
// No uplink assigned yet — deliberately no backbone join, no route,
|
|
312
|
-
// no NAT for this segment (see Segment.uplink, types.ts). Isolated
|
|
313
|
-
// is the safe default until a real uplink (e.g. a WireGuard VPN
|
|
314
|
-
// tunnel) exists for it, rather than falling through to whichever
|
|
315
|
-
// uplink happens to be declared elsewhere in the config.
|
|
316
|
-
if (s.uplink === undefined) {
|
|
317
|
-
return [`# --- segment ${s.name}: no uplink assigned, isolated ---`, ""];
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
const segRouter = `router-${s.name}`;
|
|
321
|
-
const segLrpBb = `lrp-${s.name}-bb`;
|
|
322
|
-
const segLspBb = `lsp-backbone-${s.name}`;
|
|
323
|
-
|
|
324
|
-
const resolved = s.uplink.resolve();
|
|
325
|
-
const upRouter = `router-uplink-${resolved.name}`;
|
|
326
|
-
const upLrpBb = `lrp-uplink-${resolved.name}-bb`;
|
|
327
|
-
const upLspBb = `lsp-backbone-uplink-${resolved.name}`;
|
|
328
|
-
|
|
329
|
-
const segId = s.addresses[0].id();
|
|
330
|
-
const segBackbone = segmentBackboneNet(segId, 1);
|
|
331
|
-
const upBackbone = uplinkBackboneNet(
|
|
332
|
-
resolved.addresses[0].id(),
|
|
333
|
-
resolved.slot,
|
|
334
|
-
1,
|
|
335
|
-
);
|
|
336
|
-
|
|
337
|
-
const segMac = macFromV4(segBackbone.ipv4);
|
|
338
|
-
const upMac = macFromV4(upBackbone.ipv4);
|
|
339
|
-
|
|
340
|
-
return [
|
|
341
|
-
`# --- backbone join: segment ${s.name} -> uplink ${resolved.name} ---`,
|
|
342
|
-
`ovn-nbctl --may-exist lrp-add ${segRouter} ${segLrpBb} ${segMac} ` +
|
|
343
|
-
`${v4Prefix(segBackbone)} ${v6Prefix(segBackbone)}`,
|
|
344
|
-
...emitGatewayChassis(segLrpBb),
|
|
345
|
-
`ovn-nbctl --may-exist lsp-add sw-backbone ${segLspBb}`,
|
|
346
|
-
`ovn-nbctl lsp-set-type ${segLspBb} router`,
|
|
347
|
-
`ovn-nbctl lsp-set-addresses ${segLspBb} router`,
|
|
348
|
-
`ovn-nbctl lsp-set-options ${segLspBb} router-port=${segLrpBb}`,
|
|
349
|
-
`ovn-nbctl --may-exist lrp-add ${upRouter} ${upLrpBb} ${upMac} ` +
|
|
350
|
-
`${v4Prefix(upBackbone)} ${v6Prefix(upBackbone)}`,
|
|
351
|
-
...emitGatewayChassis(upLrpBb),
|
|
352
|
-
`ovn-nbctl --may-exist lsp-add sw-backbone ${upLspBb}`,
|
|
353
|
-
`ovn-nbctl lsp-set-type ${upLspBb} router`,
|
|
354
|
-
`ovn-nbctl lsp-set-addresses ${upLspBb} router`,
|
|
355
|
-
`ovn-nbctl lsp-set-options ${upLspBb} router-port=${upLrpBb}`,
|
|
356
|
-
`ovn-nbctl --may-exist lr-route-add ${segRouter} 0.0.0.0/0 ${upBackbone.ipv4.to_s()}`,
|
|
357
|
-
`ovn-nbctl --may-exist lr-route-add ${segRouter} ::/0 ${upBackbone.ipv6.to_s()}`,
|
|
358
|
-
// Backroute: the uplink router has no other way to learn how to
|
|
359
|
-
// reach this segment's client subnet — it's two hops away
|
|
360
|
-
// (uplink router -> sw-backbone -> segment router -> segment
|
|
361
|
-
// switch), not a directly-connected network from the uplink
|
|
362
|
-
// router's point of view, so nothing auto-populates this.
|
|
363
|
-
// Confirmed live, this session, from BOTH directions: `ovn-nbctl
|
|
364
|
-
// lr-route-list` on the uplink router came back completely empty,
|
|
365
|
-
// and independently, a real client on the segment saw `traceroute`
|
|
366
|
-
// reach the uplink router's backbone address (10.80.0.9) but then
|
|
367
|
-
// get no further response — the uplink router had received the
|
|
368
|
-
// packet and generated a TTL-exceeded reply, but had nowhere to
|
|
369
|
-
// route that reply back to, so it silently dropped it.
|
|
370
|
-
// `ovn-nbctl lr-route-add` normalizes a host+prefix (e.g.
|
|
371
|
-
// 192.168.128.2/24) down to the true network address itself —
|
|
372
|
-
// confirmed live — so v4Prefix(s.addresses[0]) is safe to pass
|
|
373
|
-
// directly here without a separate network-address helper.
|
|
374
|
-
`ovn-nbctl --may-exist lr-route-add ${upRouter} ${v4Prefix(s.addresses[0])} ${segBackbone.ipv4.to_s()}`,
|
|
375
|
-
`ovn-nbctl --may-exist lr-route-add ${upRouter} ${v6Prefix(s.addresses[0])} ${segBackbone.ipv6.to_s()}`,
|
|
376
|
-
"",
|
|
377
|
-
];
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
// ── required OS packages ────────────────────────────────────────────
|
|
381
|
-
// Two different consumers, deliberately kept apart (2026-07-06, per
|
|
382
|
-
// explicit feedback):
|
|
383
|
-
//
|
|
384
|
-
// - emitAptInstall: actually installs anything missing (apt-get).
|
|
385
|
-
// Slow, needs network, and can block on a held apt/dpkg lock
|
|
386
|
-
// (unattended-upgrades, etc.) — none of that is acceptable at boot
|
|
387
|
-
// time, before the backbone router comes up. So this is emitted
|
|
388
|
-
// ONLY in scriptForHost's manual/first-run branch (the `$1 !=
|
|
389
|
-
// --setup-only` block, BEFORE systemctl takes over) — never on
|
|
390
|
-
// the systemd/boot path.
|
|
391
|
-
//
|
|
392
|
-
// - emitPreflightChecks: the fast, boot-safe counterpart used on the
|
|
393
|
-
// systemd/`--setup-only` path instead. Purely informational — no
|
|
394
|
-
// apt-get, just `dpkg -s`, every check capped at `timeout 5` and
|
|
395
|
-
// never allowed to fail the script — a missing package or a stuck
|
|
396
|
-
// dpkg lock produces a WARNING on stderr and setup continues
|
|
397
|
-
// regardless, so this can never block (or be blocked by) the
|
|
398
|
-
// backbone router/OVN setup that follows it.
|
|
399
|
-
//
|
|
400
|
-
// Both share the same package list — iproute2 (`ip`) and iptables
|
|
401
|
-
// (`iptables`/`ip6tables`) are hard, unconditional dependencies of
|
|
402
|
-
// nearly every line this generator emits, so they're always included;
|
|
403
|
-
// dhclient/dhcpcd/wireguard-tools are added only when this topology
|
|
404
|
-
// actually uses them (derived from every uplink's discovery.client,
|
|
405
|
-
// falling back to dhclient's package when discovery.ipv4 is "dhcp"
|
|
406
|
-
// with no explicit client — same default emitIpv4Discovery uses, see
|
|
407
|
-
// generate-netns.ts — and from whether any uplink is InterfaceKind
|
|
408
|
-
// "wireguard"), not a fixed list.
|
|
409
|
-
|
|
410
|
-
const ALWAYS_REQUIRED_PACKAGES = ["iproute2", "iptables"];
|
|
411
|
-
|
|
412
|
-
const CLIENT_PACKAGE: Record<Exclude<DhcpClient, "static">, string> = {
|
|
413
|
-
dhclient: "isc-dhcp-client",
|
|
414
|
-
dhcpcd: "dhcpcd5",
|
|
415
|
-
};
|
|
416
|
-
|
|
417
|
-
function requiredPackages(uplinks: readonly Uplink[]): string[] {
|
|
418
|
-
const packages = new Set(ALWAYS_REQUIRED_PACKAGES);
|
|
419
|
-
for (const u of uplinks) {
|
|
420
|
-
const client = u.discovery?.client;
|
|
421
|
-
if (client === "dhclient" || client === "dhcpcd") {
|
|
422
|
-
packages.add(CLIENT_PACKAGE[client]);
|
|
423
|
-
} else if (client === undefined && u.discovery?.ipv4 === "dhcp") {
|
|
424
|
-
packages.add(CLIENT_PACKAGE.dhclient);
|
|
425
|
-
}
|
|
426
|
-
if (u.if.kind === "wireguard") packages.add("wireguard-tools");
|
|
427
|
-
}
|
|
428
|
-
return [...packages].sort();
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
/** Manual/first-run only — see header comment above. Indented two
|
|
432
|
-
* spaces throughout: spliced directly inside scriptForHost's `$1 !=
|
|
433
|
-
* --setup-only` block. */
|
|
434
|
-
function emitAptInstall(uplinks: readonly Uplink[]): string[] {
|
|
435
|
-
const packages = requiredPackages(uplinks);
|
|
436
|
-
return [
|
|
437
|
-
" # install required packages (this branch only runs on a manual,",
|
|
438
|
-
" # direct invocation — never on the systemd/boot path, see header",
|
|
439
|
-
" # comment on requiredPackages above)",
|
|
440
|
-
` apt-get install -y ${packages.join(" ")}`,
|
|
441
|
-
];
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/** Systemd/boot path only — see header comment above. */
|
|
445
|
-
function emitPreflightChecks(uplinks: readonly Uplink[]): string[] {
|
|
446
|
-
const lines = [
|
|
447
|
-
"# ── preflight: required tooling present? (best-effort, never ───",
|
|
448
|
-
"# blocks startup — 5s cap per package, see header comment) ─────",
|
|
449
|
-
"check_pkg() {",
|
|
450
|
-
' timeout 5 dpkg -s "$1" >/dev/null 2>&1 || ' +
|
|
451
|
-
'echo "WARNING: package \\"$1\\" not detected (or check timed out) -- continuing anyway" >&2',
|
|
452
|
-
"}",
|
|
453
|
-
];
|
|
454
|
-
for (const pkg of requiredPackages(uplinks)) lines.push(`check_pkg ${pkg}`);
|
|
455
|
-
lines.push("");
|
|
456
|
-
return lines;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
// ── per-host assembly: one self-installing script ────────────────────
|
|
460
|
-
|
|
461
|
-
function scriptForHost(
|
|
462
|
-
net: NetworkDefinition,
|
|
463
|
-
host: Host,
|
|
464
|
-
segments: readonly Segment[],
|
|
465
|
-
uplinks: readonly Uplink[],
|
|
466
|
-
): string {
|
|
467
|
-
const unitName = `topology-${net.name}`;
|
|
468
|
-
const selfPath = `/usr/local/sbin/topologie-gen/${unitName}.sh`;
|
|
469
|
-
|
|
470
|
-
const interfaceLines: string[] = [];
|
|
471
|
-
const bridgeMappings: BridgeMapping[] = [];
|
|
472
|
-
|
|
473
|
-
for (const s of segments) {
|
|
474
|
-
interfaceLines.push(...emitSegmentInterface(s));
|
|
475
|
-
bridgeMappings.push({ networkName: `seg-${s.name}`, bridge: `br-${s.name}` });
|
|
476
|
-
}
|
|
477
|
-
for (const u of uplinks) {
|
|
478
|
-
interfaceLines.push(...emitUplinkTransferInterface(u));
|
|
479
|
-
bridgeMappings.push({
|
|
480
|
-
networkName: `seg-uplink-${u.name}-transfer`,
|
|
481
|
-
bridge: uplinkTransferBridge(u),
|
|
482
|
-
});
|
|
483
|
-
if (u.backdoor !== undefined) {
|
|
484
|
-
interfaceLines.push(...emitBackdoorInterface(u));
|
|
485
|
-
bridgeMappings.push({
|
|
486
|
-
networkName: `seg-backdoor-${u.name}`,
|
|
487
|
-
bridge: backdoorBridge(u.backdoor),
|
|
488
|
-
});
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
// Single external-ids write — ovn-bridge-mappings is one
|
|
493
|
-
// comma-joined string, not additive, so every (network_name, bridge)
|
|
494
|
-
// pair for this host must be collected before emitting it. Without
|
|
495
|
-
// this, every localnet port above binds to nothing.
|
|
496
|
-
if (bridgeMappings.length > 0) {
|
|
497
|
-
const mappingValue = bridgeMappings
|
|
498
|
-
.map((m) => `${m.networkName}:${m.bridge}`)
|
|
499
|
-
.join(",");
|
|
500
|
-
interfaceLines.push(
|
|
501
|
-
"# bridge-mappings: tells ovn-controller which physical OVS " +
|
|
502
|
-
"bridge each network_name (used by localnet ports above) binds to",
|
|
503
|
-
`ovs-vsctl set open_vswitch . external-ids:ovn-bridge-mappings=${mappingValue}`,
|
|
504
|
-
"",
|
|
505
|
-
);
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
const ovnLines: string[] = [];
|
|
509
|
-
if (segments.length > 0) ovnLines.push("ovn-nbctl --may-exist ls-add sw-backbone", "");
|
|
510
|
-
for (const segment of segments) ovnLines.push(...emitSegment(segment));
|
|
511
|
-
for (const uplink of uplinks) {
|
|
512
|
-
ovnLines.push(...emitUplinkTransfer(uplink));
|
|
513
|
-
// Must come after emitUplinkTransfer above, in this same pass: a
|
|
514
|
-
// backdoor attaches to its `via` uplink's router, which only
|
|
515
|
-
// exists once `via`'s own emitUplinkTransfer has run — guaranteed
|
|
516
|
-
// by NetworkBuilder requiring `via` to be declared (and therefore
|
|
517
|
-
// iterated over) earlier (see define.ts).
|
|
518
|
-
ovnLines.push(...emitBackdoor(uplink));
|
|
519
|
-
}
|
|
520
|
-
for (const segment of segments) {
|
|
521
|
-
ovnLines.push(...emitSegmentBackboneJoin(segment));
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
// netns tier: per uplink, derive which segments currently resolve to
|
|
525
|
-
// it (AT GENERATION TIME, same rule as the backbone join above) and
|
|
526
|
-
// set up its netns/veth/SLAAC/dhclient/NAT accordingly.
|
|
527
|
-
const netnsLines: string[] = [];
|
|
528
|
-
for (const uplink of uplinks) {
|
|
529
|
-
const segmentsOnThisUplink = segments.filter(
|
|
530
|
-
(s) => s.uplink?.resolve().name === uplink.name,
|
|
531
|
-
);
|
|
532
|
-
netnsLines.push(...emitUplinkNetns(uplink, segmentsOnThisUplink));
|
|
533
|
-
// Must come after `via`'s OWN netns section has already run (its
|
|
534
|
-
// NAT rule targets `via`'s netns directly) — guaranteed by the same
|
|
535
|
-
// declaration-order requirement as emitBackdoor above. Only the NAT
|
|
536
|
-
// rule is emitted here — the backdoor's veth/address/route setup
|
|
537
|
-
// now happens INSIDE emitUplinkNetns above, since it IS this
|
|
538
|
-
// uplink's real interface (see realIfaceFor, generate-netns.ts).
|
|
539
|
-
netnsLines.push(...emitBackdoorNat(uplink, segmentsOnThisUplink));
|
|
540
|
-
}
|
|
541
|
-
|
|
542
|
-
return [
|
|
543
|
-
"#!/bin/sh",
|
|
544
|
-
`# Generated by topology-gen from network "${net.name}", host "${host.name}".`,
|
|
545
|
-
"# ONE file: copy this to the host and run it. It sets up OVS",
|
|
546
|
-
"# bridges/interfaces for each segment (no netplan), configures the",
|
|
547
|
-
"# full OVN topology (segments, uplink transfer links, backbone",
|
|
548
|
-
"# joins), sets up each uplink's netns/veth/SLAAC/dhclient/NAT, and",
|
|
549
|
-
"# installs itself as a boot-time systemd unit so it re-applies on",
|
|
550
|
-
"# every reboot. Idempotent: safe to re-run. Backbone joins and NAT",
|
|
551
|
-
"# subnets reflect each segment's UplinkSelector resolution AT",
|
|
552
|
-
"# GENERATION TIME — re-run after switching an uplink to regenerate",
|
|
553
|
-
"# against the new target.",
|
|
554
|
-
"set -e",
|
|
555
|
-
"",
|
|
556
|
-
"# ── self-install as a boot-time unit (idempotent) ──────────────",
|
|
557
|
-
`mkdir -p "$(dirname ${selfPath})"`,
|
|
558
|
-
// cp errors if $0 and selfPath are literally the same file — true
|
|
559
|
-
// when this runs as the already-installed systemd unit (ExecStart
|
|
560
|
-
// invokes selfPath directly, so $0 IS selfPath). cmp -s treats
|
|
561
|
-
// "same file" as "identical", skipping the copy safely either way.
|
|
562
|
-
`cmp -s "$0" "${selfPath}" 2>/dev/null || cp "$0" "${selfPath}"`,
|
|
563
|
-
`chmod +x "${selfPath}"`,
|
|
564
|
-
"",
|
|
565
|
-
`cat > /etc/systemd/system/${unitName}.service << 'UNIT'`,
|
|
566
|
-
"[Unit]",
|
|
567
|
-
`Description=Topology setup for network "${net.name}" (generated, no netplan)`,
|
|
568
|
-
"After=network-pre.target openvswitch-switch.service ovn-central.service",
|
|
569
|
-
"Wants=openvswitch-switch.service ovn-central.service",
|
|
570
|
-
"",
|
|
571
|
-
"[Service]",
|
|
572
|
-
"Type=oneshot",
|
|
573
|
-
`ExecStart=${selfPath} --setup-only`,
|
|
574
|
-
"RemainAfterExit=yes",
|
|
575
|
-
"",
|
|
576
|
-
"[Install]",
|
|
577
|
-
"WantedBy=multi-user.target",
|
|
578
|
-
"UNIT",
|
|
579
|
-
"",
|
|
580
|
-
"systemctl daemon-reload",
|
|
581
|
-
`systemctl enable "${unitName}.service"`,
|
|
582
|
-
"",
|
|
583
|
-
'if [ "$1" != "--setup-only" ]; then',
|
|
584
|
-
...emitAptInstall(uplinks),
|
|
585
|
-
` systemctl start "${unitName}.service"`,
|
|
586
|
-
" exit 0",
|
|
587
|
-
"fi",
|
|
588
|
-
"",
|
|
589
|
-
...emitPreflightChecks(uplinks),
|
|
590
|
-
"# ── interface setup (must run before OVN config below) ─────────",
|
|
591
|
-
...interfaceLines,
|
|
592
|
-
"# ── OVN logical topology ────────────────────────────────────────",
|
|
593
|
-
// Resolved once, here, in the emitted script — NOT queried by the
|
|
594
|
-
// generator itself (ADR 0001 §2, generator never touches live
|
|
595
|
-
// state). Every router port below needs this for gateway-chassis
|
|
596
|
-
// scheduling; on a single-chassis host this is the only chassis.
|
|
597
|
-
'CHASSIS=$(ovs-vsctl get open . external-ids:system-id | tr -d \'"\')',
|
|
598
|
-
"",
|
|
599
|
-
...ovnLines,
|
|
600
|
-
"# ── uplink netns setup (SLAAC/dhclient/NAT) ─────────────────────",
|
|
601
|
-
...netnsLines,
|
|
602
|
-
].join("\n");
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
/**
|
|
606
|
-
* One self-installing script per distinct Host referenced by the
|
|
607
|
-
* network's segments or uplinks. Keyed by host name. A single-chassis
|
|
608
|
-
* topology will always return exactly one entry; nothing here assumes
|
|
609
|
-
* that, so a multi-host topology "just works" by declaring more hosts.
|
|
610
|
-
*/
|
|
611
|
-
export function generateOvnScripts(
|
|
612
|
-
net: NetworkDefinition,
|
|
613
|
-
): ReadonlyMap<string, string> {
|
|
614
|
-
const hostsByName = new Map<string, Host>();
|
|
615
|
-
for (const s of net.allSegments) hostsByName.set(s.host.name, s.host);
|
|
616
|
-
for (const u of net.allUplinks) hostsByName.set(u.host.name, u.host);
|
|
617
|
-
|
|
618
|
-
const scripts = new Map<string, string>();
|
|
619
|
-
for (const host of hostsByName.values()) {
|
|
620
|
-
const segments = net.allSegments.filter((s) => s.host.name === host.name);
|
|
621
|
-
const uplinks = net.allUplinks.filter((u) => u.host.name === host.name);
|
|
622
|
-
scripts.set(host.name, scriptForHost(net, host, segments, uplinks));
|
|
623
|
-
}
|
|
624
|
-
return scripts;
|
|
625
|
-
}
|