@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.
- package/README.md +19 -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/README.md
CHANGED
|
@@ -17,33 +17,41 @@ a disposable, regeneratable artifact.
|
|
|
17
17
|
|
|
18
18
|
**Via npx** (no local Deno install required — the official
|
|
19
19
|
[`deno`](https://www.npmjs.com/package/deno) npm package is pulled in
|
|
20
|
-
automatically as a dependency the first time you run this
|
|
20
|
+
automatically as a dependency the first time you run this; note the package
|
|
21
|
+
is scoped, so it's `npx @adviser/ovn-fabric`, not `npx ovn-fabric`):
|
|
21
22
|
|
|
22
23
|
```sh
|
|
23
|
-
npx ovn-fabric generate-ovn path/to/topology.ts > install.sh
|
|
24
|
+
npx @adviser/ovn-fabric generate-ovn path/to/topology.ts > install.sh
|
|
24
25
|
```
|
|
25
26
|
|
|
26
|
-
**Via Deno**, if you already have it
|
|
27
|
+
**Via Deno**, if you already have it (the CLI is the `/cli` export — the bare
|
|
28
|
+
package name is the library, for use from your own `topology.ts`, see below):
|
|
27
29
|
|
|
28
30
|
```sh
|
|
29
|
-
deno run -A jsr:@adviser/ovn-fabric generate-ovn path/to/topology.ts > install.sh
|
|
31
|
+
deno run -A jsr:@adviser/ovn-fabric/cli generate-ovn path/to/topology.ts > install.sh
|
|
30
32
|
```
|
|
31
33
|
|
|
32
34
|
**As a permanent global command**:
|
|
33
35
|
|
|
34
36
|
```sh
|
|
35
|
-
deno install -g -A -n ovn-fabric jsr:@adviser/ovn-fabric
|
|
37
|
+
deno install -g -A -n ovn-fabric jsr:@adviser/ovn-fabric/cli
|
|
36
38
|
```
|
|
37
39
|
|
|
38
40
|
## Quickstart
|
|
39
41
|
|
|
40
42
|
Copy [`examples/minimal-topology.ts`](examples/minimal-topology.ts) as a
|
|
41
|
-
starting point
|
|
43
|
+
starting point. Everything you need to declare a topology comes from the one
|
|
44
|
+
package import — `defineNetwork`, every uplink/segment factory, and the
|
|
45
|
+
public types (`Host`, `Uplink`, `Segment`, `ManualUplink`, ...) all live at
|
|
46
|
+
the same `@adviser/ovn-fabric` / `jsr:@adviser/ovn-fabric` path:
|
|
42
47
|
|
|
43
48
|
```ts
|
|
44
|
-
import {
|
|
45
|
-
|
|
46
|
-
|
|
49
|
+
import {
|
|
50
|
+
defineNetwork,
|
|
51
|
+
segmentPhysical,
|
|
52
|
+
uplinkPhysical,
|
|
53
|
+
ManualUplink,
|
|
54
|
+
} from "jsr:@adviser/ovn-fabric";
|
|
47
55
|
|
|
48
56
|
export const network = defineNetwork("minimal", (net) => {
|
|
49
57
|
const host = net.localHost("this-host");
|
|
@@ -68,8 +76,8 @@ export const network = defineNetwork("minimal", (net) => {
|
|
|
68
76
|
Then:
|
|
69
77
|
|
|
70
78
|
```sh
|
|
71
|
-
npx ovn-fabric generate topology.ts # sanity-check what it declares
|
|
72
|
-
npx ovn-fabric generate-ovn topology.ts # emit the install script(s)
|
|
79
|
+
npx @adviser/ovn-fabric generate topology.ts # sanity-check what it declares
|
|
80
|
+
npx @adviser/ovn-fabric generate-ovn topology.ts # emit the install script(s)
|
|
73
81
|
```
|
|
74
82
|
|
|
75
83
|
`generate-ovn` prints one script per distinct `Host` your config declares.
|
package/bin/ovn-fabric.js
CHANGED
|
@@ -1,27 +1,44 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// bin/ovn-fabric.js — the npx/npm entry point.
|
|
3
3
|
//
|
|
4
|
-
// ovn-fabric itself is a Deno/TypeScript CLI (see src/cli.ts
|
|
5
|
-
// shim is the only piece of the npm
|
|
6
|
-
//
|
|
7
|
-
// automatically as this package's own npm dependency — see
|
|
4
|
+
// ovn-fabric itself is a Deno/TypeScript CLI (see src/cli.ts, exported
|
|
5
|
+
// as "./cli" from deno.json) — this shim is the only piece of the npm
|
|
6
|
+
// distribution that runs under Node. It locates the `deno` executable
|
|
7
|
+
// (installed automatically as this package's own npm dependency — see
|
|
8
8
|
// https://www.npmjs.com/package/deno, the official, Deno-team-
|
|
9
|
-
// maintained npm distribution of the Deno runtime itself) and
|
|
10
|
-
// to it
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
9
|
+
// maintained npm distribution of the Deno runtime itself) and hands
|
|
10
|
+
// off to it, running the CANONICAL, JSR-published module rather than a
|
|
11
|
+
// bundled local copy.
|
|
12
|
+
//
|
|
13
|
+
// Why not just bundle src/ in this npm package and run it directly?
|
|
14
|
+
// Because Deno refuses to type-strip .ts files that live inside any
|
|
15
|
+
// node_modules directory (mirrors Node's own restriction on its native
|
|
16
|
+
// TypeScript support) — once this package is npm-installed, its own
|
|
17
|
+
// files necessarily live under node_modules/@adviser/ovn-fabric/, so
|
|
18
|
+
// handing deno a local .ts path there fails with
|
|
19
|
+
// ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Delegating to the JSR
|
|
20
|
+
// copy sidesteps that entirely (JSR-fetched modules are cached outside
|
|
21
|
+
// node_modules) and, as a bonus, keeps exactly one published source of
|
|
22
|
+
// truth instead of two copies that can drift out of sync.
|
|
23
|
+
//
|
|
24
|
+
// The JSR fetch is pinned to this exact installed npm version (see
|
|
25
|
+
// package.json's own "version", kept in lockstep with deno.json by CI
|
|
26
|
+
// — see scripts/patch-version.mjs) — this is a specific, already-
|
|
27
|
+
// decided version the user just chose by installing this npm package,
|
|
28
|
+
// not a "floating, could be anything" dependency, so bypassing Deno's
|
|
29
|
+
// minimum-dependency-age check for THIS fetch only is a narrow,
|
|
30
|
+
// justified override rather than a blanket safety bypass.
|
|
14
31
|
"use strict";
|
|
15
32
|
|
|
16
|
-
const path = require("path");
|
|
17
33
|
const { spawnSync } = require("child_process");
|
|
34
|
+
const pkg = require("../package.json");
|
|
18
35
|
|
|
19
36
|
// deno/bin.cjs is itself a small Node launcher (see the `deno` npm
|
|
20
37
|
// package) that locates/spawns the real deno binary and forwards
|
|
21
38
|
// argv — resolved via require.resolve so this works regardless of how
|
|
22
39
|
// npm/pnpm/yarn hoisted node_modules.
|
|
23
40
|
const denoLauncher = require.resolve("deno/bin.cjs");
|
|
24
|
-
const
|
|
41
|
+
const moduleSpecifier = `jsr:@adviser/ovn-fabric@${pkg.version}/cli`;
|
|
25
42
|
|
|
26
43
|
const result = spawnSync(
|
|
27
44
|
process.execPath,
|
|
@@ -30,7 +47,8 @@ const result = spawnSync(
|
|
|
30
47
|
"run",
|
|
31
48
|
"--allow-read",
|
|
32
49
|
"--allow-env",
|
|
33
|
-
|
|
50
|
+
"--minimum-dependency-age=0",
|
|
51
|
+
moduleSpecifier,
|
|
34
52
|
...process.argv.slice(2),
|
|
35
53
|
],
|
|
36
54
|
{ stdio: "inherit" },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adviser/ovn-fabric",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "Declarative OVN/OVS topology generator — one config, one self-installing shell script per host.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"publishConfig": {
|
|
@@ -11,7 +11,6 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"bin",
|
|
14
|
-
"src",
|
|
15
14
|
"README.md",
|
|
16
15
|
"LICENSE"
|
|
17
16
|
],
|
package/src/addressing.ts
DELETED
|
@@ -1,222 +0,0 @@
|
|
|
1
|
-
// src/addressing.ts — the three fold rules, exactly once each, exposed
|
|
2
|
-
// as NetId factories.
|
|
3
|
-
//
|
|
4
|
-
// Fold operation = string construction. Decimal identifiers are placed
|
|
5
|
-
// directly into the address text, so the result stays human-readable
|
|
6
|
-
// and visually corresponds to its IPv4 counterpart (fd00:10:80::128:1
|
|
7
|
-
// reads as "segment 128", the same way 10.80.128.1 does). IPAddress.parse()
|
|
8
|
-
// is called ONLY AFTER the string is fully built — it validates the
|
|
9
|
-
// result and returns a real IPAddress for everything downstream
|
|
10
|
-
// (containment checks, to_string(), comparisons). There is no integer
|
|
11
|
-
// arithmetic on Crunchy here: that produces hex-folded results (segment
|
|
12
|
-
// 128 -> "80") which breaks the human-readability property these rules
|
|
13
|
-
// exist for.
|
|
14
|
-
|
|
15
|
-
import { IPAddress } from "npm:ipaddress@0.2.6";
|
|
16
|
-
import {
|
|
17
|
-
type NetId,
|
|
18
|
-
segmentId,
|
|
19
|
-
type SegmentId,
|
|
20
|
-
uplinkId,
|
|
21
|
-
type UplinkId,
|
|
22
|
-
} from "./types.ts";
|
|
23
|
-
|
|
24
|
-
function makeNetId(
|
|
25
|
-
ipv4Str: string,
|
|
26
|
-
ipv6Str: string,
|
|
27
|
-
rawId: number,
|
|
28
|
-
vlan: number | undefined,
|
|
29
|
-
): NetId {
|
|
30
|
-
const ipv4 = IPAddress.parse(ipv4Str);
|
|
31
|
-
const ipv6 = IPAddress.parse(ipv6Str);
|
|
32
|
-
return {
|
|
33
|
-
ipv4,
|
|
34
|
-
ipv6,
|
|
35
|
-
id: () => rawId,
|
|
36
|
-
vlan: () => vlan,
|
|
37
|
-
};
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
// ── backbone: one shared /16, every identifier gets its own /28 block ──
|
|
41
|
-
//
|
|
42
|
-
// IPv4: 10.80.0.0/16, 4096 /28 blocks (same partition scheme as
|
|
43
|
-
// transferNet's 10.99.0.0/16 — see that function's comment for the
|
|
44
|
-
// general approach). block_base = identifier << 4. Within a block,
|
|
45
|
-
// SEGMENTS use the low half (host offsets 1-7) and UPLINKS use the
|
|
46
|
-
// high half (block_base | 0x8, host offsets 1-7 within that half) —
|
|
47
|
-
// so a segment and an uplink that happen to share the same numeric
|
|
48
|
-
// slot can never collide, both halves fit in the same /28 without
|
|
49
|
-
// overlapping.
|
|
50
|
-
//
|
|
51
|
-
// IPv6 has no equivalent scarcity (128 bits is enormous) — it keeps
|
|
52
|
-
// directly folding the real identifier into the network portion, with
|
|
53
|
-
// a literal 0/8 marker group distinguishing segment vs uplink, so the
|
|
54
|
-
// human-readability property (you can read an id straight out of the
|
|
55
|
-
// address) is preserved on the v6 side even though v4 has to use the
|
|
56
|
-
// less-readable shifted-block scheme to fit in 32 bits.
|
|
57
|
-
//
|
|
58
|
-
// No physical VLAN on either — the backbone is OVN-internal transit,
|
|
59
|
-
// not wired to any single segment's or uplink's physical VLAN. vlan()
|
|
60
|
-
// is always undefined.
|
|
61
|
-
|
|
62
|
-
function backboneBlockAddress(blockBase: number, host: number): string {
|
|
63
|
-
if (host < 1 || host > 7) {
|
|
64
|
-
throw new RangeError(`backbone host out of range (1-7): ${host}`);
|
|
65
|
-
}
|
|
66
|
-
const third = (blockBase >> 8) & 0xff;
|
|
67
|
-
const fourth = (blockBase & 0xff) + host;
|
|
68
|
-
return `10.80.${third}.${fourth}`;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function segmentBackboneNet(
|
|
72
|
-
segment: SegmentId | number,
|
|
73
|
-
host: number,
|
|
74
|
-
): NetId {
|
|
75
|
-
const id = typeof segment === "number" ? segmentId(segment) : segment;
|
|
76
|
-
const blockBase = id << 4;
|
|
77
|
-
return makeNetId(
|
|
78
|
-
`${backboneBlockAddress(blockBase, host)}/16`,
|
|
79
|
-
`fd00:10:80::0:${id}:${host}/64`,
|
|
80
|
-
id,
|
|
81
|
-
undefined,
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function uplinkBackboneNet(
|
|
86
|
-
uplink: UplinkId | number,
|
|
87
|
-
slot: number,
|
|
88
|
-
host: number,
|
|
89
|
-
): NetId {
|
|
90
|
-
// `slot` (not the real uplink id) drives the IPv4 block-shift, same
|
|
91
|
-
// as transferNet — uplink ids like 1280 are far too large to use
|
|
92
|
-
// directly in <<4 arithmetic (1280<<4 = 20480, producing a
|
|
93
|
-
// meaningless-looking but technically-valid address in the wrong
|
|
94
|
-
// place: 10.80.80.x). `slot` MUST be the same small sequential index
|
|
95
|
-
// (0,1,2,...) NetworkBuilder already assigns for the transfer link
|
|
96
|
-
// (see define.ts), so an uplink's backbone leg and transfer leg use
|
|
97
|
-
// consistent, small, collision-free numbering. The real uplink id is
|
|
98
|
-
// still used for the IPv6 fold, which has no such size constraint.
|
|
99
|
-
const id = typeof uplink === "number" ? uplinkId(uplink) : uplink;
|
|
100
|
-
if (slot < 0 || slot > 4095) {
|
|
101
|
-
throw new RangeError(`uplinkBackboneNet: slot out of range (0-4095): ${slot}`);
|
|
102
|
-
}
|
|
103
|
-
const blockBase = (slot << 4) | 0x8;
|
|
104
|
-
return makeNetId(
|
|
105
|
-
`${backboneBlockAddress(blockBase, host)}/16`,
|
|
106
|
-
`fd00:10:80::8:${id}:${host}/64`,
|
|
107
|
-
id,
|
|
108
|
-
undefined,
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
// ── client segments: one network PER SEGMENT, identifier folds into the
|
|
113
|
-
// NETWORK portion ──
|
|
114
|
-
// fd00:192:168:<segment>::<host>/64 <-> 192.168.<segment>.<host>/24
|
|
115
|
-
//
|
|
116
|
-
// vlan() returns the segment id itself when the segment's physical VLAN
|
|
117
|
-
// tag matches its numeric id (true for every segment built so far —
|
|
118
|
-
// segment 128 is VLAN 128, etc.). Pass an explicit vlan to override
|
|
119
|
-
// (e.g. segment 128 is untagged on the wire, so its real VLAN concept
|
|
120
|
-
// doesn't apply the same way — see untaggedOnWire on Segment).
|
|
121
|
-
|
|
122
|
-
export function segmentNet(
|
|
123
|
-
segment: SegmentId | number,
|
|
124
|
-
host: number,
|
|
125
|
-
vlan?: number,
|
|
126
|
-
): NetId {
|
|
127
|
-
const id = typeof segment === "number" ? segmentId(segment) : segment;
|
|
128
|
-
return makeNetId(
|
|
129
|
-
`192.168.${id}.${host}/24`,
|
|
130
|
-
`fd00:192:168:${id}::${host}/64`,
|
|
131
|
-
id,
|
|
132
|
-
vlan ?? id,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// ── transfer links: one network PER UPLINK, identifier folds into the
|
|
137
|
-
// NETWORK portion ──
|
|
138
|
-
// fd00:10:99:<uplink>::<host>/124 <-> 10.99.<third>.<fourth>/28
|
|
139
|
-
//
|
|
140
|
-
// IPv4: 10.99.0.0/16 holds exactly 4096 /28 blocks (16 addresses each).
|
|
141
|
-
// `slot` (0-4095) selects which block — slot N is the Nth /28 in
|
|
142
|
-
// sequence: slot 0 = 10.99.0.0/28, slot 1 = 10.99.0.16/28, ...,
|
|
143
|
-
// slot 128 = 10.99.8.0/28, etc. `slot` is NOT the uplink's own id
|
|
144
|
-
// (uplink ids like 1280/1281/1282 exceed 4095 and aren't sequential
|
|
145
|
-
// from 0) — it's a small sequential index the caller assigns, one per
|
|
146
|
-
// uplink, distinct from the uplink's real identity. See define.ts,
|
|
147
|
-
// where NetworkBuilder assigns slots automatically in declaration
|
|
148
|
-
// order so config/topology.ts never has to think about this at all.
|
|
149
|
-
//
|
|
150
|
-
// IPv6 still folds the uplink's real id (not slot) into the network
|
|
151
|
-
// portion, since IPv6 has no equivalent address-space scarcity forcing
|
|
152
|
-
// a slot scheme — fd00:10:99:1280::/124 is perfectly fine on its own.
|
|
153
|
-
//
|
|
154
|
-
// No physical VLAN on the OVN side of a transfer link — vlan() is
|
|
155
|
-
// undefined here; the REAL uplink (see uplinkNet below) is what carries
|
|
156
|
-
// the physical VLAN tag.
|
|
157
|
-
|
|
158
|
-
export function transferNet(
|
|
159
|
-
uplink: UplinkId | number,
|
|
160
|
-
slot: number,
|
|
161
|
-
host: number,
|
|
162
|
-
): NetId {
|
|
163
|
-
const id = typeof uplink === "number" ? uplinkId(uplink) : uplink;
|
|
164
|
-
if (slot < 0 || slot > 4095) {
|
|
165
|
-
throw new RangeError(`transfer slot out of range (0-4095): ${slot}`);
|
|
166
|
-
}
|
|
167
|
-
if (host < 1 || host > 14) {
|
|
168
|
-
throw new RangeError(`transfer host out of range (1-14): ${host}`);
|
|
169
|
-
}
|
|
170
|
-
const blockBase = slot * 16; // 0-65520, fits in 16 bits
|
|
171
|
-
const third = (blockBase >> 8) & 0xff;
|
|
172
|
-
const fourth = (blockBase & 0xff) + host;
|
|
173
|
-
return makeNetId(
|
|
174
|
-
`10.99.${third}.${fourth}/28`,
|
|
175
|
-
`fd00:10:99:${id}::${host}/124`,
|
|
176
|
-
id,
|
|
177
|
-
undefined,
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// ── uplink's real-world physical identity ──────────────────────────
|
|
182
|
-
// The uplink's own VLAN (e.g. 1280 for an uplink named isp-primary) — this is the ONE
|
|
183
|
-
// place vlan() reflects a real physical tag tied 1:1 to the uplink
|
|
184
|
-
// numeric id, since every uplink built so far uses vlanId === id.
|
|
185
|
-
// No IPv4/IPv6 addressing of its own (the uplink's real address is
|
|
186
|
-
// whatever the ISP hands out dynamically) — ipv4/ipv6 here are the
|
|
187
|
-
// transfer link's OVN-side address, reused, so every NetId still
|
|
188
|
-
// satisfies the same shape.
|
|
189
|
-
|
|
190
|
-
export function uplinkNet(
|
|
191
|
-
uplink: UplinkId | number,
|
|
192
|
-
slot: number,
|
|
193
|
-
): NetId {
|
|
194
|
-
const id = typeof uplink === "number" ? uplinkId(uplink) : uplink;
|
|
195
|
-
const transfer = transferNet(id, slot, 1);
|
|
196
|
-
return makeNetId(
|
|
197
|
-
transfer.ipv4.to_string(),
|
|
198
|
-
transfer.ipv6.to_string(),
|
|
199
|
-
id,
|
|
200
|
-
id,
|
|
201
|
-
);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// ── MAC address derivation ────────────────────────────────────────
|
|
205
|
-
// Folds an IPv4 address's four octets directly into a MAC, prefixed
|
|
206
|
-
// with 00:00 — the convention already used by hand throughout this
|
|
207
|
-
// project (e.g. 192.168.128.2 -> 00:00:c0:a8:80:02, verified against a
|
|
208
|
-
// live deployment). Locally-administered OUI space
|
|
209
|
-
// (00:00:xx is not a real vendor block) is fine for this use — these
|
|
210
|
-
// MACs only need to be unique within OVN's logical topology, never
|
|
211
|
-
// routed on a real physical LAN segment.
|
|
212
|
-
|
|
213
|
-
export function macFromV4(ipv4: IPAddress): string {
|
|
214
|
-
const octets = ipv4.to_s().split("/")[0].split(".").map((s) =>
|
|
215
|
-
Number.parseInt(s, 10)
|
|
216
|
-
);
|
|
217
|
-
if (octets.length !== 4 || octets.some((o) => Number.isNaN(o))) {
|
|
218
|
-
throw new Error(`macFromV4: not a valid IPv4 address: ${ipv4.to_s()}`);
|
|
219
|
-
}
|
|
220
|
-
const hex = octets.map((o) => o.toString(16).padStart(2, "0"));
|
|
221
|
-
return `00:00:${hex.join(":")}`;
|
|
222
|
-
}
|
package/src/cli.ts
DELETED
|
@@ -1,163 +0,0 @@
|
|
|
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
|
-
}
|