@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.
- package/LICENSE +201 -0
- package/README.md +131 -0
- package/bin/ovn-fabric.js +43 -0
- package/package.json +38 -0
- package/src/addressing.ts +222 -0
- package/src/cli.ts +163 -0
- package/src/define.ts +187 -0
- package/src/factories.ts +444 -0
- package/src/generate-netns.ts +622 -0
- package/src/generate-ovn.ts +625 -0
- package/src/types.ts +408 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// src/cli.ts — the CLI entry point.
|
|
2
|
+
// Library code: defines the command surface. Does not know about any
|
|
3
|
+
// concrete topology — the config module path is supplied at the
|
|
4
|
+
// command line, imported dynamically. The config module must export a
|
|
5
|
+
// NetworkDefinition (the return value of defineNetwork()), under any
|
|
6
|
+
// export name — the loader looks for the first export matching that
|
|
7
|
+
// shape rather than requiring a specific name, since defineNetwork
|
|
8
|
+
// already collects allUplinks/allSegments and a second, separately
|
|
9
|
+
// named re-export of the same data would just be duplication.
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
command,
|
|
13
|
+
positional,
|
|
14
|
+
run,
|
|
15
|
+
string,
|
|
16
|
+
subcommands,
|
|
17
|
+
} from "npm:cmd-ts@0.13.0";
|
|
18
|
+
|
|
19
|
+
import { generateOvnScripts } from "./generate-ovn.ts";
|
|
20
|
+
import type { NetworkDefinition } from "./define.ts";
|
|
21
|
+
import type { Host, InterfaceKind } from "./types.ts";
|
|
22
|
+
|
|
23
|
+
function describeAccess(host: Host): string {
|
|
24
|
+
return host.access.method === "ssh"
|
|
25
|
+
? `ssh ${host.access.user}@${host.address}`
|
|
26
|
+
: `local`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function describeInterface(ifc: InterfaceKind): string {
|
|
30
|
+
switch (ifc.kind) {
|
|
31
|
+
case "vlan":
|
|
32
|
+
return `vlan ${ifc.vlanId} on ${ifc.vlanParent}` +
|
|
33
|
+
(ifc.ifaceName ? ` (as ${ifc.ifaceName})` : "");
|
|
34
|
+
case "physical":
|
|
35
|
+
return `physical ${ifc.name}`;
|
|
36
|
+
case "bridge-port":
|
|
37
|
+
return `bridge-port ${ifc.port} on ${ifc.bridge}`;
|
|
38
|
+
case "wireguard":
|
|
39
|
+
return `wireguard ${ifc.ifaceName} -> ${ifc.config.peer.endpoint}`;
|
|
40
|
+
case "dummy":
|
|
41
|
+
return `dummy (placeholder, no real interface)`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isNetworkDefinition(value: unknown): value is NetworkDefinition {
|
|
46
|
+
return (
|
|
47
|
+
typeof value === "object" &&
|
|
48
|
+
value !== null &&
|
|
49
|
+
"name" in value &&
|
|
50
|
+
"allUplinks" in value &&
|
|
51
|
+
"allSegments" in value &&
|
|
52
|
+
Array.isArray((value as NetworkDefinition).allUplinks) &&
|
|
53
|
+
Array.isArray((value as NetworkDefinition).allSegments)
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function loadConfig(configPath: string): Promise<NetworkDefinition> {
|
|
58
|
+
const resolved = await Deno.realPath(configPath);
|
|
59
|
+
const mod = await import(`file://${resolved}`);
|
|
60
|
+
|
|
61
|
+
const found = Object.values(mod).find(isNetworkDefinition);
|
|
62
|
+
if (found === undefined) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`${configPath} does not export a NetworkDefinition ` +
|
|
65
|
+
`(the return value of defineNetwork(...))`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
return found;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const generate = command({
|
|
72
|
+
name: "generate",
|
|
73
|
+
description:
|
|
74
|
+
"Load a topology config and report what it declares.",
|
|
75
|
+
args: {
|
|
76
|
+
configPath: positional({
|
|
77
|
+
type: string,
|
|
78
|
+
displayName: "config-path",
|
|
79
|
+
description: "Path to a topology config module (e.g. config/topology.ts)",
|
|
80
|
+
}),
|
|
81
|
+
},
|
|
82
|
+
handler: async ({ configPath }) => {
|
|
83
|
+
const net = await loadConfig(configPath);
|
|
84
|
+
|
|
85
|
+
console.log(`Loaded network: ${net.name} (from ${configPath})`);
|
|
86
|
+
console.log(` uplinks: ${net.allUplinks.length}`);
|
|
87
|
+
for (const u of net.allUplinks) {
|
|
88
|
+
const addrSummary = u.addresses
|
|
89
|
+
.map((a) => `${a.ipv4.to_string()} / ${a.ipv6.to_string()}`)
|
|
90
|
+
.join(", ");
|
|
91
|
+
console.log(
|
|
92
|
+
` - ${u.name} (if=${describeInterface(u.if)}, ` +
|
|
93
|
+
`addresses=[${addrSummary}], host=${u.host.name} [${describeAccess(u.host)}])`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
console.log(` segments: ${net.allSegments.length}`);
|
|
97
|
+
for (const s of net.allSegments) {
|
|
98
|
+
// undefined uplink = deliberately isolated, no egress yet (see
|
|
99
|
+
// Segment.uplink, types.ts) — not a missing-data bug.
|
|
100
|
+
const selectorKind = s.uplink?.constructor.name ?? "(none)";
|
|
101
|
+
const uplinkName = s.uplink?.resolve().name ?? "(isolated, no uplink)";
|
|
102
|
+
const addrSummary = s.addresses
|
|
103
|
+
.map((a) => `${a.ipv4.to_string()} / ${a.ipv6.to_string()}`)
|
|
104
|
+
.join(", ");
|
|
105
|
+
console.log(
|
|
106
|
+
` - ${s.name} (if=${describeInterface(s.if)}, ` +
|
|
107
|
+
`addresses=[${addrSummary}], uplink=${uplinkName}, ` +
|
|
108
|
+
`selector=${selectorKind}, host=${s.host.name} [${describeAccess(s.host)}])`,
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const generateOvn = command({
|
|
115
|
+
name: "generate-ovn",
|
|
116
|
+
description:
|
|
117
|
+
"Emit ONE self-installing shell script per host: copy it to the " +
|
|
118
|
+
"host and run it. Sets up OVS bridges/interfaces (no netplan), " +
|
|
119
|
+
"configures the full OVN topology, and installs itself as a " +
|
|
120
|
+
"boot-time systemd unit (see generate-ovn.ts header comment). " +
|
|
121
|
+
"Pure text output; does not execute anything itself.",
|
|
122
|
+
args: {
|
|
123
|
+
configPath: positional({
|
|
124
|
+
type: string,
|
|
125
|
+
displayName: "config-path",
|
|
126
|
+
description: "Path to a topology config module (e.g. config/topology.ts)",
|
|
127
|
+
}),
|
|
128
|
+
},
|
|
129
|
+
handler: async ({ configPath }) => {
|
|
130
|
+
const net = await loadConfig(configPath);
|
|
131
|
+
const scripts = generateOvnScripts(net);
|
|
132
|
+
// The separator must NEVER be the first line of the combined
|
|
133
|
+
// output: each per-host script is meant to be saved as-is and run
|
|
134
|
+
// directly (including by systemd's ExecStart, which execs the file
|
|
135
|
+
// itself rather than piping it through a shell) — a leading
|
|
136
|
+
// comment line before "#!/bin/sh" breaks that exec entirely
|
|
137
|
+
// (ENOEXEC), silently no-op'ing the whole setup. Confirmed live,
|
|
138
|
+
// 2026-07-06: the installed copy had this marker as line 1, so the
|
|
139
|
+
// boot-time systemd unit never ran a single command, and nothing
|
|
140
|
+
// it was meant to create (e.g. br-bd-4) ever existed. `sh
|
|
141
|
+
// script.sh` tolerates it fine (comments are skipped) — only
|
|
142
|
+
// direct exec doesn't — so this only ever shows up in the one path
|
|
143
|
+
// this generator is actually designed around. Only print the
|
|
144
|
+
// separator BETWEEN scripts (today, with one host declared, it
|
|
145
|
+
// never prints at all).
|
|
146
|
+
let first = true;
|
|
147
|
+
for (const [hostName, script] of scripts) {
|
|
148
|
+
if (!first) console.log(`# ===== host: ${hostName} =====`);
|
|
149
|
+
first = false;
|
|
150
|
+
console.log(script);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const app = subcommands({
|
|
156
|
+
name: "ovn-fabric",
|
|
157
|
+
description: "Declarative OVN/OVS topology generator CLI",
|
|
158
|
+
cmds: { generate, "generate-ovn": generateOvn },
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (import.meta.main) {
|
|
162
|
+
await run(app, Deno.args);
|
|
163
|
+
}
|
package/src/define.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
// src/define.ts — defineNetwork(): a Vite-defineConfig / Jest-describe
|
|
2
|
+
// style builder. Lets config/topology.ts read as a declaration rather
|
|
3
|
+
// than a set of raw object literals, and validates as it goes (e.g. a
|
|
4
|
+
// segment can only reference an uplink that was already declared in
|
|
5
|
+
// the same defineNetwork call).
|
|
6
|
+
|
|
7
|
+
import type { UplinkBuilder } from "./factories.ts";
|
|
8
|
+
import {
|
|
9
|
+
FixedUplink,
|
|
10
|
+
type Host,
|
|
11
|
+
localHost,
|
|
12
|
+
type Segment,
|
|
13
|
+
sshHost,
|
|
14
|
+
type Uplink,
|
|
15
|
+
type UplinkSelector,
|
|
16
|
+
} from "./types.ts";
|
|
17
|
+
|
|
18
|
+
export interface NetworkDefinition {
|
|
19
|
+
readonly name: string;
|
|
20
|
+
readonly allUplinks: readonly Uplink[];
|
|
21
|
+
readonly allSegments: readonly Segment[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* net.uplink(name, uplinkVlan({...})) / net.uplink(name, uplinkPhysical({...})) —
|
|
26
|
+
* the second argument is an UplinkBuilder: a function of `slot` that a
|
|
27
|
+
* factory in factories.ts returned. NetworkBuilder assigns the next
|
|
28
|
+
* free slot (0-4095, see addressing.ts transferNet()) and calls it —
|
|
29
|
+
* config/topology.ts never sees or chooses a slot itself, so two
|
|
30
|
+
* uplinks can never collide on the same transfer-link block.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
type SegmentSpec = Omit<Segment, "name">;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The builder context passed into defineNetwork's callback. Each method
|
|
37
|
+
* both registers the declared thing and returns a handle to it, so later
|
|
38
|
+
* calls in the same callback can reference earlier ones directly
|
|
39
|
+
* (`net.segment("home", { uplink: avm, ... })`), the same way Jest's
|
|
40
|
+
* `describe`/`it` or Vite's defineConfig read top-to-bottom.
|
|
41
|
+
*/
|
|
42
|
+
export class NetworkBuilder {
|
|
43
|
+
private readonly uplinksByName = new Map<string, Uplink>();
|
|
44
|
+
private readonly usedUplinkIds = new Set<number>();
|
|
45
|
+
private nextSlot = 0;
|
|
46
|
+
private readonly segmentsByName = new Map<string, Segment>();
|
|
47
|
+
private readonly usedSegmentIds = new Set<number>();
|
|
48
|
+
private readonly hostsByName = new Map<string, Host>();
|
|
49
|
+
|
|
50
|
+
/** Declare a host reachable via SSH. Returns a handle for reuse. */
|
|
51
|
+
sshHost(name: string, address: string, user: string): Host {
|
|
52
|
+
if (this.hostsByName.has(name)) {
|
|
53
|
+
throw new Error(`host "${name}" declared more than once`);
|
|
54
|
+
}
|
|
55
|
+
const host = sshHost(name, address, user);
|
|
56
|
+
this.hostsByName.set(name, host);
|
|
57
|
+
return host;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Declare the generator's own host — no SSH needed. */
|
|
61
|
+
localHost(name: string): Host {
|
|
62
|
+
if (this.hostsByName.has(name)) {
|
|
63
|
+
throw new Error(`host "${name}" declared more than once`);
|
|
64
|
+
}
|
|
65
|
+
const host = localHost(name);
|
|
66
|
+
this.hostsByName.set(name, host);
|
|
67
|
+
return host;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
uplink(name: string, builder: UplinkBuilder): Uplink {
|
|
71
|
+
if (this.uplinksByName.has(name)) {
|
|
72
|
+
throw new Error(`uplink "${name}" declared more than once`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Passed as an allocator, not a single slot: most uplinks call this
|
|
76
|
+
// once (their own front-door transfer link), but one with a
|
|
77
|
+
// `backdoor` (see Backdoor, types.ts) calls it again for the
|
|
78
|
+
// backdoor's own, separate slot — same sequential pool either way,
|
|
79
|
+
// so nothing declared via this builder can ever collide on a slot.
|
|
80
|
+
const allocSlot = (): number => {
|
|
81
|
+
if (this.nextSlot > 4095) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
"no transfer-link slots remaining (4096 max, see addressing.ts " +
|
|
84
|
+
"transferNet()) — this network has more uplinks (and/or " +
|
|
85
|
+
"backdoors) than the 10.99.0.0/16 transfer-link space can " +
|
|
86
|
+
"address",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return this.nextSlot++;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const spec = builder(allocSlot, name);
|
|
93
|
+
|
|
94
|
+
const id = spec.addresses[0]?.id();
|
|
95
|
+
if (id !== undefined && this.usedUplinkIds.has(id)) {
|
|
96
|
+
const existing = [...this.uplinksByName.entries()]
|
|
97
|
+
.find(([, u]) => u.addresses[0]?.id() === id)?.[0];
|
|
98
|
+
throw new Error(
|
|
99
|
+
`uplink "${name}" reuses id ${id}, ` +
|
|
100
|
+
`already used by uplink "${existing}"`,
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Same "must already be declared via this builder" fail-fast as
|
|
105
|
+
// segment()'s uplink check below — a backdoor's `via` has to be a
|
|
106
|
+
// real, already-registered uplink, not an arbitrary object that
|
|
107
|
+
// happens to match the Uplink shape (and, since it must be
|
|
108
|
+
// declared BEFORE this one, its own emitUplinkTransfer/netns setup
|
|
109
|
+
// is guaranteed to already exist by the time this uplink's backdoor
|
|
110
|
+
// is emitted — see generate-ovn.ts, scriptForHost).
|
|
111
|
+
if (
|
|
112
|
+
spec.backdoor !== undefined &&
|
|
113
|
+
!this.uplinksByName.has(spec.backdoor.via.name)
|
|
114
|
+
) {
|
|
115
|
+
throw new Error(
|
|
116
|
+
`uplink "${name}" has a backdoor via "${spec.backdoor.via.name}", ` +
|
|
117
|
+
`which was not declared via net.uplink() before this uplink`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const uplink: Uplink = { name, ...spec };
|
|
122
|
+
|
|
123
|
+
this.uplinksByName.set(name, uplink);
|
|
124
|
+
if (id !== undefined) this.usedUplinkIds.add(id);
|
|
125
|
+
return uplink;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
segment(name: string, spec: SegmentSpec): Segment {
|
|
129
|
+
if (this.segmentsByName.has(name)) {
|
|
130
|
+
throw new Error(`segment "${name}" declared more than once`);
|
|
131
|
+
}
|
|
132
|
+
const id = spec.addresses[0]?.id();
|
|
133
|
+
if (id !== undefined && this.usedSegmentIds.has(id)) {
|
|
134
|
+
const existing = [...this.segmentsByName.entries()]
|
|
135
|
+
.find(([, s]) => s.addresses[0]?.id() === id)?.[0];
|
|
136
|
+
throw new Error(
|
|
137
|
+
`segment "${name}" reuses id ${id}, ` +
|
|
138
|
+
`already used by segment "${existing}"`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// undefined means "no uplink yet, deliberately isolated" (see
|
|
143
|
+
// Segment.uplink, types.ts) — not every segment resolves to
|
|
144
|
+
// something, so this must be checked before the "resolve" in
|
|
145
|
+
// probe below, which throws on undefined.
|
|
146
|
+
const selector: UplinkSelector | undefined = spec.uplink === undefined
|
|
147
|
+
? undefined
|
|
148
|
+
: ("resolve" in spec.uplink ? spec.uplink : new FixedUplink(spec.uplink));
|
|
149
|
+
|
|
150
|
+
// fail fast: if an uplink WAS given, it must have been declared via
|
|
151
|
+
// this same builder, not an arbitrary object that happens to match
|
|
152
|
+
// the Uplink shape.
|
|
153
|
+
if (selector !== undefined) {
|
|
154
|
+
const resolved = selector.resolve();
|
|
155
|
+
if (!this.uplinksByName.has(resolved.name)) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`segment "${name}" references an uplink ("${resolved.name}") ` +
|
|
158
|
+
`that was not declared via net.uplink() in this defineNetwork call`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const segment: Segment = { name, ...spec, uplink: selector };
|
|
164
|
+
|
|
165
|
+
this.segmentsByName.set(name, segment);
|
|
166
|
+
if (id !== undefined) this.usedSegmentIds.add(id);
|
|
167
|
+
return segment;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** @internal used by defineNetwork to extract the final declarations */
|
|
171
|
+
build(name: string): NetworkDefinition {
|
|
172
|
+
return {
|
|
173
|
+
name,
|
|
174
|
+
allUplinks: [...this.uplinksByName.values()],
|
|
175
|
+
allSegments: [...this.segmentsByName.values()],
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function defineNetwork(
|
|
181
|
+
name: string,
|
|
182
|
+
build: (net: NetworkBuilder) => void,
|
|
183
|
+
): NetworkDefinition {
|
|
184
|
+
const builder = new NetworkBuilder();
|
|
185
|
+
build(builder);
|
|
186
|
+
return builder.build(name);
|
|
187
|
+
}
|