@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.
Files changed (256) hide show
  1. package/README.md +115 -37
  2. package/package.json +21 -58
  3. package/src/checks/across.ts +402 -0
  4. package/src/checks/built-in.ts +256 -0
  5. package/src/checks/device.ts +436 -0
  6. package/src/checks/index.ts +53 -0
  7. package/src/cli/advisories.ts +108 -0
  8. package/src/cli/app.ts +71 -0
  9. package/src/cli/editor-types.generated.ts +30 -0
  10. package/src/cli/editor-types.ts +69 -0
  11. package/src/cli/format.ts +124 -0
  12. package/src/cli/git-hooks.ts +79 -0
  13. package/src/cli/index.ts +350 -1170
  14. package/src/cli/migrate.ts +554 -0
  15. package/src/cli/project-commands.ts +656 -0
  16. package/src/cli/releases.ts +374 -0
  17. package/src/cli/remember.ts +339 -0
  18. package/src/cli/rename.ts +27 -33
  19. package/src/cli/secrets-password.ts +60 -0
  20. package/src/cli/secrets.ts +258 -0
  21. package/src/cli/setup.ts +300 -0
  22. package/src/cli/signing.ts +23 -0
  23. package/src/cli/text-editor.ts +268 -0
  24. package/src/cli/ui.ts +298 -0
  25. package/src/cli/version.ts +2 -0
  26. package/src/cli/versions.ts +158 -0
  27. package/src/config/address.ts +102 -0
  28. package/src/config/circuit.d.ts +30 -0
  29. package/src/config/common.ts +65 -0
  30. package/src/config/communities.ts +479 -0
  31. package/src/config/define.ts +684 -0
  32. package/src/config/device.ts +164 -0
  33. package/src/config/firewall.ts +150 -0
  34. package/src/config/index.ts +22 -0
  35. package/src/{schema → config}/interfaces.ts +121 -85
  36. package/src/config/keys.ts +434 -0
  37. package/src/config/management.ts +61 -0
  38. package/src/config/merge.ts +335 -0
  39. package/src/config/models.ts +70 -0
  40. package/src/config/monitoring.ts +64 -0
  41. package/src/config/network.ts +180 -0
  42. package/src/config/presets.ts +149 -0
  43. package/src/config/routing.ts +401 -0
  44. package/src/config/services.ts +51 -0
  45. package/src/config/switching.ts +84 -0
  46. package/src/config/system.ts +103 -0
  47. package/src/{schema → config}/time-zones.ts +1 -1
  48. package/src/core/address.ts +1 -1
  49. package/src/core/cabling.ts +8 -43
  50. package/src/core/graph.ts +40 -0
  51. package/src/{integrations → core}/registries.ts +5 -3
  52. package/src/core/secrets-file.ts +539 -0
  53. package/src/core/secrets.ts +83 -64
  54. package/src/core/topology.ts +103 -146
  55. package/src/core/utils.ts +12 -1
  56. package/src/model.ts +83 -0
  57. package/src/platforms/derive.ts +64 -37
  58. package/src/platforms/finding.ts +9 -0
  59. package/src/platforms/fortios/firewall.ts +76 -64
  60. package/src/platforms/fortios/index.ts +189 -125
  61. package/src/platforms/fortios/names.ts +51 -18
  62. package/src/platforms/fortios/render.ts +69 -62
  63. package/src/platforms/fortios/routing.ts +89 -58
  64. package/src/platforms/fortios/vdoms.ts +47 -16
  65. package/src/platforms/junos/TRAPS.md +2 -2
  66. package/src/platforms/junos/bgp.ts +63 -42
  67. package/src/platforms/junos/index.ts +122 -89
  68. package/src/platforms/junos/render.ts +139 -104
  69. package/src/platforms/platform.ts +10 -8
  70. package/src/platforms/raisecom-ros/TRAPS.md +1 -1
  71. package/src/platforms/raisecom-ros/index.ts +89 -70
  72. package/src/platforms/raisecom-ros/render.ts +49 -45
  73. package/src/platforms/routeros/TRAPS.md +3 -3
  74. package/src/platforms/routeros/index.ts +121 -60
  75. package/src/platforms/routeros/render.ts +214 -195
  76. package/src/platforms/vrp/TRAPS.md +1 -1
  77. package/src/platforms/vrp/index.ts +74 -50
  78. package/src/platforms/vrp/render.ts +67 -56
  79. package/src/project/project.ts +725 -0
  80. package/src/studio/app/env.d.ts +9 -0
  81. package/src/studio/app/index.html +18 -0
  82. package/src/studio/app/src/App.vue +95 -0
  83. package/src/studio/app/src/components/AddressesView.vue +38 -0
  84. package/src/studio/app/src/components/CablesView.vue +29 -0
  85. package/src/studio/app/src/components/CompareView.vue +160 -0
  86. package/src/studio/app/src/components/DataTable.vue +49 -0
  87. package/src/studio/app/src/components/DeviceView.vue +237 -0
  88. package/src/studio/app/src/components/DevicesView.vue +32 -0
  89. package/src/studio/app/src/components/Faceplate.vue +75 -0
  90. package/src/studio/app/src/components/GraphView.vue +282 -0
  91. package/src/studio/app/src/components/LineDiff.vue +24 -0
  92. package/src/studio/app/src/components/ProblemsView.vue +42 -0
  93. package/src/studio/app/src/components/SearchView.vue +72 -0
  94. package/src/studio/app/src/components/SecretsView.vue +112 -0
  95. package/src/studio/app/src/components/SessionsView.vue +35 -0
  96. package/src/studio/app/src/components/Settings.vue +95 -0
  97. package/src/studio/app/src/components/ToolsView.vue +118 -0
  98. package/src/studio/app/src/components/VlansView.vue +50 -0
  99. package/src/studio/app/src/lib/csv.ts +19 -0
  100. package/src/studio/{public/diff.js → app/src/lib/diff.ts} +40 -35
  101. package/src/studio/app/src/lib/format.ts +38 -0
  102. package/src/studio/app/src/lib/net.ts +68 -0
  103. package/src/studio/app/src/lib/secrets-crypto.ts +35 -0
  104. package/src/studio/app/src/lib/store.ts +71 -0
  105. package/src/studio/app/src/lib/tools.ts +217 -0
  106. package/src/studio/app/src/main.ts +9 -0
  107. package/src/studio/{public → app/src}/style.css +14 -4
  108. package/src/studio/app/tsconfig.json +17 -0
  109. package/src/studio/app/vite.config.ts +16 -0
  110. package/src/studio/assets.generated.ts +3 -0
  111. package/src/studio/data.ts +96 -188
  112. package/src/studio/git.ts +27 -43
  113. package/src/studio/server.ts +196 -107
  114. package/src/studio/types.ts +141 -0
  115. package/src/transport/console.ts +3 -3
  116. package/src/transport/ssh.ts +1 -1
  117. package/dist/chunks/index-3b343xva.js +0 -177
  118. package/dist/chunks/index-6wb41q67.js +0 -62
  119. package/dist/chunks/index-n7w3tg5y.js +0 -149
  120. package/dist/chunks/index-yzw86rc8.js +0 -921
  121. package/dist/chunks/new-w3zys5y4.js +0 -172
  122. package/dist/chunks/server-cxr9ynxc.js +0 -259
  123. package/dist/cli/index.js +0 -12922
  124. package/dist/helpers/index.js +0 -881
  125. package/dist/index.js +0 -28
  126. package/dist/types/core/address-space.d.ts +0 -62
  127. package/dist/types/core/address.d.ts +0 -44
  128. package/dist/types/core/cabling.d.ts +0 -41
  129. package/dist/types/core/cross-checks.d.ts +0 -28
  130. package/dist/types/core/define.d.ts +0 -79
  131. package/dist/types/core/project-files.d.ts +0 -12
  132. package/dist/types/core/schema-docs.d.ts +0 -25
  133. package/dist/types/core/secrets.d.ts +0 -62
  134. package/dist/types/core/topology.d.ts +0 -96
  135. package/dist/types/core/utils.d.ts +0 -10
  136. package/dist/types/core/validator.d.ts +0 -23
  137. package/dist/types/helpers/bgp.d.ts +0 -195
  138. package/dist/types/helpers/cables.d.ts +0 -69
  139. package/dist/types/helpers/checks.d.ts +0 -36
  140. package/dist/types/helpers/exchange.d.ts +0 -55
  141. package/dist/types/helpers/filters.d.ts +0 -51
  142. package/dist/types/helpers/index.d.ts +0 -14
  143. package/dist/types/helpers/route-server.d.ts +0 -52
  144. package/dist/types/helpers/upstreams.d.ts +0 -192
  145. package/dist/types/index.d.ts +0 -10
  146. package/dist/types/integrations/librenms.d.ts +0 -60
  147. package/dist/types/integrations/netbox.d.ts +0 -89
  148. package/dist/types/integrations/peeringdb.d.ts +0 -24
  149. package/dist/types/integrations/prometheus.d.ts +0 -24
  150. package/dist/types/integrations/registries.d.ts +0 -8
  151. package/dist/types/platforms/bgp-wait.d.ts +0 -24
  152. package/dist/types/platforms/catalog.d.ts +0 -109
  153. package/dist/types/platforms/derive.d.ts +0 -17
  154. package/dist/types/platforms/fortios/firewall.d.ts +0 -47
  155. package/dist/types/platforms/fortios/index.d.ts +0 -140
  156. package/dist/types/platforms/fortios/models.d.ts +0 -14
  157. package/dist/types/platforms/fortios/names.d.ts +0 -52
  158. package/dist/types/platforms/fortios/plan.d.ts +0 -114
  159. package/dist/types/platforms/fortios/render.d.ts +0 -77
  160. package/dist/types/platforms/fortios/routing.d.ts +0 -56
  161. package/dist/types/platforms/fortios/tree.d.ts +0 -37
  162. package/dist/types/platforms/fortios/vdoms.d.ts +0 -50
  163. package/dist/types/platforms/index.d.ts +0 -13
  164. package/dist/types/platforms/junos/bgp.d.ts +0 -44
  165. package/dist/types/platforms/junos/index.d.ts +0 -114
  166. package/dist/types/platforms/junos/models.d.ts +0 -14
  167. package/dist/types/platforms/junos/render.d.ts +0 -154
  168. package/dist/types/platforms/line-diff.d.ts +0 -67
  169. package/dist/types/platforms/platform.d.ts +0 -135
  170. package/dist/types/platforms/raisecom-ros/index.d.ts +0 -71
  171. package/dist/types/platforms/raisecom-ros/models.d.ts +0 -14
  172. package/dist/types/platforms/raisecom-ros/render.d.ts +0 -41
  173. package/dist/types/platforms/routeros/export.d.ts +0 -59
  174. package/dist/types/platforms/routeros/index.d.ts +0 -116
  175. package/dist/types/platforms/routeros/models.d.ts +0 -18
  176. package/dist/types/platforms/routeros/plan.d.ts +0 -58
  177. package/dist/types/platforms/routeros/render.d.ts +0 -250
  178. package/dist/types/platforms/vrp/index.d.ts +0 -83
  179. package/dist/types/platforms/vrp/models.d.ts +0 -12
  180. package/dist/types/platforms/vrp/render.d.ts +0 -96
  181. package/dist/types/schema/common.d.ts +0 -30
  182. package/dist/types/schema/device.d.ts +0 -126
  183. package/dist/types/schema/firewall.d.ts +0 -131
  184. package/dist/types/schema/index.d.ts +0 -8
  185. package/dist/types/schema/interfaces.d.ts +0 -205
  186. package/dist/types/schema/policy.d.ts +0 -107
  187. package/dist/types/schema/routing.d.ts +0 -161
  188. package/dist/types/schema/system.d.ts +0 -198
  189. package/dist/types/schema/time-zones.d.ts +0 -2
  190. package/dist/types/transport/console.d.ts +0 -115
  191. package/dist/types/transport/path-check.d.ts +0 -32
  192. package/dist/types/transport/ssh.d.ts +0 -27
  193. package/src/cli/graph-format.ts +0 -91
  194. package/src/cli/new.ts +0 -234
  195. package/src/core/address-space.ts +0 -320
  196. package/src/core/cross-checks.ts +0 -369
  197. package/src/core/define.ts +0 -124
  198. package/src/core/project-files.ts +0 -35
  199. package/src/core/schema-docs.ts +0 -174
  200. package/src/core/validator.ts +0 -508
  201. package/src/helpers/bgp.ts +0 -473
  202. package/src/helpers/cables.ts +0 -74
  203. package/src/helpers/checks.ts +0 -201
  204. package/src/helpers/exchange.ts +0 -123
  205. package/src/helpers/filters.ts +0 -107
  206. package/src/helpers/index.ts +0 -25
  207. package/src/helpers/route-server.ts +0 -212
  208. package/src/helpers/upstreams.ts +0 -369
  209. package/src/index.ts +0 -13
  210. package/src/integrations/librenms.ts +0 -291
  211. package/src/integrations/netbox.ts +0 -620
  212. package/src/integrations/peeringdb.ts +0 -173
  213. package/src/integrations/prometheus.ts +0 -90
  214. package/src/schema/common.ts +0 -41
  215. package/src/schema/device.ts +0 -145
  216. package/src/schema/firewall.ts +0 -140
  217. package/src/schema/index.ts +0 -8
  218. package/src/schema/policy.ts +0 -115
  219. package/src/schema/routing.ts +0 -173
  220. package/src/schema/system.ts +0 -203
  221. package/src/studio/public/app.js +0 -186
  222. package/src/studio/public/compare.js +0 -250
  223. package/src/studio/public/dom.js +0 -138
  224. package/src/studio/public/evaluate.js +0 -687
  225. package/src/studio/public/export.js +0 -130
  226. package/src/studio/public/graph.js +0 -1541
  227. package/src/studio/public/index.html +0 -83
  228. package/src/studio/public/net.js +0 -134
  229. package/src/studio/public/tables.js +0 -1289
  230. package/src/studio/public/tools.js +0 -610
  231. package/templates/colocation/checks.ts +0 -53
  232. package/templates/colocation/circuit.config.ts +0 -9
  233. package/templates/colocation/edge-01.ts +0 -133
  234. package/templates/colocation/routing.ts +0 -13
  235. package/templates/colocation/tenancy.ts +0 -76
  236. package/templates/colocation/tenants/acme.ts +0 -19
  237. package/templates/colocation/tenants/index.ts +0 -6
  238. package/templates/colocation/tor-01.ts +0 -33
  239. package/templates/edge-router/checks.ts +0 -20
  240. package/templates/edge-router/circuit.config.ts +0 -8
  241. package/templates/edge-router/edge-01.ts +0 -110
  242. package/templates/edge-router/routing.ts +0 -13
  243. package/templates/project/AGENTS.md +0 -51
  244. package/templates/project/CLAUDE.md +0 -1
  245. package/templates/project/README.md +0 -16
  246. package/templates/project/gitignore +0 -5
  247. package/templates/project/tsconfig.json +0 -13
  248. package/templates/single-site/checks.ts +0 -26
  249. package/templates/single-site/circuit.config.ts +0 -8
  250. package/templates/single-site/router.ts +0 -86
  251. package/templates/single-site/site.ts +0 -18
  252. package/templates/single-site/switch.ts +0 -34
  253. /package/src/studio/{public → app/public}/favicon.svg +0 -0
  254. /package/src/studio/{public → app/public}/special-gothic-latin-ext.woff2 +0 -0
  255. /package/src/studio/{public → app/public}/special-gothic-latin.woff2 +0 -0
  256. /package/src/studio/{public → app/public}/special-gothic-license.txt +0 -0
