@takodotid/circuit 0.43.1 → 1.0.0-rc.2
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 +115 -37
- package/package.json +21 -58
- package/src/checks/across.ts +402 -0
- package/src/checks/built-in.ts +256 -0
- package/src/checks/device.ts +436 -0
- package/src/checks/index.ts +53 -0
- package/src/cli/advisories.ts +108 -0
- package/src/cli/app.ts +71 -0
- package/src/cli/editor-types.generated.ts +30 -0
- package/src/cli/editor-types.ts +69 -0
- package/src/cli/format.ts +124 -0
- package/src/cli/git-hooks.ts +79 -0
- package/src/cli/index.ts +350 -1170
- package/src/cli/migrate.ts +554 -0
- package/src/cli/project-commands.ts +656 -0
- package/src/cli/releases.ts +374 -0
- package/src/cli/remember.ts +339 -0
- package/src/cli/rename.ts +27 -33
- package/src/cli/secrets-password.ts +60 -0
- package/src/cli/secrets.ts +258 -0
- package/src/cli/setup.ts +300 -0
- package/src/cli/signing.ts +23 -0
- package/src/cli/text-editor.ts +268 -0
- package/src/cli/ui.ts +298 -0
- package/src/cli/version.ts +2 -0
- package/src/cli/versions.ts +158 -0
- package/src/config/address.ts +102 -0
- package/src/config/circuit.d.ts +30 -0
- package/src/config/common.ts +65 -0
- package/src/config/communities.ts +479 -0
- package/src/config/define.ts +684 -0
- package/src/config/device.ts +164 -0
- package/src/config/firewall.ts +150 -0
- package/src/config/index.ts +22 -0
- package/src/{schema → config}/interfaces.ts +121 -85
- package/src/config/keys.ts +434 -0
- package/src/config/management.ts +61 -0
- package/src/config/merge.ts +335 -0
- package/src/config/models.ts +70 -0
- package/src/config/monitoring.ts +64 -0
- package/src/config/network.ts +180 -0
- package/src/config/presets.ts +149 -0
- package/src/config/routing.ts +401 -0
- package/src/config/services.ts +51 -0
- package/src/config/switching.ts +84 -0
- package/src/config/system.ts +103 -0
- package/src/{schema → config}/time-zones.ts +1 -1
- package/src/core/address.ts +1 -1
- package/src/core/cabling.ts +8 -43
- package/src/core/graph.ts +40 -0
- package/src/{integrations → core}/registries.ts +5 -3
- package/src/core/secrets-file.ts +539 -0
- package/src/core/secrets.ts +83 -64
- package/src/core/topology.ts +103 -146
- package/src/core/utils.ts +12 -1
- package/src/model.ts +83 -0
- package/src/platforms/derive.ts +64 -37
- package/src/platforms/finding.ts +9 -0
- package/src/platforms/fortios/firewall.ts +76 -64
- package/src/platforms/fortios/index.ts +189 -125
- package/src/platforms/fortios/names.ts +51 -18
- package/src/platforms/fortios/render.ts +69 -62
- package/src/platforms/fortios/routing.ts +89 -58
- package/src/platforms/fortios/vdoms.ts +47 -16
- package/src/platforms/junos/TRAPS.md +2 -2
- package/src/platforms/junos/bgp.ts +63 -42
- package/src/platforms/junos/index.ts +122 -89
- package/src/platforms/junos/render.ts +139 -104
- package/src/platforms/platform.ts +10 -8
- package/src/platforms/raisecom-ros/TRAPS.md +1 -1
- package/src/platforms/raisecom-ros/index.ts +89 -70
- package/src/platforms/raisecom-ros/render.ts +49 -45
- package/src/platforms/routeros/TRAPS.md +3 -3
- package/src/platforms/routeros/index.ts +121 -60
- package/src/platforms/routeros/render.ts +214 -195
- package/src/platforms/vrp/TRAPS.md +1 -1
- package/src/platforms/vrp/index.ts +74 -50
- package/src/platforms/vrp/render.ts +67 -56
- package/src/project/project.ts +725 -0
- package/src/studio/app/env.d.ts +9 -0
- package/src/studio/app/index.html +18 -0
- package/src/studio/app/src/App.vue +95 -0
- package/src/studio/app/src/components/AddressesView.vue +38 -0
- package/src/studio/app/src/components/CablesView.vue +29 -0
- package/src/studio/app/src/components/CompareView.vue +160 -0
- package/src/studio/app/src/components/DataTable.vue +49 -0
- package/src/studio/app/src/components/DeviceView.vue +237 -0
- package/src/studio/app/src/components/DevicesView.vue +32 -0
- package/src/studio/app/src/components/Faceplate.vue +75 -0
- package/src/studio/app/src/components/GraphView.vue +282 -0
- package/src/studio/app/src/components/LineDiff.vue +24 -0
- package/src/studio/app/src/components/ProblemsView.vue +42 -0
- package/src/studio/app/src/components/SearchView.vue +72 -0
- package/src/studio/app/src/components/SecretsView.vue +112 -0
- package/src/studio/app/src/components/SessionsView.vue +35 -0
- package/src/studio/app/src/components/Settings.vue +95 -0
- package/src/studio/app/src/components/ToolsView.vue +118 -0
- package/src/studio/app/src/components/VlansView.vue +50 -0
- package/src/studio/app/src/lib/csv.ts +19 -0
- package/src/studio/{public/diff.js → app/src/lib/diff.ts} +40 -35
- package/src/studio/app/src/lib/format.ts +38 -0
- package/src/studio/app/src/lib/net.ts +68 -0
- package/src/studio/app/src/lib/secrets-crypto.ts +35 -0
- package/src/studio/app/src/lib/store.ts +71 -0
- package/src/studio/app/src/lib/tools.ts +217 -0
- package/src/studio/app/src/main.ts +9 -0
- package/src/studio/{public → app/src}/style.css +14 -4
- package/src/studio/app/tsconfig.json +17 -0
- package/src/studio/app/vite.config.ts +16 -0
- package/src/studio/assets.generated.ts +3 -0
- package/src/studio/data.ts +96 -188
- package/src/studio/git.ts +27 -43
- package/src/studio/server.ts +196 -107
- package/src/studio/types.ts +141 -0
- package/src/transport/console.ts +3 -3
- package/src/transport/ssh.ts +1 -1
- package/dist/chunks/index-3b343xva.js +0 -177
- package/dist/chunks/index-6wb41q67.js +0 -62
- package/dist/chunks/index-n7w3tg5y.js +0 -149
- package/dist/chunks/index-yzw86rc8.js +0 -921
- package/dist/chunks/new-w3zys5y4.js +0 -172
- package/dist/chunks/server-cxr9ynxc.js +0 -259
- package/dist/cli/index.js +0 -12922
- package/dist/helpers/index.js +0 -881
- package/dist/index.js +0 -28
- package/dist/types/core/address-space.d.ts +0 -62
- package/dist/types/core/address.d.ts +0 -44
- package/dist/types/core/cabling.d.ts +0 -41
- package/dist/types/core/cross-checks.d.ts +0 -28
- package/dist/types/core/define.d.ts +0 -79
- package/dist/types/core/project-files.d.ts +0 -12
- package/dist/types/core/schema-docs.d.ts +0 -25
- package/dist/types/core/secrets.d.ts +0 -62
- package/dist/types/core/topology.d.ts +0 -96
- package/dist/types/core/utils.d.ts +0 -10
- package/dist/types/core/validator.d.ts +0 -23
- package/dist/types/helpers/bgp.d.ts +0 -195
- package/dist/types/helpers/cables.d.ts +0 -69
- package/dist/types/helpers/checks.d.ts +0 -36
- package/dist/types/helpers/exchange.d.ts +0 -55
- package/dist/types/helpers/filters.d.ts +0 -51
- package/dist/types/helpers/index.d.ts +0 -14
- package/dist/types/helpers/route-server.d.ts +0 -52
- package/dist/types/helpers/upstreams.d.ts +0 -192
- package/dist/types/index.d.ts +0 -10
- package/dist/types/integrations/librenms.d.ts +0 -60
- package/dist/types/integrations/netbox.d.ts +0 -89
- package/dist/types/integrations/peeringdb.d.ts +0 -24
- package/dist/types/integrations/prometheus.d.ts +0 -24
- package/dist/types/integrations/registries.d.ts +0 -8
- package/dist/types/platforms/bgp-wait.d.ts +0 -24
- package/dist/types/platforms/catalog.d.ts +0 -109
- package/dist/types/platforms/derive.d.ts +0 -17
- package/dist/types/platforms/fortios/firewall.d.ts +0 -47
- package/dist/types/platforms/fortios/index.d.ts +0 -140
- package/dist/types/platforms/fortios/models.d.ts +0 -14
- package/dist/types/platforms/fortios/names.d.ts +0 -52
- package/dist/types/platforms/fortios/plan.d.ts +0 -114
- package/dist/types/platforms/fortios/render.d.ts +0 -77
- package/dist/types/platforms/fortios/routing.d.ts +0 -56
- package/dist/types/platforms/fortios/tree.d.ts +0 -37
- package/dist/types/platforms/fortios/vdoms.d.ts +0 -50
- package/dist/types/platforms/index.d.ts +0 -13
- package/dist/types/platforms/junos/bgp.d.ts +0 -44
- package/dist/types/platforms/junos/index.d.ts +0 -114
- package/dist/types/platforms/junos/models.d.ts +0 -14
- package/dist/types/platforms/junos/render.d.ts +0 -154
- package/dist/types/platforms/line-diff.d.ts +0 -67
- package/dist/types/platforms/platform.d.ts +0 -135
- package/dist/types/platforms/raisecom-ros/index.d.ts +0 -71
- package/dist/types/platforms/raisecom-ros/models.d.ts +0 -14
- package/dist/types/platforms/raisecom-ros/render.d.ts +0 -41
- package/dist/types/platforms/routeros/export.d.ts +0 -59
- package/dist/types/platforms/routeros/index.d.ts +0 -116
- package/dist/types/platforms/routeros/models.d.ts +0 -18
- package/dist/types/platforms/routeros/plan.d.ts +0 -58
- package/dist/types/platforms/routeros/render.d.ts +0 -250
- package/dist/types/platforms/vrp/index.d.ts +0 -83
- package/dist/types/platforms/vrp/models.d.ts +0 -12
- package/dist/types/platforms/vrp/render.d.ts +0 -96
- package/dist/types/schema/common.d.ts +0 -30
- package/dist/types/schema/device.d.ts +0 -126
- package/dist/types/schema/firewall.d.ts +0 -131
- package/dist/types/schema/index.d.ts +0 -8
- package/dist/types/schema/interfaces.d.ts +0 -205
- package/dist/types/schema/policy.d.ts +0 -107
- package/dist/types/schema/routing.d.ts +0 -161
- package/dist/types/schema/system.d.ts +0 -198
- package/dist/types/schema/time-zones.d.ts +0 -2
- package/dist/types/transport/console.d.ts +0 -115
- package/dist/types/transport/path-check.d.ts +0 -32
- package/dist/types/transport/ssh.d.ts +0 -27
- package/src/cli/graph-format.ts +0 -91
- package/src/cli/new.ts +0 -234
- package/src/core/address-space.ts +0 -320
- package/src/core/cross-checks.ts +0 -369
- package/src/core/define.ts +0 -124
- package/src/core/project-files.ts +0 -35
- package/src/core/schema-docs.ts +0 -174
- package/src/core/validator.ts +0 -508
- package/src/helpers/bgp.ts +0 -473
- package/src/helpers/cables.ts +0 -74
- package/src/helpers/checks.ts +0 -201
- package/src/helpers/exchange.ts +0 -123
- package/src/helpers/filters.ts +0 -107
- package/src/helpers/index.ts +0 -25
- package/src/helpers/route-server.ts +0 -212
- package/src/helpers/upstreams.ts +0 -369
- package/src/index.ts +0 -13
- package/src/integrations/librenms.ts +0 -291
- package/src/integrations/netbox.ts +0 -620
- package/src/integrations/peeringdb.ts +0 -173
- package/src/integrations/prometheus.ts +0 -90
- package/src/schema/common.ts +0 -41
- package/src/schema/device.ts +0 -145
- package/src/schema/firewall.ts +0 -140
- package/src/schema/index.ts +0 -8
- package/src/schema/policy.ts +0 -115
- package/src/schema/routing.ts +0 -173
- package/src/schema/system.ts +0 -203
- package/src/studio/public/app.js +0 -186
- package/src/studio/public/compare.js +0 -250
- package/src/studio/public/dom.js +0 -138
- package/src/studio/public/evaluate.js +0 -687
- package/src/studio/public/export.js +0 -130
- package/src/studio/public/graph.js +0 -1541
- package/src/studio/public/index.html +0 -83
- package/src/studio/public/net.js +0 -134
- package/src/studio/public/tables.js +0 -1289
- package/src/studio/public/tools.js +0 -610
- package/templates/colocation/checks.ts +0 -53
- package/templates/colocation/circuit.config.ts +0 -9
- package/templates/colocation/edge-01.ts +0 -133
- package/templates/colocation/routing.ts +0 -13
- package/templates/colocation/tenancy.ts +0 -76
- package/templates/colocation/tenants/acme.ts +0 -19
- package/templates/colocation/tenants/index.ts +0 -6
- package/templates/colocation/tor-01.ts +0 -33
- package/templates/edge-router/checks.ts +0 -20
- package/templates/edge-router/circuit.config.ts +0 -8
- package/templates/edge-router/edge-01.ts +0 -110
- package/templates/edge-router/routing.ts +0 -13
- package/templates/project/AGENTS.md +0 -51
- package/templates/project/CLAUDE.md +0 -1
- package/templates/project/README.md +0 -16
- package/templates/project/gitignore +0 -5
- package/templates/project/tsconfig.json +0 -13
- package/templates/single-site/checks.ts +0 -26
- package/templates/single-site/circuit.config.ts +0 -8
- package/templates/single-site/router.ts +0 -86
- package/templates/single-site/site.ts +0 -18
- package/templates/single-site/switch.ts +0 -34
- /package/src/studio/{public → app/public}/favicon.svg +0 -0
- /package/src/studio/{public → app/public}/special-gothic-latin-ext.woff2 +0 -0
- /package/src/studio/{public → app/public}/special-gothic-latin.woff2 +0 -0
- /package/src/studio/{public → app/public}/special-gothic-license.txt +0 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
// `circuit migrate`: a Circuit 0.x project written again as a v1 project. 0.x's `circuit.config.ts` is plain data made by functions that only return what they are given (`defineNetwork`, `defineDevice`), so Circuit imports it as it is, with the 0.x package the project installed, and writes each device in v1's groups and names. What v1 cannot take is listed in MIGRATION.md, for a person to do.
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { parseEnv } from "node:util";
|
|
7
|
+
import { Validation } from "../checks";
|
|
8
|
+
import { OnePassword, SecretsFile } from "../core/secrets-file";
|
|
9
|
+
import { SecretsPassword } from "./secrets-password";
|
|
10
|
+
import { ProjectError, readProject } from "../project/project";
|
|
11
|
+
import { EditorTypes } from "./editor-types";
|
|
12
|
+
import { GITIGNORE, Setup } from "./setup";
|
|
13
|
+
import { Ui } from "./ui";
|
|
14
|
+
|
|
15
|
+
type Json = any;
|
|
16
|
+
|
|
17
|
+
/** What 0.x's `defineNetwork` holds, as far as a migration reads it. */
|
|
18
|
+
type V0Network = {
|
|
19
|
+
devices: readonly Json[];
|
|
20
|
+
checks?: readonly unknown[];
|
|
21
|
+
asn?: number;
|
|
22
|
+
communities?: readonly { community: string; description: string }[];
|
|
23
|
+
site?: (device: Json) => string;
|
|
24
|
+
peeringdb?: { api_key: Json };
|
|
25
|
+
librenms?: { url: string; api_token: Json };
|
|
26
|
+
netbox?: { url: string; api_token: Json; device?: (device: Json) => { role?: string; device_type?: string } };
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** A project written in v1: its network, its devices by site, its certificate files, and what a person has to do. */
|
|
30
|
+
export type Migrated = {
|
|
31
|
+
/** The network's name in `circuit.ts`, such as `as64500`. */
|
|
32
|
+
name: string;
|
|
33
|
+
settings: Json;
|
|
34
|
+
devices: { site: string; device: Json }[];
|
|
35
|
+
/** Certificate files, by path from the project. */
|
|
36
|
+
files: Record<string, string>;
|
|
37
|
+
/** What did not move, by device; the network's under "". */
|
|
38
|
+
notes: Record<string, string[]>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/** Drops each key whose value is undefined, and the map itself when nothing is left. */
|
|
42
|
+
function clean(map: Json): Json {
|
|
43
|
+
if (!map || typeof map !== "object" || Array.isArray(map)) return map;
|
|
44
|
+
const kept = Object.fromEntries(Object.entries(map).filter(([, v]) => v !== undefined));
|
|
45
|
+
return Object.keys(kept).length ? kept : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A map with each value changed by `change`. */
|
|
49
|
+
const each = (map: Json, change: (v: Json, k: string) => Json) =>
|
|
50
|
+
map && Object.fromEntries(Object.entries<Json>(map).map(([k, v]) => [k, change(v, k)]));
|
|
51
|
+
|
|
52
|
+
/** A filter or NAT rule's match. */
|
|
53
|
+
const filterMatch = (m: Json) =>
|
|
54
|
+
m &&
|
|
55
|
+
clean({
|
|
56
|
+
...m,
|
|
57
|
+
src_set: undefined,
|
|
58
|
+
dst_set: undefined,
|
|
59
|
+
state: undefined,
|
|
60
|
+
src_address_set: m.src_set,
|
|
61
|
+
dst_address_set: m.dst_set,
|
|
62
|
+
connection_state: m.state,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const filterRule = ({ match, action, offload, ...rest }: Json) =>
|
|
66
|
+
clean({ ...rest, if: filterMatch(match), then: action, fast_path: offload });
|
|
67
|
+
|
|
68
|
+
const natRule = ({ match, action, to, ...rest }: Json) => clean({ ...rest, if: filterMatch(match), then: action, to_address: to });
|
|
69
|
+
|
|
70
|
+
const aclRule = ({ match, action, ...rest }: Json) => clean({ ...rest, if: match, then: action });
|
|
71
|
+
|
|
72
|
+
function policyRule(rule: Json): Json {
|
|
73
|
+
// A v1 call rule holds nothing but `call`: 0.x's description on one has no place.
|
|
74
|
+
if (rule.call !== undefined) return { call: rule.call };
|
|
75
|
+
const { match, set, action, ...rest } = rule;
|
|
76
|
+
const m =
|
|
77
|
+
match &&
|
|
78
|
+
clean({
|
|
79
|
+
...match,
|
|
80
|
+
through_as: undefined,
|
|
81
|
+
community: undefined,
|
|
82
|
+
as_path_contains: match.through_as,
|
|
83
|
+
standard_community: match.community,
|
|
84
|
+
});
|
|
85
|
+
const s =
|
|
86
|
+
set &&
|
|
87
|
+
clean({
|
|
88
|
+
...set,
|
|
89
|
+
communities: undefined,
|
|
90
|
+
add_communities: undefined,
|
|
91
|
+
remove_communities: undefined,
|
|
92
|
+
standard_communities: set.communities,
|
|
93
|
+
add_standard_communities: set.add_communities,
|
|
94
|
+
remove_standard_communities: set.remove_communities,
|
|
95
|
+
});
|
|
96
|
+
return clean({ ...rest, if: m, set: s, then: action });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const staticRoute = ({ via, table, ...rest }: Json) => clean({ ...rest, next_hop: via, routing_table: table });
|
|
100
|
+
|
|
101
|
+
const neighbor = ({ import: imp, export: exp, families, table, group, ...rest }: Json) =>
|
|
102
|
+
clean({ ...rest, import_policy: imp, export_policy: exp, address_families: families, routing_table: table, neighbor_group: group });
|
|
103
|
+
|
|
104
|
+
const addresses = (list: Json) => list?.map((a: Json) => (typeof a === "string" ? a : clean({ address: a.address, peer_address: a.peer })));
|
|
105
|
+
|
|
106
|
+
/** A port or an interface, in v1's names. */
|
|
107
|
+
function routed(x: Json): Json {
|
|
108
|
+
const { lag, acl, local, remote, members, addresses: list, ...rest } = x;
|
|
109
|
+
// A port with no settings stays: it is on, where a port left out is shut down.
|
|
110
|
+
return (
|
|
111
|
+
clean({
|
|
112
|
+
...rest,
|
|
113
|
+
addresses: addresses(list),
|
|
114
|
+
in_lag: lag,
|
|
115
|
+
ingress_acl: acl,
|
|
116
|
+
local_address: local,
|
|
117
|
+
remote_address: remote,
|
|
118
|
+
member_interfaces: members,
|
|
119
|
+
}) ?? {}
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export class Migrate {
|
|
124
|
+
/** 0.x's built-in checks, by their name in v1's `checks`. */
|
|
125
|
+
private static readonly CHECKS: Record<string, string> = {
|
|
126
|
+
exportsEndInReject: "exports_end_in_reject",
|
|
127
|
+
importsEndInAcceptOrReject: "imports_end_in_accept_or_reject",
|
|
128
|
+
tunnelsOutsideOffered: "tunnels_outside_offered",
|
|
129
|
+
nativeVlansAreDeadEnds: "native_vlans_are_dead_ends",
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/** The site a device is at when 0.x's network names none. */
|
|
133
|
+
static readonly DEFAULT_SITE = "site-1";
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A device written for Circuit 0.x, as the v1 model has it: the same values, in v1's groups and field names, every rule as `{ if, then }`. `certificate` gives the path a certificate's text is kept at.
|
|
137
|
+
*/
|
|
138
|
+
static device(d: Json, site: string, certificate: (name: string, pem: string) => string): Json {
|
|
139
|
+
const {
|
|
140
|
+
name,
|
|
141
|
+
platform,
|
|
142
|
+
model,
|
|
143
|
+
connection,
|
|
144
|
+
system = {},
|
|
145
|
+
users,
|
|
146
|
+
management = {},
|
|
147
|
+
certificates: certs,
|
|
148
|
+
vlans,
|
|
149
|
+
ports,
|
|
150
|
+
interfaces,
|
|
151
|
+
vrfs,
|
|
152
|
+
} = d;
|
|
153
|
+
const {
|
|
154
|
+
routing = {},
|
|
155
|
+
policies,
|
|
156
|
+
prefix_sets,
|
|
157
|
+
firewall,
|
|
158
|
+
acls,
|
|
159
|
+
dhcp,
|
|
160
|
+
dhcp_relay,
|
|
161
|
+
flow_export,
|
|
162
|
+
lldp,
|
|
163
|
+
stp,
|
|
164
|
+
hardware_offload,
|
|
165
|
+
vdoms,
|
|
166
|
+
} = d;
|
|
167
|
+
const { dns, ntp, logging, alarms, transceiver_monitoring, ip, ...systemRest } = system;
|
|
168
|
+
const { snmp, native, ...managementRest } = management;
|
|
169
|
+
const { console: con, ...conn } = connection;
|
|
170
|
+
|
|
171
|
+
const byType: Json = {};
|
|
172
|
+
for (const [key, { type, ...rest }] of Object.entries<Json>(interfaces ?? {}))
|
|
173
|
+
(byType[type === "vlan" ? "vlan_interface" : type] ??= {})[key] = routed(rest);
|
|
174
|
+
|
|
175
|
+
const { static: statics, rules, bgp, ospf, ...routingRest } = routing;
|
|
176
|
+
|
|
177
|
+
return clean({
|
|
178
|
+
name,
|
|
179
|
+
site,
|
|
180
|
+
platform,
|
|
181
|
+
model,
|
|
182
|
+
connection: clean({ ...conn, console: con && clean({ ...con, passwordOnly: undefined, password_only: con.passwordOnly }) }),
|
|
183
|
+
system: clean({
|
|
184
|
+
...systemRest,
|
|
185
|
+
ip_stack: ip,
|
|
186
|
+
dns: dns?.servers && { servers: dns.servers },
|
|
187
|
+
ntp: ntp?.servers && { servers: ntp.servers },
|
|
188
|
+
logging: logging?.local && { local: logging.local },
|
|
189
|
+
alarms,
|
|
190
|
+
hardware_offload,
|
|
191
|
+
users,
|
|
192
|
+
certificates: each(certs, (c, k) => clean({ certificate_file: certificate(k, c.certificate), private_key: c.private_key })),
|
|
193
|
+
vdoms: each(vdoms, ({ static: s, ...v }) => clean({ ...v, static_routes: s?.map(staticRoute) })),
|
|
194
|
+
}),
|
|
195
|
+
management: clean({ ...managementRest, native_protocol: native }),
|
|
196
|
+
monitoring: clean({ snmp, syslog: logging?.remote, flow_export, transceivers: transceiver_monitoring }),
|
|
197
|
+
interfaces: clean({ ethernet: each(ports, routed), ...byType }),
|
|
198
|
+
switching: clean({ vlans, stp, lldp, acls: each(acls, (rules) => rules.map(aclRule)) }),
|
|
199
|
+
services: clean({ dhcp_server: dhcp, dhcp_relay, dns_server: dns?.serve, ntp_server: ntp?.serve }),
|
|
200
|
+
routing: clean({
|
|
201
|
+
...routingRest,
|
|
202
|
+
vrfs,
|
|
203
|
+
static_routes: statics?.map(staticRoute),
|
|
204
|
+
policy_routing: rules?.map(({ table, ...r }: Json) => clean({ ...r, routing_table: table })),
|
|
205
|
+
ospf: ospf && clean({ ...ospf, families: undefined, address_families: ospf.families }),
|
|
206
|
+
bgp:
|
|
207
|
+
bgp &&
|
|
208
|
+
clean({
|
|
209
|
+
asn: bgp.asn,
|
|
210
|
+
originated_prefixes: bgp.networks,
|
|
211
|
+
neighbor_groups: each(bgp.groups, neighbor),
|
|
212
|
+
neighbors: each(bgp.neighbors, neighbor),
|
|
213
|
+
}),
|
|
214
|
+
policies: each(policies, (rules) => rules.map(policyRule)),
|
|
215
|
+
prefix_sets,
|
|
216
|
+
}),
|
|
217
|
+
firewall:
|
|
218
|
+
firewall &&
|
|
219
|
+
clean({
|
|
220
|
+
address_sets: firewall.address_sets,
|
|
221
|
+
filter: each(firewall.filter, ({ default: def, rules: r }) =>
|
|
222
|
+
clean({ default_action: def, rules: r?.map(filterRule) })
|
|
223
|
+
),
|
|
224
|
+
nat:
|
|
225
|
+
firewall.nat &&
|
|
226
|
+
clean({ source: firewall.nat.source?.map(natRule), destination: firewall.nat.destination?.map(natRule) }),
|
|
227
|
+
conntrack_helpers: firewall.helpers,
|
|
228
|
+
untracked: firewall.untracked,
|
|
229
|
+
}),
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** What a person has to do for one 0.x device that the migration cannot: values that have no place in v1. */
|
|
234
|
+
private static deviceNotes(d: Json): string[] {
|
|
235
|
+
const notes: string[] = [];
|
|
236
|
+
|
|
237
|
+
for (const [name, rules] of Object.entries<Json>(d.policies ?? {})) {
|
|
238
|
+
const described = (rules as Json[]).filter((rule) => rule.call !== undefined && rule.description !== undefined).length;
|
|
239
|
+
if (described)
|
|
240
|
+
notes.push(
|
|
241
|
+
`Route policy \`${name}\`: ${described} \`call\` rule(s) had a description. A v1 call rule holds only \`call\`, so it was left out.`
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return notes;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Import 0.x's `circuit.config.ts`, with the 0.x package the project installed. Its default export is the network. */
|
|
249
|
+
static async load(folder: string, config: string): Promise<{ network: V0Network; checks: Record<string, unknown> }> {
|
|
250
|
+
const path = resolve(folder, config);
|
|
251
|
+
if (!existsSync(path)) Ui.fail(`No ${config} in ${folder}. Give the 0.x project's folder, or its config file with --config.`);
|
|
252
|
+
|
|
253
|
+
const installed = join(folder, "node_modules", "@takodotid", "circuit", "package.json");
|
|
254
|
+
if (readFileSync(path, "utf8").includes("@takodotid/circuit") && !existsSync(installed)) {
|
|
255
|
+
Ui.fail(
|
|
256
|
+
`${folder} has no node_modules/@takodotid/circuit. Install the 0.x project's packages first, as you did to run it: npm install (or bun install) in that folder.`
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
let module: Json;
|
|
261
|
+
try {
|
|
262
|
+
module = await import(pathToFileURL(path).href);
|
|
263
|
+
} catch (error) {
|
|
264
|
+
Ui.fail(`Circuit 0.x's config could not be read: ${(error as Error).message}`);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const network = module.default as V0Network | undefined;
|
|
268
|
+
if (!network || !Array.isArray(network.devices))
|
|
269
|
+
Ui.fail(`${config} does not export a network: export default defineNetwork({ devices: [...] }).`);
|
|
270
|
+
|
|
271
|
+
// 0.x's own checks, the same functions the config placed in `checks`, so they can be told apart from a project's.
|
|
272
|
+
let checks: Record<string, unknown> = {};
|
|
273
|
+
try {
|
|
274
|
+
const helpers = join(dirname(installed), "src", "helpers", "index.ts");
|
|
275
|
+
if (existsSync(helpers)) checks = (await import(pathToFileURL(helpers).href)).Checks ?? {};
|
|
276
|
+
} catch {
|
|
277
|
+
checks = {};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return { network, checks };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The network and its devices in v1, and what did not move. */
|
|
284
|
+
static convert(network: V0Network, v0Checks: Record<string, unknown> = {}): Migrated {
|
|
285
|
+
const notes: Record<string, string[]> = { "": [] };
|
|
286
|
+
const files: Record<string, string> = {};
|
|
287
|
+
const name = network.asn ? `as${network.asn}` : "network";
|
|
288
|
+
|
|
289
|
+
const siteOf = (device: Json) => network.site?.(device) ?? Migrate.DEFAULT_SITE;
|
|
290
|
+
if (!network.site)
|
|
291
|
+
notes[""]!.push(
|
|
292
|
+
`0.x's network names no \`site\`, so every device is at \`${Migrate.DEFAULT_SITE}\`. Rename it in circuit.ts, and its folder under sites/.`
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
const devices = network.devices.map((d) => {
|
|
296
|
+
const site = siteOf(d);
|
|
297
|
+
const device = Migrate.device(d, site, (certificate, pem) => {
|
|
298
|
+
const path = `certificates/${d.name}-${certificate}.pem`;
|
|
299
|
+
files[path] = pem;
|
|
300
|
+
return path;
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const role = network.netbox?.device?.(d).role;
|
|
304
|
+
if (role) device.role = role;
|
|
305
|
+
|
|
306
|
+
notes[d.name] = Migrate.deviceNotes(d);
|
|
307
|
+
return { site, device };
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const checks: Record<string, true> = {};
|
|
311
|
+
let own = 0;
|
|
312
|
+
for (const check of network.checks ?? []) {
|
|
313
|
+
const builtIn = Object.entries(Migrate.CHECKS).find(([v0]) => v0Checks[v0] === check)?.[1];
|
|
314
|
+
if (builtIn) checks[builtIn] = true;
|
|
315
|
+
else own++;
|
|
316
|
+
}
|
|
317
|
+
if (own) {
|
|
318
|
+
notes[""]!.push(
|
|
319
|
+
`${own} check(s) in \`checks\` are your own, or \`Checks.trustBoundary\`. They are code, so they did not move: write them again in circuit.ts. \`trustBoundary\` is now the built-in \`untrusted_vlans\`.`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (network.communities?.length) {
|
|
324
|
+
notes[""]!.push(
|
|
325
|
+
`0.x's \`communities\` listed ${network.communities.length} communities. v1 writes the network's community scheme instead, in \`circuit()\`'s \`communities\`: write it by hand. They were:`,
|
|
326
|
+
...network.communities.map((entry) => ` - \`${entry.community}\`: ${entry.description}`)
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (network.netbox?.device)
|
|
331
|
+
notes[""]!.push(
|
|
332
|
+
"NetBox: each device's `role` came from `netbox.device`. A `device_type` other than the model has no place in v1."
|
|
333
|
+
);
|
|
334
|
+
|
|
335
|
+
const integrations = clean({
|
|
336
|
+
peeringdb: network.peeringdb && { api_key: network.peeringdb.api_key },
|
|
337
|
+
librenms: network.librenms && { url: network.librenms.url, api_token: network.librenms.api_token },
|
|
338
|
+
netbox: network.netbox && { url: network.netbox.url, api_token: network.netbox.api_token },
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const sites = Object.fromEntries([...new Set(devices.map((entry) => entry.site))].sort().map((site) => [site, {}]));
|
|
342
|
+
const settings = clean({
|
|
343
|
+
asn: network.asn,
|
|
344
|
+
sites,
|
|
345
|
+
checks: clean(checks),
|
|
346
|
+
integrations,
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
return { name, settings, devices, files, notes };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** A value written as TypeScript a person reads: keys bare where they can be, a secret as `secret("NAME")`, short lists on one line. */
|
|
353
|
+
static print(value: Json, indent = ""): string {
|
|
354
|
+
const inner = `${indent} `;
|
|
355
|
+
|
|
356
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
|
357
|
+
|
|
358
|
+
if (!Array.isArray(value)) {
|
|
359
|
+
const keys = Object.keys(value);
|
|
360
|
+
if (keys.length === 1 && typeof value.secret === "string") return `secret(${JSON.stringify(value.secret)})`;
|
|
361
|
+
if (keys.length === 1 && typeof value.secret_file === "string") return `secretFile(${JSON.stringify(value.secret_file)})`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (Array.isArray(value) ? !value.length : !Object.keys(value).length) return Array.isArray(value) ? "[]" : "{}";
|
|
365
|
+
|
|
366
|
+
const flat = Array.isArray(value)
|
|
367
|
+
? `[${value.map((item) => Migrate.print(item, inner)).join(", ")}]`
|
|
368
|
+
: `{ ${Object.entries(value)
|
|
369
|
+
.map(([key, item]) => `${Migrate.key(key)}: ${Migrate.print(item, inner)}`)
|
|
370
|
+
.join(", ")} }`;
|
|
371
|
+
|
|
372
|
+
// One line when it is short and holds nothing nested over lines.
|
|
373
|
+
if (!flat.includes("\n") && flat.length + indent.length <= 100) return flat;
|
|
374
|
+
|
|
375
|
+
const lines = Array.isArray(value)
|
|
376
|
+
? value.map((item) => `${inner}${Migrate.print(item, inner)},`)
|
|
377
|
+
: Object.entries(value).map(([key, item]) => `${inner}${Migrate.key(key)}: ${Migrate.print(item, inner)},`);
|
|
378
|
+
const [open, close] = Array.isArray(value) ? ["[", "]"] : ["{", "}"];
|
|
379
|
+
return `${open}\n${lines.join("\n")}\n${indent}${close}`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private static key(key: string): string {
|
|
383
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Write the project into `out`, which must be empty or not there yet. 0.x's `.env` goes into `.circuit/secrets`, encrypted, only when a password is given. */
|
|
387
|
+
static write(out: string, migrated: Migrated, from: { root: string; password?: string }): void {
|
|
388
|
+
if (existsSync(out) && readdirSync(out).length) Ui.fail(`${out} already holds files. Give an empty folder with --out.`);
|
|
389
|
+
|
|
390
|
+
mkdirSync(join(out, "sites"), { recursive: true });
|
|
391
|
+
mkdirSync(join(out, "tenants"), { recursive: true });
|
|
392
|
+
|
|
393
|
+
writeFileSync(
|
|
394
|
+
join(out, "circuit.ts"),
|
|
395
|
+
`// The network, moved from Circuit 0.x by circuit migrate. What did not move is in MIGRATION.md.\n\nexport const ${migrated.name} = circuit(${Migrate.print(migrated.settings)});\n`
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
for (const { site, device } of migrated.devices) {
|
|
399
|
+
mkdirSync(join(out, "sites", site), { recursive: true });
|
|
400
|
+
writeFileSync(
|
|
401
|
+
join(out, "sites", site, `${device.name}.ts`),
|
|
402
|
+
`import { ${migrated.name} } from "../../circuit";\n\nexport default ${migrated.name}.device(${Migrate.print(device)});\n`
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
for (const [path, text] of Object.entries(migrated.files)) {
|
|
407
|
+
mkdirSync(join(out, dirname(path)), { recursive: true });
|
|
408
|
+
writeFileSync(join(out, path), text);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
writeFileSync(
|
|
412
|
+
join(out, "sites", "README.md"),
|
|
413
|
+
"One folder per site, named as in circuit.ts, with one file per device: sites/<site>/<device>.ts.\n"
|
|
414
|
+
);
|
|
415
|
+
writeFileSync(
|
|
416
|
+
join(out, "tenants", "README.md"),
|
|
417
|
+
"What tenants share with your devices, one file each, merged in `tenants/index.ts`.\n"
|
|
418
|
+
);
|
|
419
|
+
writeFileSync(join(out, ".gitignore"), GITIGNORE);
|
|
420
|
+
writeFileSync(join(out, "tsconfig.json"), EditorTypes.TSCONFIG);
|
|
421
|
+
EditorTypes.write(out);
|
|
422
|
+
Setup.writeNotice(out);
|
|
423
|
+
|
|
424
|
+
Migrate.writeSecrets(out, from);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** 0.x's `.env` values in `.circuit/secrets`, each encrypted with `password`. A value 0.x had encrypted itself is left out, to set again. No value is ever printed. */
|
|
428
|
+
private static writeSecrets(out: string, from: { root: string; password?: string }): void {
|
|
429
|
+
const source = join(from.root, ".env");
|
|
430
|
+
if (from.password === undefined || !existsSync(source)) return;
|
|
431
|
+
|
|
432
|
+
const file = SecretsFile.at(out);
|
|
433
|
+
file.unlock(from.password);
|
|
434
|
+
const skipped: string[] = [];
|
|
435
|
+
for (const [name, value] of Object.entries(parseEnv(readFileSync(source, "utf8")) as Record<string, string>)) {
|
|
436
|
+
if (name.startsWith("DOTENV_") || name === SecretsFile.PASSWORD || !value) continue;
|
|
437
|
+
if (value.startsWith("encrypted:")) skipped.push(name);
|
|
438
|
+
else file.set(name, value);
|
|
439
|
+
}
|
|
440
|
+
if (skipped.length)
|
|
441
|
+
Ui.warn(`${skipped.join(", ")}: encrypted in 0.x's .env, so not copied. Set each with circuit secrets set NAME.`);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Files a `secretFile()` names, which a migration does not copy: they stay out of git, by hand. */
|
|
445
|
+
private static secretFiles(value: Json, found = new Set<string>()): Set<string> {
|
|
446
|
+
if (value && typeof value === "object") {
|
|
447
|
+
if (typeof value.secret_file === "string") found.add(value.secret_file);
|
|
448
|
+
for (const item of Object.values(value)) Migrate.secretFiles(item, found);
|
|
449
|
+
}
|
|
450
|
+
return found;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** MIGRATION.md: what a person still has to do, the network's first, then each device's, then what Circuit v1 finds in the project. */
|
|
454
|
+
static report(migrated: Migrated, found: string[]): string {
|
|
455
|
+
const lines = [
|
|
456
|
+
"# Moved from Circuit 0.x",
|
|
457
|
+
"",
|
|
458
|
+
"`circuit migrate` wrote this project from a Circuit 0.x project. Each line below is something to do by hand. Delete this file when they are done.",
|
|
459
|
+
"",
|
|
460
|
+
"0.x's snapshots (`.circuit/state`) did not move: v1 reads each device when it plans.",
|
|
461
|
+
];
|
|
462
|
+
|
|
463
|
+
const files = [...Migrate.secretFiles(migrated.devices.map((entry) => entry.device)), ...Migrate.secretFiles(migrated.settings)];
|
|
464
|
+
if (files.length)
|
|
465
|
+
migrated.notes[""]!.push(
|
|
466
|
+
`Copy the files \`secretFile()\` names into this folder, and keep them out of git: ${files.map((file) => `\`${file}\``).join(", ")}.`
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
if (migrated.notes[""]!.length)
|
|
470
|
+
lines.push("", "## The network", "", ...migrated.notes[""]!.map((note) => (note.startsWith(" ") ? note : `- ${note}`)));
|
|
471
|
+
|
|
472
|
+
for (const { device } of migrated.devices) {
|
|
473
|
+
const notes = migrated.notes[device.name] ?? [];
|
|
474
|
+
if (notes.length) lines.push("", `## ${device.name}`, "", ...notes.map((note) => `- ${note}`));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
lines.push("", "## What Circuit v1 finds", "");
|
|
478
|
+
if (found.length)
|
|
479
|
+
lines.push(
|
|
480
|
+
"An error stops `diff` and `apply` until it is fixed; a warning does not. Then run `circuit validate`:",
|
|
481
|
+
"",
|
|
482
|
+
...found.map((line) => `- ${line}`)
|
|
483
|
+
);
|
|
484
|
+
else lines.push("Nothing: the project reads and validates. Run `circuit diff <device>` on each device before the first `apply`.");
|
|
485
|
+
|
|
486
|
+
return `${lines.join("\n")}\n`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** What Circuit v1 finds in the project just written: errors reading it, then each error and warning of the model's checks. */
|
|
490
|
+
static async findings(out: string): Promise<string[]> {
|
|
491
|
+
try {
|
|
492
|
+
const read = await readProject(out);
|
|
493
|
+
return [
|
|
494
|
+
...read.warnings.map((warning) => `warning: ${warning}`),
|
|
495
|
+
...read.networks.flatMap((network) =>
|
|
496
|
+
Validation.run(Object.values(network.devices), network.settings.checks).map(
|
|
497
|
+
(finding) => `${finding.level}: ${finding.device ? `${finding.device}: ` : ""}${finding.message}`
|
|
498
|
+
)
|
|
499
|
+
),
|
|
500
|
+
];
|
|
501
|
+
} catch (error) {
|
|
502
|
+
if (error instanceof ProjectError) return error.lines;
|
|
503
|
+
throw error;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/** The whole of `circuit migrate`: read 0.x, write v1, and say what is left. */
|
|
508
|
+
static async run(folder: string, options: { out?: string; config: string; yes: boolean }): Promise<void> {
|
|
509
|
+
const root = resolve(process.cwd(), folder);
|
|
510
|
+
const out = resolve(process.cwd(), options.out ?? `${root}-v1`);
|
|
511
|
+
if (existsSync(out) && readdirSync(out).length) Ui.fail(`${out} already holds files. Give an empty folder with --out.`);
|
|
512
|
+
|
|
513
|
+
const { network, checks } = await Ui.step("Reading the Circuit 0.x project", () => Migrate.load(root, options.config));
|
|
514
|
+
const migrated = Migrate.convert(network, checks);
|
|
515
|
+
|
|
516
|
+
let copyEnv = false;
|
|
517
|
+
if (existsSync(join(root, ".env"))) {
|
|
518
|
+
if (options.yes) copyEnv = true;
|
|
519
|
+
else if (Ui.interactive)
|
|
520
|
+
copyEnv = await Ui.confirm(
|
|
521
|
+
"Copy the 0.x project's .env into the new project's secrets? Each value is encrypted there at once."
|
|
522
|
+
);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
let password: string | undefined;
|
|
526
|
+
if (copyEnv) {
|
|
527
|
+
password = process.env[SecretsFile.PASSWORD];
|
|
528
|
+
if (password?.startsWith("op://")) password = OnePassword.read(password, "The secrets password");
|
|
529
|
+
password ||= await SecretsPassword.choose(SecretsFile.at(out), "a password for the new project's secrets");
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
await Ui.step(`Writing ${migrated.devices.length} device(s) to ${out}`, async () =>
|
|
533
|
+
Migrate.write(out, migrated, { root, ...(password !== undefined ? { password } : {}) })
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
const found = await Ui.step("Checking the new project", () => Migrate.findings(out));
|
|
537
|
+
writeFileSync(join(out, "MIGRATION.md"), Migrate.report(migrated, found));
|
|
538
|
+
|
|
539
|
+
Ui.done(
|
|
540
|
+
`${basename(out)}: ${migrated.devices.length} device(s) moved${copyEnv ? `, .env copied into ${SecretsFile.DEFAULT}, encrypted` : ""}.`
|
|
541
|
+
);
|
|
542
|
+
Ui.note(
|
|
543
|
+
[
|
|
544
|
+
`cd ${out}`,
|
|
545
|
+
"Read MIGRATION.md: what did not move, and what to fix.",
|
|
546
|
+
copyEnv
|
|
547
|
+
? `Keep the secrets password in your password manager. In 1Password, Circuit can read it: circuit secrets password --1password op://vault/item/field`
|
|
548
|
+
: "Set each secret with circuit secrets set NAME.",
|
|
549
|
+
"circuit validate, then circuit diff <device> on each device.",
|
|
550
|
+
].join("\n"),
|
|
551
|
+
"Next"
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
}
|