@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 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 { defineNetwork } from "jsr:@adviser/ovn-fabric/define";
45
- import { segmentPhysical, uplinkPhysical } from "jsr:@adviser/ovn-fabric/factories";
46
- import { ManualUplink } from "jsr:@adviser/ovn-fabric/types";
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) — this
5
- // shim is the only piece of the npm distribution that runs under Node.
6
- // It does nothing but locate the `deno` executable (installed
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 hand off
10
- // to it with the bundled src/cli.ts as the entrypoint. `deno` handles
11
- // picking the right prebuilt binary for the current OS/arch on its own
12
- // (via its optionalDependencies + postinstall) — nothing here needs to
13
- // know or care what platform it's running on.
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 cliEntry = path.join(__dirname, "..", "src", "cli.ts");
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
- cliEntry,
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.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
- }