@@ -0,0 +1,656 @@
1
+ // The commands that read a project: `validate`, `diff` and `apply`, and the utilities that reach a device, `exec`, `bgp` and `access-console`. Each reads the project in the working directory, or the nearest folder above it that holds `circuit.ts`.
2
+
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { basename, dirname, extname, join, resolve } from "node:path";
5
+ import { Validation } from "../checks";
6
+ import { Cabling } from "../core/cabling";
7
+ import { Secrets } from "../core/secrets";
8
+ import type { Device } from "../model";
9
+ import { Platforms } from "../platforms";
10
+ import { BgpWait } from "../platforms/bgp-wait";
11
+ import type { Finding } from "../platforms/finding";
12
+ import type { ApplyOptions, BgpLogLine, BgpSession, Plan, Platform, ReadSession } from "../platforms/platform";
13
+ import { ProjectError, type ProjectRead, readProject, secretsFileOf } from "../project/project";
14
+ import { ConsoleScript, type ConsoleSession, SerialConsole } from "../transport/console";
15
+ import { PathCheck } from "../transport/path-check";
16
+ import { Remember } from "./remember";
17
+ import { Rename, type RenamePlan } from "./rename";
18
+ import { SecretsPassword } from "./secrets-password";
19
+ import { Setup } from "./setup";
20
+ import { Ui } from "./ui";
21
+
22
+ /** How a device is reached and what a plan sends, as `diff` and `apply` take them. */
23
+ export type ReachFlags = { secrets?: boolean; rollback?: number; console?: boolean; experimental?: boolean };
24
+
25
+ /** One project, read, and how it reaches devices. */
26
+ export class ProjectCommands {
27
+ /** Ctrl-], the key that leaves `circuit access-console`, as telnet uses it. */
28
+ private static readonly LEAVE_KEY = 0x1d;
29
+
30
+ /** Every device of every network of the project. */
31
+ readonly devices: Device[];
32
+
33
+ private constructor(
34
+ /** The project's folder. */
35
+ readonly root: string,
36
+ readonly project: ProjectRead,
37
+ /** How a device is reached and what a plan sends: `apply`'s flags, the defaults for every other command. */
38
+ private readonly options: ApplyOptions,
39
+ /** `--experimental`: fields a platform renders but has not proved are warnings, not errors. */
40
+ private readonly experimental = false
41
+ ) {
42
+ this.devices = project.networks.flatMap((network) => Object.values(network.devices));
43
+ }
44
+
45
+ /** The project's folder: the working directory, or the nearest folder above it with `circuit.ts`. Undefined when there is none. */
46
+ static find(): string | undefined {
47
+ let current = process.cwd();
48
+ while (true) {
49
+ if (existsSync(join(current, "circuit.ts"))) return current;
50
+
51
+ const parent = dirname(current);
52
+ if (parent === current) return undefined;
53
+ current = parent;
54
+ }
55
+ }
56
+
57
+ /** Read the project. Stops with every reason when it can't be read. */
58
+ static async open(flags: ReachFlags = {}): Promise<ProjectCommands> {
59
+ const root =
60
+ ProjectCommands.find() ??
61
+ Ui.fail(
62
+ "No Circuit project here: no circuit.ts in this folder or above it. Run circuit in a project's folder, or start one with circuit."
63
+ );
64
+
65
+ Setup.prepare(root);
66
+ Secrets.useRoot(root);
67
+
68
+ let project: ProjectRead;
69
+ try {
70
+ project = await Ui.step("Reading the project", () => readProject(root));
71
+ } catch (error) {
72
+ if (error instanceof ProjectError) Ui.fail(`${error.lines.join("\n")}\n\n${error.lines.length} error(s).`);
73
+ throw error;
74
+ }
75
+
76
+ for (const warning of project.warnings) Ui.warn(warning);
77
+
78
+ const names = project.networks.flatMap((network) => Object.keys(network.devices));
79
+ const twice = names.filter((name, i) => names.indexOf(name) !== i);
80
+ if (twice.length) Ui.fail(`${twice.join(", ")}: one hostname in two networks. A hostname is one device.`);
81
+
82
+ const options: ApplyOptions = {
83
+ secrets: flags.secrets ?? false,
84
+ rollback: flags.rollback ?? 10,
85
+ ...(flags.console ? { console: true } : {}),
86
+ };
87
+
88
+ return new ProjectCommands(root, project, options, flags.experimental ?? false);
89
+ }
90
+
91
+ /** The device with this name. Without one, a person picks it from a list; a script must name it. */
92
+ async pick(name: string | undefined, devices: readonly Device[] = this.devices): Promise<Device> {
93
+ if (name !== undefined) {
94
+ const known = this.devices.map((device) => device.name).join(", ");
95
+ return this.devices.find((device) => device.name === name) ?? Ui.fail(`No device ${name}. The project has: ${known}`);
96
+ }
97
+
98
+ if (!devices.length) Ui.fail("The project has no device yet.");
99
+ if (!Ui.interactive) Ui.fail("Name the device.");
100
+
101
+ return Ui.select(
102
+ "Which device?",
103
+ devices.map((device) => ({ value: device, label: device.name, hint: device.platform }))
104
+ );
105
+ }
106
+
107
+ /** The device, its secrets read from its network's file, the person asked for that file's password when nothing else gives it. */
108
+ private async withSecrets(device: Device): Promise<Device> {
109
+ const network = this.project.networks.find((each) => device.name in each.devices);
110
+ Secrets.useFile(network ? secretsFileOf(network) : undefined);
111
+ await SecretsPassword.unlock(Secrets.lockedFor(device));
112
+ await Remember.offer();
113
+ return device;
114
+ }
115
+
116
+ /** Every secrets file the networks use, relative to the project. */
117
+ secretsFiles(): string[] {
118
+ return [...new Set(this.project.networks.map(secretsFileOf))];
119
+ }
120
+
121
+ /** How a device is reached: through its console when `--console` asks, or when it has no management address. */
122
+ private optionsFor(device: Device): ApplyOptions {
123
+ return this.options.console || !device.connection.host ? { ...this.options, console: true } : this.options;
124
+ }
125
+
126
+ /** The adapter of a device, which must be able to use its console when the device is reached through it. */
127
+ private adapterOf(device: Device): Platform {
128
+ const adapter = Platforms.find(device.platform) ?? Ui.fail(`No adapter for ${device.platform}.`);
129
+ if (!this.optionsFor(device).console) return adapter;
130
+
131
+ if (!adapter.console) Ui.fail(`${device.name}: ${device.platform} cannot be reached through a console yet.`);
132
+ if (!device.connection.console) Ui.fail(`${device.name}: --console needs connection.console.`);
133
+ return adapter;
134
+ }
135
+
136
+ /** What the model's checks find, network by network: what each platform can't express, what it does otherwise, and the built-in checks the network turns on. */
137
+ private modelFindings(): Finding[] {
138
+ return this.project.networks.flatMap((network) =>
139
+ Validation.run(Object.values(network.devices), network.settings.checks, { experimental: this.experimental })
140
+ );
141
+ }
142
+
143
+ /** Print what was found, and stop on an error about one of these devices. */
144
+ private guard(devices: readonly Device[], all: readonly Finding[] = this.modelFindings()): void {
145
+ const relevant = new Set(devices.map((device) => device.name));
146
+ const findings = all.filter((finding) => !finding.device || relevant.has(finding.device));
147
+
148
+ for (const finding of findings) {
149
+ const where = finding.device ? `${finding.device}: ` : "";
150
+ if (finding.level === "error") Ui.error(`${where}${finding.message}`);
151
+ else Ui.warn(`${where}${finding.message}`);
152
+ }
153
+
154
+ const errors = findings.filter((finding) => finding.level === "error").length;
155
+ if (errors) Ui.fail(`\n${errors} error(s).`);
156
+ }
157
+
158
+ /** Print a plan. Returns how many commands and problems it holds. */
159
+ private static printPlan(device: Device, plan: Plan): number {
160
+ const commands = plan.steps.reduce((count, step) => count + step.send.length, 0);
161
+ if (!commands && !plan.problems.length) {
162
+ Ui.done(`${device.name} already runs its config. Nothing to change.`);
163
+ return 0;
164
+ }
165
+
166
+ console.log(`\n${Ui.bold(device.name)}: ${commands} command(s) in ${plan.steps.length} step(s)`);
167
+
168
+ for (const step of plan.steps) {
169
+ console.log(` ${Ui.bold(step.title)}`);
170
+ for (const line of step.show) console.log(` ${line.slice(0, 220)}`);
171
+ }
172
+
173
+ for (const problem of plan.problems) Ui.error(problem);
174
+
175
+ return commands + plan.problems.length;
176
+ }
177
+
178
+ /** Where LLDP and `link` disagree on this device, read while Circuit is connected. Warnings only: LLDP can be off at the far end. */
179
+ private async checkCabling(device: Device, adapter: Platform): Promise<void> {
180
+ if (!Cabling.runsAnywhere(device) || !adapter.neighbors) return;
181
+
182
+ const neighbors = await Ui.step(`Reading ${device.name}'s LLDP neighbors`, () =>
183
+ adapter.neighbors!(device, this.devices, this.optionsFor(device))
184
+ );
185
+ for (const finding of Cabling.compare(this.devices, new Map([[device.name, neighbors]])))
186
+ Ui.warn(`${device.name}: ${finding.message}`);
187
+ }
188
+
189
+ /** Read the device and plan against what it runs now. */
190
+ private async plan(device: Device, adapter: Platform): Promise<Plan> {
191
+ const live = await Ui.step(`Reading what ${device.name} runs`, () => adapter.read(device, this.devices, this.optionsFor(device)));
192
+ await this.checkCabling(device, adapter);
193
+
194
+ return adapter.plan(adapter.render(device), live, this.options);
195
+ }
196
+
197
+ validate(): void {
198
+ this.guard(this.devices);
199
+ Ui.done(`${this.devices.length} device(s), no errors.`);
200
+ }
201
+
202
+ /** What `apply` would send, against what the device runs now. Exits 1 when something would change. */
203
+ async diff(name: string | undefined): Promise<void> {
204
+ const device = await this.withSecrets(await this.pick(name));
205
+ this.guard([device]);
206
+
207
+ const adapter = this.adapterOf(device);
208
+ const changes = ProjectCommands.printPlan(device, await this.plan(device, adapter));
209
+
210
+ await Remember.offer();
211
+ process.exit(changes ? 1 : 0);
212
+ }
213
+
214
+ /** Plan against what the device runs, show the plan, and send it once the person says yes, or with `--confirm`. */
215
+ async apply(name: string | undefined, confirmed: boolean, force: boolean): Promise<void> {
216
+ const device = await this.withSecrets(await this.pick(name));
217
+ this.guard([device]);
218
+ const adapter = this.adapterOf(device);
219
+
220
+ // A lossy path also cuts the read short, so the path is checked before the read.
221
+ await this.checkPath(device, force);
222
+
223
+ const plan = await this.plan(device, adapter);
224
+ if (!ProjectCommands.printPlan(device, plan)) return;
225
+
226
+ if (plan.problems.length) Ui.fail("\nNot sent: resolve the problems first.");
227
+
228
+ if (!confirmed) {
229
+ if (!Ui.interactive) Ui.fail("\nNot sent. Without a person to answer, add --confirm to send.");
230
+ console.log();
231
+ if (!(await Ui.confirm(`Send these changes to ${device.name}?`))) {
232
+ console.log("Nothing sent.");
233
+ return;
234
+ }
235
+ }
236
+
237
+ // What the checks after the apply compare against, such as the BGP sessions up now, is read before anything is sent.
238
+ const health =
239
+ adapter.health &&
240
+ (await Ui.step(`Reading what to check on ${device.name} after the apply`, () =>
241
+ adapter.health!(device, plan, this.optionsFor(device))
242
+ ));
243
+
244
+ await Ui.step(`Sending the changes to ${device.name}`, () => adapter.apply(device, this.devices, plan, this.optionsFor(device)));
245
+
246
+ if (!health) return;
247
+
248
+ const problems = await Ui.step("Checking the device after the apply", () => health.run());
249
+ if (problems.length) Ui.fail(`\nApplied, but the device is not healthy:\n${problems.map((problem) => ` ${problem}`).join("\n")}`);
250
+
251
+ Ui.done("No problems found.");
252
+ }
253
+
254
+ /** Run commands that only read, each under a header with how long it took. Every command is checked before anything connects. */
255
+ async exec(name: string | undefined, given: string[], file: string | undefined, timeoutSeconds: number): Promise<void> {
256
+ const device = await this.withSecrets(await this.pick(name));
257
+ const adapter = this.adapterOf(device);
258
+
259
+ const fromFile = file ? readFileSync(resolve(process.cwd(), file), "utf8").split(/\r?\n/) : [];
260
+ const listed = fromFile.map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
261
+ const commands = [...given, ...listed];
262
+ if (!commands.length && Ui.interactive)
263
+ commands.push(await Ui.text("Command to run", { check: (value) => (value.trim() ? undefined : "Type a command.") }));
264
+ if (!commands.length) Ui.fail("Give a command to run, or --file.");
265
+
266
+ const refused = commands.filter((command) => !adapter.readOnly(command));
267
+ if (refused.length) {
268
+ Ui.fail(
269
+ `Not known to only read on ${device.platform}, so nothing was sent. A change goes through circuit apply.\n${refused.map((command) => ` ${command}`).join("\n")}`
270
+ );
271
+ }
272
+
273
+ const session = await Ui.step(`Connecting to ${device.name}`, () => adapter.session(device, this.optionsFor(device)));
274
+ const timeout = timeoutSeconds * 1000;
275
+ let failed = false;
276
+
277
+ try {
278
+ for (const command of commands) {
279
+ const started = Date.now();
280
+ let output: string;
281
+
282
+ try {
283
+ output = await Ui.step(command, () => session.run(command, timeout));
284
+ } catch (error) {
285
+ output = `stopped: ${(error as Error).message}`;
286
+ failed = true;
287
+ }
288
+
289
+ const took = Date.now() - started;
290
+ const stoppedAtLimit = !failed && took >= timeout ? `\n(stopped at the ${timeoutSeconds} second limit)` : "";
291
+ const block = `${Ui.bold(`>>> ${command}`)} ${Ui.dim(`(${(took / 1000).toFixed(1)}s)`)}\n${output.replace(/\r/g, "").trimEnd()}${stoppedAtLimit}\n\n`;
292
+ process.stdout.write(Secrets.scrub(block, { network: this.devices }));
293
+
294
+ // A shell or a console still runs a command that did not finish, so nothing more can be sent.
295
+ if (failed) break;
296
+ }
297
+ } finally {
298
+ session.close();
299
+ }
300
+
301
+ if (failed) process.exitCode = 1;
302
+ }
303
+
304
+ /** A device's new name everywhere the project's files write it, and its file's when asked. Shows every change, and writes once the person says yes, or with `--confirm`. */
305
+ async rename(from: string | undefined, to: string | undefined, file: boolean | undefined, confirmed: boolean): Promise<void> {
306
+ const device = await this.pick(from);
307
+ const network = this.project.networks.find((candidate) => device.name in candidate.devices)!;
308
+ const declaring = network.files[device.name] ?? Ui.fail(`No file of the project writes ${device.name}, so rename it by hand.`);
309
+
310
+ const check = (name: string) => {
311
+ if (!Rename.HOSTNAME.test(name)) return `${name} is not a hostname: letters, digits, dots, dashes and underscores.`;
312
+ if (this.devices.some((candidate) => candidate.name === name)) return `${name} is already a device.`;
313
+ return undefined;
314
+ };
315
+ const name = to ?? (Ui.interactive ? await Ui.text(`New name for ${device.name}`, { check }) : Ui.fail("Give the new name."));
316
+ const wrong = check(name);
317
+ if (wrong) Ui.fail(wrong);
318
+
319
+ // A file named after the device follows it, unless the person says otherwise.
320
+ const namedAfter = basename(declaring, extname(declaring)) === device.name;
321
+ const moveFile =
322
+ file ??
323
+ (namedAfter && Ui.interactive ? await Ui.confirm(`Also rename ${declaring} to ${name}${extname(declaring)}?`, true) : false);
324
+
325
+ let plan: RenamePlan;
326
+ try {
327
+ plan = Rename.plan(this.root, device.name, name, { declaring, file: moveFile });
328
+ } catch (error) {
329
+ Ui.fail((error as Error).message);
330
+ }
331
+
332
+ for (const change of plan.changes) {
333
+ console.log(Ui.bold(`${change.file}:${change.line}`));
334
+ console.log(Ui.red(` - ${change.before.trim()}`));
335
+ console.log(Ui.green(` + ${change.after.trim()}`));
336
+ }
337
+ for (const move of plan.moves) console.log(`${Ui.bold("move")} ${move.from} to ${move.to}`);
338
+
339
+ if (plan.mentions.length) {
340
+ console.log(`\nStill naming ${device.name}, left as they are. Change them by hand if they mean the device:`);
341
+ for (const mention of plan.mentions) console.log(` ${mention.file}:${mention.line} ${mention.text.slice(0, 160)}`);
342
+ }
343
+
344
+ if (!confirmed) {
345
+ if (!Ui.interactive) Ui.fail("\nNothing written. Without a person to answer, add --confirm.");
346
+ console.log();
347
+ if (!(await Ui.confirm(`Rename ${device.name} to ${name}?`))) {
348
+ console.log("Nothing written.");
349
+ return;
350
+ }
351
+ }
352
+
353
+ Rename.apply(this.root, plan);
354
+ Ui.done(`Renamed. Run circuit validate, then circuit apply ${name}: the device keeps its old hostname until then.`);
355
+ }
356
+
357
+ /** Every secret the config names, as `secret("NAME")` writes it. */
358
+ references(): string[] {
359
+ return [...Secrets.referencesIn([this.devices, this.project.networks.map((network) => network.settings.integrations)])].sort();
360
+ }
361
+
362
+ /**
363
+ * Each BGP neighbor in the config next to its live session, on the device named or the only one with BGP neighbors, and only those whose name matches `neighbors` when given. With `wait`, reads again until every neighbor shown, or that many, is up, and fails when time runs out. With `clearLimit`, opens again the neighbors shown that closed on their prefix limit, and fails when none did.
364
+ */
365
+ async bgp(
366
+ deviceName: string | undefined,
367
+ flags: { neighbors?: string; wait?: true | number; max: number; log?: true | number; clearLimit?: true }
368
+ ): Promise<void> {
369
+ const device = await this.withSecrets(await this.bgpDevice(deviceName));
370
+ const pattern = flags.neighbors;
371
+ const adapter = this.adapterOf(device);
372
+
373
+ const supported = Platforms.all()
374
+ .filter((platform) => platform.bgpSessions)
375
+ .map((platform) => platform.name)
376
+ .join(", ");
377
+ if (!adapter.bgpSessions) Ui.fail(`circuit bgp does not support ${device.platform} yet, only ${supported}`);
378
+ if (flags.log && !adapter.bgpLog) Ui.fail(`circuit bgp --log does not support ${device.platform} yet`);
379
+ if (flags.clearLimit && !adapter.bgpClearLimit) Ui.fail(`circuit bgp --clear-limit does not support ${device.platform} yet`);
380
+ if (flags.clearLimit && flags.wait !== undefined) Ui.fail("--clear-limit and --wait go in two runs: clear first, then wait");
381
+
382
+ let matcher: RegExp;
383
+ try {
384
+ matcher = new RegExp(pattern ?? ".");
385
+ } catch (error) {
386
+ Ui.fail(`${pattern} is not a regular expression: ${(error as Error).message}`);
387
+ }
388
+
389
+ const names = Object.keys(device.routing?.bgp?.neighbors ?? {});
390
+ if (!names.length) Ui.fail(`${device.name} has no BGP neighbors`);
391
+
392
+ const shown = names.filter((name) => matcher.test(name));
393
+ if (!shown.length) Ui.fail(`no BGP neighbor of ${device.name} matches ${matcher}; known: ${names.join(", ")}`);
394
+
395
+ const want = flags.wait === undefined ? 0 : flags.wait === true ? shown.length : flags.wait;
396
+ const session = await Ui.step(`Connecting to ${device.name}`, () => adapter.session(device, this.optionsFor(device)));
397
+
398
+ try {
399
+ const read = () =>
400
+ Ui.step(`Reading ${device.name}'s BGP sessions`, async () =>
401
+ (await adapter.bgpSessions!(device, session)).filter((neighbor) => shown.includes(neighbor.neighbor))
402
+ );
403
+
404
+ if (flags.clearLimit) {
405
+ await ProjectCommands.clearLimits(adapter, session, read);
406
+ return;
407
+ }
408
+
409
+ const limits = { seconds: flags.max, every: BgpWait.COMMAND.every };
410
+ const until = () =>
411
+ BgpWait.until(
412
+ read,
413
+ (sessions) => BgpWait.upCount(sessions) >= want,
414
+ limits,
415
+ (sessions) =>
416
+ console.log(
417
+ `${new Date().toLocaleTimeString()}: ${BgpWait.upCount(sessions)}/${want} up, checking again in ${limits.every}s`
418
+ )
419
+ );
420
+ // While it waits, one spinner covers the whole wait, and each read inside it shows none of its own.
421
+ const sessions = want ? await Ui.step(`Waiting for ${want} BGP session(s) of ${device.name} to come up`, until) : await until();
422
+
423
+ ProjectCommands.printBgp(sessions);
424
+ if (BgpWait.upCount(sessions) < want) process.exitCode = 1;
425
+
426
+ if (flags.log) await this.printBgpLog(adapter, session, flags.log === true ? 5 : flags.log);
427
+ } finally {
428
+ session.close();
429
+ }
430
+ }
431
+
432
+ /** Open again the sessions closed on their limit, say which others were left alone, and show them all after. */
433
+ private static async clearLimits(adapter: Platform, session: ReadSession, read: () => Promise<BgpSession[]>): Promise<void> {
434
+ const sessions = await read();
435
+ const closed = sessions.filter((bgp) => bgp.limit);
436
+
437
+ if (!closed.length) {
438
+ ProjectCommands.printBgp(sessions);
439
+ console.error("\nno neighbor shown is closed on its prefix limit, so nothing was cleared");
440
+ process.exitCode = 1;
441
+ return;
442
+ }
443
+
444
+ for (const bgp of sessions) if (!bgp.limit) console.log(`${bgp.neighbor}: left alone, not closed on its limit`);
445
+ await Ui.step(`Clearing ${closed.map((bgp) => bgp.neighbor).join(", ")}`, () => adapter.bgpClearLimit!(session, closed));
446
+ console.log(
447
+ `cleared ${closed.map((bgp) => bgp.neighbor).join(", ")}: each connects again, and closes again if it still sends too much\n`
448
+ );
449
+
450
+ ProjectCommands.printBgp(await read());
451
+ }
452
+
453
+ /** The device `circuit bgp` reads: the one named, the only device with BGP neighbors, or one a person picks from those. */
454
+ private async bgpDevice(name: string | undefined): Promise<Device> {
455
+ if (name !== undefined) return this.pick(name);
456
+
457
+ const withBgp = this.devices.filter((device) => Object.keys(device.routing?.bgp?.neighbors ?? {}).length);
458
+ if (withBgp.length === 1) return withBgp[0]!;
459
+ if (!withBgp.length) Ui.fail("No device has BGP neighbors.");
460
+ if (Ui.interactive) return this.pick(undefined, withBgp);
461
+
462
+ return Ui.fail(`Several devices have BGP neighbors, so name one: ${withBgp.map((device) => device.name).join(", ")}`);
463
+ }
464
+
465
+ /** One line per neighbor: name, up or down, prefixes received, uptime, remote address. Then how many are up. */
466
+ private static printBgp(sessions: readonly BgpSession[]): void {
467
+ const why = sessions.some((session) => session.reason);
468
+ const columns = [
469
+ { title: "Neighbor" },
470
+ { title: "State" },
471
+ { title: "Prefixes", right: true },
472
+ { title: "Uptime" },
473
+ { title: "Remote" },
474
+ ...(why ? [{ title: "Why" }] : []),
475
+ ];
476
+ const rows = sessions.map((session) => [
477
+ session.neighbor,
478
+ session.established ? Ui.green("up") : Ui.red("DOWN"),
479
+ session.prefixes,
480
+ session.uptime,
481
+ session.remote,
482
+ ...(why ? [ProjectCommands.downReason(session)] : []),
483
+ ]);
484
+
485
+ console.log(Ui.table(columns, rows));
486
+ console.log(`${BgpWait.upCount(sessions)}/${sessions.length} up`);
487
+
488
+ for (const burst of BgpWait.limitBursts(sessions)) {
489
+ const names = burst.map((session) => session.neighbor);
490
+ const listed = names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : names[0];
491
+ const seconds = Math.round((burst.at(-1)!.limit!.stoppedAt! - burst[0]!.limit!.stoppedAt!) / 1000);
492
+
493
+ console.log(
494
+ `
495
+ ${listed} closed on their prefix limit within ${seconds} s of each other, from ${burst[0]!.limit!.stopped}. Several limits at once is usually a leak upstream, not growth: find the leak before raising a limit, then clear them with --clear-limit.`
496
+ );
497
+ }
498
+ }
499
+
500
+ /** Why a session is down, with its limit and when it closed when that is why. */
501
+ private static downReason(session: BgpSession): string {
502
+ const { limit } = session;
503
+ if (!limit) return session.reason ?? "";
504
+
505
+ const over = limit.max !== undefined ? `: over ${limit.max} prefixes` : "";
506
+ const when = limit.stopped ? `, closed ${limit.stopped}` : "";
507
+ return `${session.reason}${over}${when}`;
508
+ }
509
+
510
+ /** The device's BGP log lines from the last `minutes`, a message repeated folded into one line, secrets hidden. */
511
+ private async printBgpLog(adapter: Platform, session: ReadSession, minutes: number): Promise<void> {
512
+ const lines = ProjectCommands.foldLog(
513
+ await Ui.step(`Reading the BGP log of the last ${minutes} min`, () => adapter.bgpLog!(session, minutes))
514
+ );
515
+ const text = `\n--- BGP log, last ${minutes} min\n${lines.join("\n") || "(nothing)"}\n`;
516
+
517
+ process.stdout.write(Secrets.scrub(text, { network: this.devices }));
518
+ }
519
+
520
+ /** A message repeated, such as one every 10 seconds, becomes one line with when it came first, when last, and how many times. */
521
+ private static foldLog(lines: readonly BgpLogLine[]): string[] {
522
+ const folded = new Map<string, { count: number; first: string; last: string }>();
523
+
524
+ for (const { at, message } of lines) {
525
+ const seen = folded.get(message);
526
+ if (seen) {
527
+ seen.count++;
528
+ seen.last = at;
529
+ } else {
530
+ folded.set(message, { count: 1, first: at, last: at });
531
+ }
532
+ }
533
+
534
+ return [...folded].map(([message, { count, first, last }]) => {
535
+ const lastTime = last.split(" ")[1] ?? last;
536
+ return count > 1 ? `${first}..${lastTime} x${count} ${message}` : `${first} ${message}`;
537
+ });
538
+ }
539
+
540
+ /**
541
+ * A device's console, for a person: logged in with the platform's own login, then joined to this terminal, or sent each line. Logs out when done, and lets the port go on Ctrl-C or when the terminal closes, so no other program finds it held.
542
+ */
543
+ async accessConsole(name: string | undefined, lines: string[], raw: boolean): Promise<void> {
544
+ const device = await this.withSecrets(await this.pick(name));
545
+ const dialect = Platforms.find(device.platform)?.console ?? Ui.fail(`${device.platform} cannot be reached through a console yet`);
546
+ const serial = device.connection.console ?? Ui.fail(`${device.name} has no connection.console`);
547
+ const login = raw ? undefined : SerialConsole.of(device);
548
+
549
+ let opened: ConsoleSession;
550
+ try {
551
+ opened = await Ui.step(`Opening ${device.name}'s console, ${serial.port}`, () => SerialConsole.open(serial, 1));
552
+ } catch (error) {
553
+ const message = (error as Error).message;
554
+ if (message.startsWith("busy:")) {
555
+ Ui.fail(
556
+ `${device.name}: ${serial.port} is in use. Only one program can hold a console port: close the other one, then try again.`
557
+ );
558
+ }
559
+ Ui.fail(`${device.name}: ${message}`);
560
+ }
561
+
562
+ let loggedIn = false;
563
+ let leaving = false;
564
+
565
+ const leave = async (code: number) => {
566
+ if (leaving) return;
567
+ leaving = true;
568
+
569
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
570
+ if (loggedIn) opened.channel.write(`${dialect.logout}\n`);
571
+
572
+ await opened.close();
573
+ process.exit(code);
574
+ };
575
+
576
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.once(signal, () => void leave(130));
577
+
578
+ opened.channel.on("close", () => {
579
+ if (leaving) return;
580
+
581
+ console.error("\nthe console closed");
582
+ process.exit(1);
583
+ });
584
+
585
+ if (login) {
586
+ try {
587
+ await Ui.step(`Logging in to ${device.name}'s console as ${login.user}`, () =>
588
+ SerialConsole.login(opened.channel, dialect, login.user, login.password, serial.password_only)
589
+ );
590
+ } catch (error) {
591
+ console.error(`${device.name}: ${(error as Error).message}`);
592
+ await leave(1);
593
+ }
594
+ loggedIn = true;
595
+ }
596
+
597
+ if (lines.length) {
598
+ await ConsoleScript.run(opened.channel, lines, (answer) =>
599
+ process.stdout.write(Secrets.scrub(answer, { network: this.devices }))
600
+ );
601
+ await leave(0);
602
+ return;
603
+ }
604
+
605
+ this.join(opened, device, loggedIn ? login?.user : undefined, leave);
606
+ }
607
+
608
+ /** Join the console to this terminal until Ctrl-] or the end of input. Every other key, Ctrl-C too, goes to the device. */
609
+ private join(opened: ConsoleSession, device: Device, user: string | undefined, leave: (code: number) => Promise<void>): void {
610
+ const stdin = process.stdin;
611
+ const as = user ? `, logged in as ${user}` : "";
612
+ console.error(`${device.name}'s console${as}. Ctrl-] leaves.`);
613
+
614
+ opened.channel.on("data", (chunk: Buffer) => process.stdout.write(chunk));
615
+
616
+ if (stdin.isTTY) stdin.setRawMode(true);
617
+
618
+ stdin.on("data", (data: Buffer) => {
619
+ const at = data.indexOf(ProjectCommands.LEAVE_KEY);
620
+ const keys = at >= 0 ? data.subarray(0, at) : data;
621
+
622
+ // A terminal sends a carriage return for Enter; text piped in ends its lines with a line feed, which the console takes as a keyboard's Enter.
623
+ if (keys.length) opened.channel.write(stdin.isTTY ? keys : keys.toString("utf8"));
624
+ if (at >= 0) void leave(0);
625
+ });
626
+
627
+ stdin.on("end", () => void leave(0));
628
+ stdin.resume();
629
+ }
630
+
631
+ /** Ping the device and stop when the path loses too much or is too slow, unless `force`. A device reached through its console has no path to measure. */
632
+ private async checkPath(device: Device, force: boolean): Promise<void> {
633
+ const host = device.connection.host;
634
+ if (!host || this.optionsFor(device).console) return;
635
+
636
+ const measure = await Ui.step(`Measuring the path to ${host}`, () => PathCheck.measure(host));
637
+ if (!measure) {
638
+ console.log(`path: no ping program here, so the path to ${host} was not measured`);
639
+ return;
640
+ }
641
+
642
+ const { refuse, summary } = PathCheck.verdict(host, measure);
643
+ console.log(`path: ${summary}`);
644
+ if (!refuse) return;
645
+
646
+ const limits = `at most ${PathCheck.MAX_LOSS_PERCENT}% lost and ${PathCheck.MAX_AVERAGE_MS} ms on average`;
647
+ if (force) {
648
+ console.log(`path: worse than ${limits}; going ahead, as --force asks`);
649
+ return;
650
+ }
651
+
652
+ Ui.fail(
653
+ `\nNot sent: the path is worse than ${limits}. A change sent over a path that drops packets can cut the device off halfway. Fix the path, or add --force to go ahead anyway.`
654
+ );
655
+ }
656
+ }