@adviser/ovn-fabric 0.1.1 → 0.1.3

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.
@@ -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
- }