@adviser/ovn-fabric 0.1.0

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.
@@ -0,0 +1,444 @@
1
+ // src/factories.ts — sparse-input factories for Uplink/Segment.
2
+ //
3
+ // net.uplink(name, uplinkVlan({ ... })) reads as: "this uplink is realized as
4
+ // a VLAN; here are the few facts that vary." Every factory here takes a
5
+ // sparse, optional-heavy input and returns an UplinkBuilder — a function
6
+ // of `slot` (the transfer-link /28 block index, see addressing.ts
7
+ // transferNet()) producing a FULLY RESOLVED object with no optional
8
+ // fields left unset. `slot` is deliberately NOT something the caller
9
+ // supplies in config/topology.ts: NetworkBuilder.uplink() (define.ts)
10
+ // assigns slots sequentially in declaration order and calls this
11
+ // function itself, so a config never has to think about slot allocation
12
+ // or its 0-4095 range at all — and two uplinks can never collide on the
13
+ // same slot, since each is assigned exactly once by NetworkBuilder.
14
+ //
15
+ // Addresses are computed from `id` via addressing.ts's fold rules
16
+ // unless the caller asks for "dhcp"/"slaac" discovery instead, in which
17
+ // case the corresponding stack's address is left to be learned at
18
+ // runtime (see ADR 0001 — the netns owns that, not this generator).
19
+ //
20
+ // This is the layer config/topology.ts actually calls. define.ts's
21
+ // NetworkBuilder.uplink()/segment() accept the UplinkBuilder/Segment
22
+ // these factories produce — they do not know about "vlan" vs "physical"
23
+ // vs sparse input at all.
24
+
25
+ import { segmentNet, transferNet } from "./addressing.ts";
26
+ import type {
27
+ Addresses,
28
+ Backdoor,
29
+ DhcpClient,
30
+ Discovery,
31
+ Host,
32
+ InterfaceKind,
33
+ Nat,
34
+ StaticIpv4,
35
+ Uplink,
36
+ WireguardInterfaceConfig,
37
+ } from "./types.ts";
38
+
39
+ /** A factory result still waiting on its assigned transfer-link
40
+ * slot(s). Takes an ALLOCATOR, not a bare slot number: most uplinks
41
+ * consume exactly one slot (their own front-door transfer link), but
42
+ * an uplink with a `backdoor` (see types.ts) needs a SECOND, entirely
43
+ * separate slot for it — sharing the front-door's /28 was tried and is
44
+ * broken (see Backdoor's doc comment). Calling allocSlot() again for
45
+ * the backdoor, from the same sequential pool NetworkBuilder already
46
+ * guarantees uniqueness over, is what keeps the two from ever
47
+ * colliding.
48
+ *
49
+ * Also receives `name` — the SAME string given to net.uplink(name,
50
+ * ...) — so a factory that needs a human-facing identifier of its own
51
+ * (e.g. uplinkWireguard's real interface/conf-file name) can reuse it
52
+ * instead of asking for a second, redundant one. Most factories ignore
53
+ * it entirely (JS/TS both allow a callback to declare fewer params
54
+ * than its type permits), which is why this was added as a second
55
+ * parameter rather than changed in place — every existing builder
56
+ * above keeps working unmodified. */
57
+ export type UplinkBuilder = (
58
+ allocSlot: () => number,
59
+ name: string,
60
+ ) => Omit<Uplink, "name">;
61
+ import {
62
+ FixedUplink,
63
+ segmentId,
64
+ type Segment,
65
+ uplinkId,
66
+ type UplinkSelector,
67
+ } from "./types.ts";
68
+
69
+ // ── shared per-stack address-or-discovery input ─────────────────────
70
+ // Each stack is either omitted (defaults to "dhcp"/"slaac" — the common
71
+ // case for a real WAN uplink, which has no fixed address to compute),
72
+ // or explicitly "dhcp"/"slaac" (same as omitted, written out for
73
+ // clarity), or a literal already-known IPAddress-parseable string for
74
+ // cases like a cable/DSL uplink's historically-stable lease.
75
+
76
+ interface AddressSpec {
77
+ readonly ipv4?: "dhcp" | string;
78
+ readonly ipv6?: "slaac" | string;
79
+ }
80
+
81
+ function resolveDiscovery(
82
+ spec: AddressSpec | undefined,
83
+ client?: DhcpClient,
84
+ static4?: StaticIpv4,
85
+ ): Discovery {
86
+ // client === "static" implies ipv4 "static" outright — the caller
87
+ // doesn't also have to remember to set addresses.ipv4: "static"
88
+ // separately for it to take effect (see emitIpv4Discovery,
89
+ // generate-netns.ts).
90
+ const ipv4 = client === "static"
91
+ ? "static"
92
+ : spec?.ipv4 === undefined || spec.ipv4 === "dhcp"
93
+ ? "dhcp"
94
+ : "static";
95
+ const ipv6 = spec?.ipv6 === undefined || spec.ipv6 === "slaac"
96
+ ? "slaac"
97
+ : "static";
98
+ return { ipv4, ipv6, client, static4 };
99
+ }
100
+
101
+ // ── vlan() ───────────────────────────────────────────────────────────
102
+
103
+ interface VlanUplinkInput {
104
+ readonly id: string | number;
105
+ readonly vlanParent: string;
106
+ /** Override the VLAN tag if it should differ from `id`. Defaults to id. */
107
+ readonly vlan?: number;
108
+ /** Override the kernel subinterface name — see InterfaceKind
109
+ * (types.ts). Only needed if `${vlanParent}.${vlanId}` is already in
110
+ * use for something else. */
111
+ readonly ifaceName?: string;
112
+ readonly nat?: Nat;
113
+ readonly host: Host;
114
+ readonly addresses?: AddressSpec;
115
+ /** Which program acquires the IPv4 lease when addresses.ipv4 is
116
+ * "dhcp" (or omitted, same default). Defaults to "dhclient" — see
117
+ * DhcpClient (types.ts). Set to "static" (with `static4` below) for
118
+ * a real interface whose address is fixed/known rather than leased. */
119
+ readonly client?: DhcpClient;
120
+ /** Only consulted when client === "static" — see StaticIpv4 (types.ts). */
121
+ readonly static4?: StaticIpv4;
122
+ }
123
+
124
+ export function uplinkVlan(input: VlanUplinkInput): UplinkBuilder {
125
+ const id = uplinkId(
126
+ typeof input.id === "string" ? Number.parseInt(input.id, 10) : input.id,
127
+ );
128
+
129
+ return (allocSlot: () => number) => {
130
+ const slot = allocSlot();
131
+ const ifc: InterfaceKind = {
132
+ kind: "vlan",
133
+ vlanParent: input.vlanParent,
134
+ vlanId: input.vlan ?? id,
135
+ ifaceName: input.ifaceName,
136
+ };
137
+
138
+ const addresses: Addresses = [
139
+ transferNet(id, slot, 1), // OVN side
140
+ transferNet(id, slot, 2), // netns side
141
+ ];
142
+
143
+ return {
144
+ slot,
145
+ addresses,
146
+ if: ifc,
147
+ nat: input.nat,
148
+ discovery: resolveDiscovery(input.addresses, input.client, input.static4),
149
+ host: input.host,
150
+ };
151
+ };
152
+ }
153
+
154
+ // ── physical() ───────────────────────────────────────────────────────
155
+ // The raw case: a real, untagged interface (e.g. { name: "ens19" }).
156
+ // No VLAN math, no transfer-link addressing — used for an uplink
157
+ // (or segment) that's wired directly, no 802.1Q tagging involved.
158
+
159
+ interface PhysicalUplinkInput {
160
+ readonly id: string | number;
161
+ readonly name: string;
162
+ readonly nat?: Nat;
163
+ readonly host: Host;
164
+ readonly addresses?: AddressSpec;
165
+ /** Which program acquires the IPv4 lease when addresses.ipv4 is
166
+ * "dhcp" (or omitted, same default). Defaults to "dhclient" — see
167
+ * DhcpClient (types.ts). Set to "static" (with `static4` below) for
168
+ * a real interface whose address is fixed/known rather than leased. */
169
+ readonly client?: DhcpClient;
170
+ /** Only consulted when client === "static" — see StaticIpv4 (types.ts). */
171
+ readonly static4?: StaticIpv4;
172
+ }
173
+
174
+ export function uplinkPhysical(input: PhysicalUplinkInput): UplinkBuilder {
175
+ const id = uplinkId(
176
+ typeof input.id === "string" ? Number.parseInt(input.id, 10) : input.id,
177
+ );
178
+
179
+ return (allocSlot: () => number) => {
180
+ const slot = allocSlot();
181
+ const ifc: InterfaceKind = { kind: "physical", name: input.name };
182
+
183
+ const addresses: Addresses = [
184
+ transferNet(id, slot, 1),
185
+ transferNet(id, slot, 2),
186
+ ];
187
+
188
+ return {
189
+ slot,
190
+ addresses,
191
+ if: ifc,
192
+ nat: input.nat,
193
+ discovery: resolveDiscovery(input.addresses, input.client, input.static4),
194
+ host: input.host,
195
+ };
196
+ };
197
+ }
198
+
199
+ // ── dummy() ──────────────────────────────────────────────────────────
200
+ // A placeholder uplink with no real backing interface — see
201
+ // InterfaceKind's "dummy" variant (types.ts). Lets the OVN
202
+ // router/transfer-link/backbone-join chain be built and pinged
203
+ // end-to-end before the real mechanism (e.g. a WireGuard tunnel)
204
+ // exists. No `name`/`vlanParent` to supply — the generator derives and
205
+ // creates the actual kernel interface itself, at a slot-based name.
206
+
207
+ interface DummyUplinkInput {
208
+ readonly id: string | number;
209
+ readonly nat?: Nat;
210
+ readonly host: Host;
211
+ readonly addresses?: AddressSpec;
212
+ readonly client?: DhcpClient;
213
+ /** Borrow real egress from another, already-working uplink — see
214
+ * Backdoor (types.ts). Needed for any VPN-like uplink (WireGuard,
215
+ * ZeroTier, ...): the dummy/tunnel interface itself carries no real
216
+ * traffic path, so something else has to get this uplink's own netns
217
+ * onto the real internet. `via` must already be declared (e.g.
218
+ * isp-primary), earlier in the same defineNetwork call. */
219
+ readonly backdoor?: { readonly via: Uplink };
220
+ }
221
+
222
+ export function uplinkDummy(input: DummyUplinkInput): UplinkBuilder {
223
+ const id = uplinkId(
224
+ typeof input.id === "string" ? Number.parseInt(input.id, 10) : input.id,
225
+ );
226
+
227
+ return (allocSlot: () => number) => {
228
+ const slot = allocSlot();
229
+ const ifc: InterfaceKind = { kind: "dummy" };
230
+
231
+ const addresses: Addresses = [
232
+ transferNet(id, slot, 1),
233
+ transferNet(id, slot, 2),
234
+ ];
235
+
236
+ // Backdoor gets its OWN slot — allocSlot() called a second time,
237
+ // from the same sequential pool as every uplink's front-door
238
+ // transfer link, so its /28 (IPv4) can never overlap with this
239
+ // uplink's own `addresses` above (see Backdoor's doc comment,
240
+ // types.ts, for why that overlap is a real bug and not just
241
+ // untidy). host=3/4 here, not 1/2: transferNet's IPv6 fold keys
242
+ // only on (id, host), NOT slot, so reusing host 1/2 under the SAME
243
+ // id would fold to the IDENTICAL IPv6 addresses as this uplink's
244
+ // own front-door transfer link above, even though slot keeps the
245
+ // IPv4 side distinct. 3/4 keeps both stacks distinct.
246
+ const backdoor: Backdoor | undefined = input.backdoor === undefined
247
+ ? undefined
248
+ : (() => {
249
+ const bdSlot = allocSlot();
250
+ return {
251
+ via: input.backdoor!.via,
252
+ slot: bdSlot,
253
+ addresses: [
254
+ transferNet(id, bdSlot, 3),
255
+ transferNet(id, bdSlot, 4),
256
+ ],
257
+ };
258
+ })();
259
+
260
+ return {
261
+ slot,
262
+ addresses,
263
+ if: ifc,
264
+ nat: input.nat,
265
+ // Defaults to "static"/"static" (no discovery) unlike
266
+ // uplinkVlan/uplinkPhysical's "dhcp" default — a dummy interface
267
+ // has no real ISP behind it, so dhclient/dhcpcd would just hang
268
+ // forever waiting for a lease that never comes. Still overridable
269
+ // via `addresses`, in case a future test wants to exercise the
270
+ // discovery path against a dummy on purpose.
271
+ discovery: resolveDiscovery(
272
+ input.addresses ?? { ipv4: "static", ipv6: "static" },
273
+ input.client,
274
+ ),
275
+ backdoor,
276
+ host: input.host,
277
+ };
278
+ };
279
+ }
280
+
281
+ // ── wireguard() ──────────────────────────────────────────────────────
282
+ // A real WireGuard tunnel — see InterfaceKind's "wireguard" variant
283
+ // (types.ts) for why this shells out to wg-quick against an
284
+ // already-on-disk conf file rather than embedding key material here.
285
+ // Almost always paired with `backdoor` (the tunnel's own
286
+ // handshake/keepalive traffic needs a mundane path to the real
287
+ // internet, same as uplinkDummy() — see Backdoor, types.ts), but not
288
+ // required: an uplink whose netns can already reach the internet some
289
+ // other way (e.g. it inherited a route) wouldn't need one.
290
+
291
+ interface WireguardUplinkInput {
292
+ readonly id: string | number;
293
+ /** Override the real kernel interface name AND the .conf's basename
294
+ * on disk (e.g. "mull-fra" -> /etc/wireguard/mull-fra.conf). Defaults
295
+ * to this uplink's OWN name (the string given to net.uplink(name,
296
+ * ...)) — redundant to repeat that in most cases, since it's already
297
+ * unique per uplink. Only needed when that default doesn't fit: it
298
+ * exceeds IFNAMSIZ (15 usable characters), or a real interface under
299
+ * a different name already exists and renaming it would mean
300
+ * bouncing a live tunnel unnecessarily. */
301
+ readonly ifaceName?: string;
302
+ /** The full wg-quick conf, declared directly here — see
303
+ * WireguardInterfaceConfig and InterfaceKind's "wireguard" variant
304
+ * (types.ts) for why the PrivateKey lives in plain sight in this
305
+ * file rather than behind an env var, for this uplink specifically. */
306
+ readonly config: WireguardInterfaceConfig;
307
+ readonly nat?: Nat;
308
+ readonly host: Host;
309
+ /** Bootstrap egress for the tunnel's own setup/keepalive traffic —
310
+ * see Backdoor (types.ts) and emitBackdoorNat (generate-netns.ts),
311
+ * which scopes the resulting NAT rule to the backdoor's OWN transit
312
+ * address (not any segment subnet) whenever the owning uplink has a
313
+ * real interface of its own, as this one does. */
314
+ readonly backdoor?: { readonly via: Uplink };
315
+ }
316
+
317
+ export function uplinkWireguard(input: WireguardUplinkInput): UplinkBuilder {
318
+ const id = uplinkId(
319
+ typeof input.id === "string" ? Number.parseInt(input.id, 10) : input.id,
320
+ );
321
+
322
+ return (allocSlot: () => number, name: string) => {
323
+ const slot = allocSlot();
324
+ const ifc: InterfaceKind = {
325
+ kind: "wireguard",
326
+ ifaceName: input.ifaceName ?? name,
327
+ config: input.config,
328
+ };
329
+
330
+ const addresses: Addresses = [
331
+ transferNet(id, slot, 1),
332
+ transferNet(id, slot, 2),
333
+ ];
334
+
335
+ // Same separate-slot reasoning as uplinkDummy()'s backdoor above —
336
+ // see Backdoor's doc comment (types.ts) for why sharing a /28 with
337
+ // this uplink's own front-door transfer link is broken, not just
338
+ // untidy.
339
+ const backdoor: Backdoor | undefined = input.backdoor === undefined
340
+ ? undefined
341
+ : (() => {
342
+ const bdSlot = allocSlot();
343
+ return {
344
+ via: input.backdoor!.via,
345
+ slot: bdSlot,
346
+ addresses: [
347
+ transferNet(id, bdSlot, 3),
348
+ transferNet(id, bdSlot, 4),
349
+ ],
350
+ };
351
+ })();
352
+
353
+ return {
354
+ slot,
355
+ addresses,
356
+ if: ifc,
357
+ nat: input.nat,
358
+ // wg-quick manages the tunnel's own address (and, via its
359
+ // fwmark/policy-routing dance, its own default route) entirely —
360
+ // nothing for dhclient/dhcpcd/static to do on this interface.
361
+ discovery: { ipv4: "static", ipv6: "static" },
362
+ backdoor,
363
+ host: input.host,
364
+ };
365
+ };
366
+ }
367
+
368
+ // ── segment factories ─────────────────────────────────────────────
369
+ // Mirror the uplink factories above, but produce a Segment-shaped
370
+ // result (adds `uplink`, uses segmentNet() instead of transferNet()
371
+ // since a segment has a client-facing gateway address, not a
372
+ // transfer-link pair).
373
+
374
+ /** input.uplink -> UplinkSelector | undefined. Omitting uplink entirely
375
+ * means "no egress yet" (see Segment.uplink, types.ts) — a deliberate,
376
+ * representable state, not an oversight. */
377
+ function resolveUplinkSelector(
378
+ uplink: Uplink | UplinkSelector | undefined,
379
+ ): UplinkSelector | undefined {
380
+ if (uplink === undefined) return undefined;
381
+ return "resolve" in uplink ? uplink : new FixedUplink(uplink);
382
+ }
383
+
384
+ interface SegmentPhysicalInput {
385
+ readonly id: string | number;
386
+ readonly name: string;
387
+ readonly uplink?: Uplink | UplinkSelector;
388
+ readonly nat?: Nat;
389
+ /** Advertise RA/SLAAC for this segment's IPv6 prefix. Defaults to true. */
390
+ readonly slaac?: boolean;
391
+ readonly host: Host;
392
+ }
393
+
394
+ export function segmentPhysical(
395
+ input: SegmentPhysicalInput,
396
+ ): Omit<Segment, "name"> {
397
+ const id = segmentId(
398
+ typeof input.id === "string" ? Number.parseInt(input.id, 10) : input.id,
399
+ );
400
+ return {
401
+ addresses: [segmentNet(id, 2)],
402
+ if: { kind: "physical", name: input.name },
403
+ uplink: resolveUplinkSelector(input.uplink),
404
+ nat: input.nat,
405
+ slaac: input.slaac ?? true,
406
+ host: input.host,
407
+ };
408
+ }
409
+
410
+ interface SegmentVlanInput {
411
+ readonly id: string | number;
412
+ readonly vlanParent: string;
413
+ /** Override the VLAN tag if it should differ from `id`. Defaults to id. */
414
+ readonly vlan?: number;
415
+ /** Override the kernel subinterface name — see InterfaceKind
416
+ * (types.ts). Only needed if `${vlanParent}.${vlanId}` is already in
417
+ * use for something else (e.g. VLAN 129 already carries this host's
418
+ * own management IP on `ens18.129` outside OVS). */
419
+ readonly ifaceName?: string;
420
+ readonly uplink?: Uplink | UplinkSelector;
421
+ readonly nat?: Nat;
422
+ /** Advertise RA/SLAAC for this segment's IPv6 prefix. Defaults to true. */
423
+ readonly slaac?: boolean;
424
+ readonly host: Host;
425
+ }
426
+
427
+ export function segmentVlan(input: SegmentVlanInput): Omit<Segment, "name"> {
428
+ const id = segmentId(
429
+ typeof input.id === "string" ? Number.parseInt(input.id, 10) : input.id,
430
+ );
431
+ return {
432
+ addresses: [segmentNet(id, 2)],
433
+ if: {
434
+ kind: "vlan",
435
+ vlanParent: input.vlanParent,
436
+ vlanId: input.vlan ?? id,
437
+ ifaceName: input.ifaceName,
438
+ },
439
+ uplink: resolveUplinkSelector(input.uplink),
440
+ nat: input.nat,
441
+ slaac: input.slaac ?? true,
442
+ host: input.host,
443
+ };
444
+ }