@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
package/src/cli/index.ts
CHANGED
|
@@ -1,1282 +1,462 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
2
|
-
// The command
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
// The `circuit` command. Alone, it opens a menu; with a command, such as `circuit validate`, it runs that command. Before each command it checks that this Circuit is current (see `Releases`).
|
|
3
3
|
|
|
4
|
-
import { Command, InvalidArgumentError
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { basename, dirname, join, relative, resolve, sep } from "node:path";
|
|
8
|
-
import { Platforms } from "../platforms";
|
|
9
|
-
import type { ApplyOptions, BgpLogLine, BgpSession, Platform, Plan, ReadSession } from "../platforms/platform";
|
|
4
|
+
import { Command, InvalidArgumentError } from "@commander-js/extra-typings";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { OnePassword, SecretsFile } from "../core/secrets-file";
|
|
10
7
|
import { BgpWait } from "../platforms/bgp-wait";
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import
|
|
20
|
-
import {
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import type { Device } from "../schema";
|
|
24
|
-
import { GraphFormat } from "./graph-format";
|
|
25
|
-
import { Rename, type RenamePlan } from "./rename";
|
|
26
|
-
import { ProjectFiles } from "../core/project-files";
|
|
27
|
-
import { ConsoleScript, type ConsoleSession, SerialConsole } from "../transport/console";
|
|
28
|
-
import { PathCheck } from "../transport/path-check";
|
|
29
|
-
import { StudioModel } from "../studio/data";
|
|
30
|
-
import type { ScaffoldOptions } from "./new";
|
|
8
|
+
import { App } from "./app";
|
|
9
|
+
import { SecretCommands } from "./secrets";
|
|
10
|
+
import { GitHooks } from "./git-hooks";
|
|
11
|
+
import { Migrate } from "./migrate";
|
|
12
|
+
import { ProjectCommands } from "./project-commands";
|
|
13
|
+
import { Releases } from "./releases";
|
|
14
|
+
import { Remember, SecretsAgent } from "./remember";
|
|
15
|
+
import { Format } from "./format";
|
|
16
|
+
import { Setup } from "./setup";
|
|
17
|
+
import { Versions } from "./versions";
|
|
18
|
+
import { type Choice, Ui } from "./ui";
|
|
19
|
+
import { VERSION } from "./version";
|
|
31
20
|
|
|
32
|
-
const GENERATED_README = `# Written by Circuit
|
|
33
|
-
|
|
34
|
-
Do not edit anything here: an edit changes nothing on a device, and Circuit overwrites it. Change the config instead.
|
|
35
|
-
|
|
36
|
-
\`state/\` holds what each device ran when Circuit last read it, with secrets removed. \`circuit snapshot\` and \`circuit apply\` write it, and \`circuit diff\` plans against it. Commit it, so the history of this directory is the history of the network.
|
|
37
|
-
|
|
38
|
-
A \`.lldp.json\` file beside a device's config is what the device saw over LLDP at the last \`circuit snapshot\`: which device and port is at the far end of each cable. \`circuit validate\` compares it with each port's \`link\`; \`circuit diff\` ignores it.
|
|
39
|
-
`;
|
|
40
|
-
|
|
41
|
-
/** One run of the command line: the network it loaded, and how it reaches devices. */
|
|
42
21
|
class Cli {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
/** Ctrl-], the key that leaves `circuit console`, as telnet uses it. */
|
|
46
|
-
private static readonly LEAVE_KEY = 0x1d;
|
|
47
|
-
|
|
48
|
-
/** `--config`, which every command but `new` takes. */
|
|
49
|
-
private static readonly CONFIG_FLAGS = "--config <path>";
|
|
50
|
-
private static readonly CONFIG_HELP = `the network's config file, ${Cli.DEFAULT_CONFIG} in the working directory by default`;
|
|
22
|
+
/** Commands the manager runs itself, whatever version a project pins. */
|
|
23
|
+
private static readonly APP_COMMANDS = new Set(["update", "uninstall", "new"]);
|
|
51
24
|
|
|
52
|
-
/**
|
|
53
|
-
private readonly root: string;
|
|
54
|
-
/** What Circuit writes, beside the config. */
|
|
55
|
-
private readonly generated: string;
|
|
56
|
-
private readonly stateDirectory: string;
|
|
57
|
-
|
|
58
|
-
private constructor(
|
|
59
|
-
private readonly configPath: string,
|
|
60
|
-
private readonly network: Network,
|
|
61
|
-
/** How a device is reached and what a plan sends: `apply`'s flags, the defaults for every other command. */
|
|
62
|
-
private readonly options: ApplyOptions
|
|
63
|
-
) {
|
|
64
|
-
this.root = dirname(configPath);
|
|
65
|
-
this.generated = join(this.root, ".circuit");
|
|
66
|
-
this.stateDirectory = join(this.generated, "state");
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Read the arguments and run the command they name. */
|
|
25
|
+
/** Read the arguments and run the command they name, or open the menu. */
|
|
70
26
|
static async main(argv: string[]): Promise<void> {
|
|
71
|
-
const
|
|
27
|
+
const command = argv[0];
|
|
28
|
+
// The process that keeps remembered secrets, started after a yes to remembering them.
|
|
29
|
+
if (command === SecretsAgent.COMMAND) return SecretsAgent.serve();
|
|
30
|
+
// This binary's own version and nothing else: no update, no project's version. The installers read it to learn which Circuit is installed.
|
|
31
|
+
if (command === "--version" || command === "-V") return console.log(VERSION);
|
|
32
|
+
Releases.tidy();
|
|
33
|
+
OnePassword.waiting = (message) => Ui.blocked(message);
|
|
34
|
+
|
|
35
|
+
// The manager keeps itself current before every command but `uninstall`. A version it started, or Studio's own reads, leave that to it.
|
|
36
|
+
if (command !== "uninstall" && !Versions.started() && !process.env.CIRCUIT_LOCAL) await Releases.check(argv);
|
|
37
|
+
|
|
38
|
+
if (!Cli.APP_COMMANDS.has(command ?? "")) {
|
|
39
|
+
Cli.runLocal(argv);
|
|
40
|
+
const root = ProjectCommands.find();
|
|
41
|
+
if (root) {
|
|
42
|
+
if (Releases.binary()) Versions.remember(root);
|
|
43
|
+
await Versions.run(root, argv);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
72
46
|
|
|
73
|
-
|
|
47
|
+
const program = Cli.program();
|
|
74
48
|
if (!argv.length) {
|
|
75
|
-
|
|
76
|
-
|
|
49
|
+
if (!Ui.interactive) {
|
|
50
|
+
program.outputHelp();
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
argv = await Cli.menu();
|
|
77
54
|
}
|
|
78
55
|
|
|
56
|
+
await Remember.load();
|
|
79
57
|
await program.parseAsync(argv, { from: "user" });
|
|
58
|
+
await Remember.offer();
|
|
80
59
|
}
|
|
81
60
|
|
|
82
|
-
/**
|
|
83
|
-
private static
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!existsSync(configPath)) {
|
|
87
|
-
Cli.fail(
|
|
88
|
-
configArgument
|
|
89
|
-
? `no config at ${configPath}`
|
|
90
|
-
: `no ${Cli.DEFAULT_CONFIG} in ${process.cwd()}; name another with --config <path>`
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
Secrets.useRoot(dirname(configPath));
|
|
95
|
-
|
|
96
|
-
const network = await Cli.load(configPath);
|
|
97
|
-
const options: ApplyOptions = {
|
|
98
|
-
secrets: flags.secrets ?? false,
|
|
99
|
-
rollback: flags.rollback ?? 10,
|
|
100
|
-
...(flags.console ? { console: true } : {}),
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
return new Cli(configPath, network, options);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** The config, a TypeScript file. Bun imports it as it is; Node needs jiti, which also resolves imports written without an extension. */
|
|
107
|
-
private static async load(path: string): Promise<Network> {
|
|
108
|
-
if (process.versions.bun) return (await import(path)).default;
|
|
109
|
-
|
|
110
|
-
const { createJiti } = await import("jiti");
|
|
111
|
-
return createJiti(import.meta.url).import(path, { default: true });
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
private static fail(message: string): never {
|
|
115
|
-
console.error(message);
|
|
116
|
-
process.exit(1);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** `--rollback`'s value: a whole number of minutes. */
|
|
120
|
-
private static minutes(value: string): number {
|
|
121
|
-
const minutes = Number(value);
|
|
122
|
-
if (!Number.isInteger(minutes) || minutes < 1) throw new InvalidArgumentError("Give a whole number of minutes, such as 10.");
|
|
61
|
+
/** In a project with a full install, run the Circuit pinned in it instead of this one, so a team runs one version. */
|
|
62
|
+
private static runLocal(argv: string[]): void {
|
|
63
|
+
if (process.env.CIRCUIT_LOCAL || !Releases.binary()) return;
|
|
123
64
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
/** `--timeout`'s value: a whole number of seconds. */
|
|
128
|
-
private static seconds(value: string): number {
|
|
129
|
-
const seconds = Number(value);
|
|
130
|
-
if (!Number.isInteger(seconds) || seconds < 1) throw new InvalidArgumentError("Give a whole number of seconds, such as 90.");
|
|
65
|
+
const root = ProjectCommands.find();
|
|
66
|
+
const local = root && Setup.localCircuit(root);
|
|
67
|
+
if (!local) return;
|
|
131
68
|
|
|
132
|
-
|
|
69
|
+
const result = spawnSync("bun", [local, ...argv], { stdio: "inherit", env: { ...process.env, CIRCUIT_LOCAL: "1" } });
|
|
70
|
+
if (result.error)
|
|
71
|
+
Ui.fail(
|
|
72
|
+
`This project runs Circuit ${Setup.pinned(root) ?? ""} with Bun, and Bun is not installed. Install it from https://bun.sh.`
|
|
73
|
+
);
|
|
74
|
+
process.exit(result.status ?? 1);
|
|
133
75
|
}
|
|
134
76
|
|
|
135
|
-
/**
|
|
136
|
-
private static
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
77
|
+
/** A whole number of minutes, seconds or sessions, as a flag takes it. */
|
|
78
|
+
private static whole(unit: string, example: number) {
|
|
79
|
+
return (value: string): number => {
|
|
80
|
+
const number = Number(value);
|
|
81
|
+
if (!Number.isInteger(number) || number < 1)
|
|
82
|
+
throw new InvalidArgumentError(`Give a whole number of ${unit}, such as ${example}.`);
|
|
83
|
+
return number;
|
|
84
|
+
};
|
|
141
85
|
}
|
|
142
86
|
|
|
143
87
|
/** Every command, with its arguments and options. `circuit --help` and `circuit <command> --help` are written from these. */
|
|
144
88
|
private static program() {
|
|
145
89
|
const program = new Command("circuit")
|
|
146
90
|
.description("Describe each network device in TypeScript, see what would change, then make the device match.")
|
|
147
|
-
.
|
|
91
|
+
.version(VERSION)
|
|
92
|
+
.usage("[command] [device] [options]")
|
|
148
93
|
.showHelpAfterError("(add --help to see what it takes)");
|
|
149
94
|
|
|
150
|
-
program
|
|
151
|
-
.command("new")
|
|
152
|
-
.description("start a project from a pattern, asking what it needs")
|
|
153
|
-
.argument("[directory]", "where to create it")
|
|
154
|
-
.option("--pattern <name>", "single-site, edge-router or colocation")
|
|
155
|
-
.option("--asn <number>", "your AS number, for edge-router and colocation")
|
|
156
|
-
.option("--1password", "write .env with 1Password references instead of empty values")
|
|
157
|
-
.option("--use <manager>", "npm, pnpm or bun; the one that ran this by default")
|
|
158
|
-
.option("--no-install", "do not install the dependencies")
|
|
159
|
-
.action(async (directory, flags) => {
|
|
160
|
-
const { Scaffold } = await import("./new");
|
|
161
|
-
const options: ScaffoldOptions = {
|
|
162
|
-
pattern: flags.pattern,
|
|
163
|
-
asn: flags.asn,
|
|
164
|
-
onePassword: flags["1password"],
|
|
165
|
-
use: flags.use,
|
|
166
|
-
install: flags.install,
|
|
167
|
-
};
|
|
168
|
-
|
|
169
|
-
await Scaffold.create(directory, options);
|
|
170
|
-
process.exit(0);
|
|
171
|
-
});
|
|
95
|
+
program.commandsGroup("Core:");
|
|
172
96
|
|
|
173
97
|
program
|
|
174
98
|
.command("validate")
|
|
175
|
-
.description("check the
|
|
176
|
-
.option(
|
|
177
|
-
.action(async (flags) => (await
|
|
178
|
-
|
|
179
|
-
program
|
|
180
|
-
.command("build")
|
|
181
|
-
.description("print the configuration the device should run, without contacting it")
|
|
182
|
-
.argument("<device>")
|
|
183
|
-
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
184
|
-
.action(async (device, flags) => (await Cli.open(flags.config)).build(device));
|
|
99
|
+
.description("check every file of the project, and every check in circuit.ts")
|
|
100
|
+
.option("--experimental", "allow what a platform renders but has not proved on a device yet, as a warning")
|
|
101
|
+
.action(async (flags) => (await ProjectCommands.open(flags)).validate());
|
|
185
102
|
|
|
186
103
|
program
|
|
187
104
|
.command("diff")
|
|
188
|
-
.description("
|
|
189
|
-
.argument("[device
|
|
105
|
+
.description("read the device, and show what apply would change on it")
|
|
106
|
+
.argument("[device]", "picked from a list when left out")
|
|
190
107
|
.option("--secrets", "plan as apply --secrets would, sending every secret again")
|
|
191
|
-
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
192
|
-
.action(async (devices, flags) => (await Cli.open(flags.config, flags)).diff(devices));
|
|
193
|
-
|
|
194
|
-
program
|
|
195
|
-
.command("snapshot")
|
|
196
|
-
.description("read what each device runs into the state directory")
|
|
197
|
-
.argument("[device...]", "every device when none is named")
|
|
198
108
|
.option("--console", "through the device's serial console on its jump host, instead of SSH")
|
|
199
|
-
.option(
|
|
200
|
-
.action(async (
|
|
109
|
+
.option("--experimental", "allow what a platform renders but has not proved on a device yet, as a warning")
|
|
110
|
+
.action(async (device, flags) => (await ProjectCommands.open(flags)).diff(device));
|
|
201
111
|
|
|
202
112
|
program
|
|
203
113
|
.command("apply")
|
|
204
|
-
.description("read the device,
|
|
205
|
-
.argument("
|
|
206
|
-
.option("--confirm", "send
|
|
207
|
-
.option("--secrets", "also send every secret, to
|
|
208
|
-
.option("--rollback <minutes>", "minutes before a device that can restore itself does", Cli.minutes, 10)
|
|
114
|
+
.description("read the device, show what would change, and send it once you say yes")
|
|
115
|
+
.argument("[device]", "picked from a list when left out")
|
|
116
|
+
.option("--confirm", "send without asking")
|
|
117
|
+
.option("--secrets", "also send every secret, to change a password")
|
|
118
|
+
.option("--rollback <minutes>", "minutes before a device that can restore itself does", Cli.whole("minutes", 10), 10)
|
|
209
119
|
.option("--console", "through the device's serial console on its jump host, instead of SSH")
|
|
210
|
-
.option("--force", "send even when the
|
|
211
|
-
.option(
|
|
120
|
+
.option("--force", "send even when the path to the device loses too much or is too slow")
|
|
121
|
+
.option("--experimental", "allow what a platform renders but has not proved on a device yet, as a warning")
|
|
212
122
|
.action(async (device, flags) =>
|
|
213
|
-
(await
|
|
123
|
+
(await ProjectCommands.open(flags)).apply(device, flags.confirm ?? false, flags.force ?? false)
|
|
214
124
|
);
|
|
215
125
|
|
|
126
|
+
program.commandsGroup("Utilities:");
|
|
127
|
+
|
|
216
128
|
program
|
|
217
|
-
.command("
|
|
218
|
-
.
|
|
219
|
-
.
|
|
129
|
+
.command("exec")
|
|
130
|
+
.alias("execute")
|
|
131
|
+
.description("run commands that only read on a device, and print what it answers, secrets hidden")
|
|
132
|
+
.argument("[device]", "picked from a list when left out")
|
|
220
133
|
.argument("[command...]", "each command in quotes, such as '/ip route print'")
|
|
221
134
|
.option("--file <path>", "also each line of this file; blank lines and lines starting with # are skipped")
|
|
222
|
-
.option("--timeout <seconds>", "how long each command may take", Cli.seconds, 90)
|
|
135
|
+
.option("--timeout <seconds>", "how long each command may take", Cli.whole("seconds", 90), 90)
|
|
223
136
|
.option("--console", "through the device's serial console, instead of SSH")
|
|
224
|
-
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
225
137
|
.action(async (device, commands, flags) =>
|
|
226
|
-
(await
|
|
138
|
+
(await ProjectCommands.open(flags)).exec(device, commands, flags.file, flags.timeout)
|
|
227
139
|
);
|
|
228
140
|
|
|
229
141
|
program
|
|
230
142
|
.command("bgp")
|
|
231
143
|
.description("each BGP neighbor in the config next to its live session: up or down and why, prefixes, uptime")
|
|
232
144
|
.argument("[device]", "may be left out when only one device has BGP neighbors")
|
|
233
|
-
// A second argument was the name filter before 0.42; it is refused below with the way to write it now.
|
|
234
|
-
.allowExcessArguments()
|
|
235
145
|
.option("--neighbors <regex>", "only the neighbors whose name matches")
|
|
236
146
|
.option(
|
|
237
147
|
"--wait [count]",
|
|
238
148
|
`read again every ${BgpWait.COMMAND.every} seconds until every neighbor shown, or this many, is up`,
|
|
239
|
-
Cli.
|
|
149
|
+
Cli.whole("sessions", 4)
|
|
240
150
|
)
|
|
241
|
-
.option("--max <seconds>", "how long --wait waits at most", Cli.seconds, BgpWait.COMMAND.seconds)
|
|
242
|
-
.option("--log [minutes]", "also print the device's BGP log lines from the last minutes, 5 by default", Cli.minutes)
|
|
151
|
+
.option("--max <seconds>", "how long --wait waits at most", Cli.whole("seconds", 90), BgpWait.COMMAND.seconds)
|
|
152
|
+
.option("--log [minutes]", "also print the device's BGP log lines from the last minutes, 5 by default", Cli.whole("minutes", 5))
|
|
243
153
|
.option("--clear-limit", "open again the neighbors shown that closed on their prefix limit; any other is left alone")
|
|
244
|
-
.
|
|
245
|
-
.action(async (device, flags, command) => {
|
|
246
|
-
const [, filter] = command.args;
|
|
247
|
-
if (filter !== undefined)
|
|
248
|
-
Cli.fail(
|
|
249
|
-
`circuit bgp takes one device; filter the neighbors with --neighbors: circuit bgp ${device} --neighbors '${filter}'`
|
|
250
|
-
);
|
|
251
|
-
(await Cli.open(flags.config)).bgp(device, flags);
|
|
252
|
-
});
|
|
154
|
+
.action(async (device, flags) => (await ProjectCommands.open()).bgp(device, flags));
|
|
253
155
|
|
|
254
156
|
program
|
|
255
|
-
.command("console")
|
|
157
|
+
.command("access-console")
|
|
256
158
|
.description("open a device's serial console here, logged in; or send it lines and print what it answers")
|
|
257
|
-
.argument("
|
|
159
|
+
.argument("[device]", "picked from a list when left out")
|
|
258
160
|
.argument("[line...]", "lines to send, each in quotes; none joins the console to this terminal, Ctrl-] leaves")
|
|
259
161
|
.option("--raw", "do not log in or out: only join the console, or send the lines")
|
|
260
|
-
.
|
|
261
|
-
.action(async (device, lines, flags) => (await Cli.open(flags.config)).console(device, lines, flags.raw ?? false));
|
|
162
|
+
.action(async (device, lines, flags) => (await ProjectCommands.open()).accessConsole(device, lines, flags.raw ?? false));
|
|
262
163
|
|
|
263
164
|
program
|
|
264
|
-
.command("
|
|
265
|
-
.description("
|
|
266
|
-
.
|
|
267
|
-
.option(
|
|
268
|
-
|
|
269
|
-
|
|
165
|
+
.command("studio")
|
|
166
|
+
.description("the project in your browser: the graph, every table, tools, compare and secrets")
|
|
167
|
+
.option("--port <number>", "the port it listens on", Cli.port, 4600)
|
|
168
|
+
.option(
|
|
169
|
+
"--host <address>",
|
|
170
|
+
"the address it listens on: this machine only by default; a server's address to share it",
|
|
171
|
+
"127.0.0.1"
|
|
172
|
+
)
|
|
173
|
+
.option(
|
|
174
|
+
"--allow-host <names...>",
|
|
175
|
+
"names besides this machine's that requests may be addressed to, such as studio.example.com behind a proxy"
|
|
176
|
+
)
|
|
177
|
+
.option("--no-open", "do not open the browser")
|
|
178
|
+
.action(async (flags) => Cli.studio(flags));
|
|
270
179
|
|
|
271
180
|
program
|
|
272
|
-
.command("
|
|
273
|
-
.description("a
|
|
274
|
-
.argument("
|
|
275
|
-
.argument("
|
|
276
|
-
.
|
|
277
|
-
.option(
|
|
278
|
-
.
|
|
181
|
+
.command("rename")
|
|
182
|
+
.description("give a device a new name everywhere the project writes it, and its file too")
|
|
183
|
+
.argument("[device]", "picked from a list when left out")
|
|
184
|
+
.argument("[new-name]", "asked for when left out")
|
|
185
|
+
.option("--file", "also rename the file that writes the device")
|
|
186
|
+
.option("--no-file", "leave the file's name as it is")
|
|
187
|
+
.option("--confirm", "write without asking")
|
|
188
|
+
.action(async (device, name, flags) => (await ProjectCommands.open()).rename(device, name, flags.file, flags.confirm ?? false));
|
|
279
189
|
|
|
280
190
|
program
|
|
281
|
-
.command("
|
|
282
|
-
.description("the
|
|
283
|
-
.
|
|
284
|
-
.
|
|
191
|
+
.command("fmt")
|
|
192
|
+
.description("tidy the spacing and line breaks of the project's .ts files; what they say stays the same")
|
|
193
|
+
.argument("[file...]", "every TypeScript file of the project when left out")
|
|
194
|
+
.option("--check", "change nothing; fail when a file is not in the style")
|
|
195
|
+
.action(async (files, flags) =>
|
|
196
|
+
Format.run(
|
|
197
|
+
ProjectCommands.find() ?? Ui.fail("No Circuit project here: no circuit.ts in this folder or above it."),
|
|
198
|
+
files,
|
|
199
|
+
flags.check ?? false
|
|
200
|
+
)
|
|
201
|
+
);
|
|
285
202
|
|
|
286
|
-
program
|
|
287
|
-
.command("peeringdb")
|
|
288
|
-
.description("bring PeeringDB's exchange records in line with the config, with --confirm")
|
|
289
|
-
.option("--confirm", "send the changes; without it, nothing is sent")
|
|
290
|
-
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
291
|
-
.action(async (flags) => (await Cli.open(flags.config)).peeringdb(flags.confirm ?? false));
|
|
203
|
+
program.commandsGroup("Secrets:");
|
|
292
204
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
.
|
|
298
|
-
.
|
|
205
|
+
const FILE_FLAGS = "--file <path>";
|
|
206
|
+
const FILE_HELP = "the secrets file, when it isn't .circuit/secrets";
|
|
207
|
+
// `--file` after a command, such as `circuit secrets list --file x`, lands on `secrets` itself, so each command reads it from there too.
|
|
208
|
+
const secrets = program
|
|
209
|
+
.command("secrets")
|
|
210
|
+
.alias("secret")
|
|
211
|
+
.description("the project's secrets, each one encrypted: alone, in a terminal, opens every one in a text editor")
|
|
212
|
+
.option(FILE_FLAGS, FILE_HELP)
|
|
213
|
+
.action(async (flags) => {
|
|
214
|
+
const commands = await SecretCommands.open(flags.file);
|
|
215
|
+
if (Ui.interactive) await commands.edit();
|
|
216
|
+
else commands.list();
|
|
217
|
+
});
|
|
218
|
+
secrets
|
|
219
|
+
.command("edit")
|
|
220
|
+
.description("every secret, decrypted, in a text editor in the terminal; saved encrypted")
|
|
221
|
+
.option(FILE_FLAGS, FILE_HELP)
|
|
222
|
+
.action(async (flags) => (await SecretCommands.open(flags.file ?? secrets.opts().file)).edit());
|
|
223
|
+
secrets
|
|
224
|
+
.command("list")
|
|
225
|
+
.description("every secret, how it is kept, and the ones the config needs that the file lacks")
|
|
226
|
+
.option(FILE_FLAGS, FILE_HELP)
|
|
227
|
+
.action(async (flags) => (await SecretCommands.open(flags.file ?? secrets.opts().file)).list());
|
|
228
|
+
secrets
|
|
229
|
+
.command("get")
|
|
230
|
+
.description("print one secret, decrypted")
|
|
231
|
+
.argument("[name]")
|
|
232
|
+
.option(FILE_FLAGS, FILE_HELP)
|
|
233
|
+
.action(async (name, flags) => (await SecretCommands.open(flags.file ?? secrets.opts().file)).get(name));
|
|
234
|
+
secrets
|
|
235
|
+
.command("set")
|
|
236
|
+
.description("add or change a secret, encrypted; leave out the value to type it hidden")
|
|
237
|
+
.argument("[name]")
|
|
238
|
+
.argument("[value]", "an op://vault/item/field reference keeps the value in 1Password")
|
|
239
|
+
.option(FILE_FLAGS, FILE_HELP)
|
|
240
|
+
.action(async (name, value, flags) => (await SecretCommands.open(flags.file ?? secrets.opts().file)).set(name, value));
|
|
241
|
+
secrets
|
|
242
|
+
.command("delete")
|
|
243
|
+
.description("remove a secret")
|
|
244
|
+
.argument("[name]")
|
|
245
|
+
.option("--confirm", "delete without asking")
|
|
246
|
+
.option(FILE_FLAGS, FILE_HELP)
|
|
247
|
+
.action(async (name, flags) =>
|
|
248
|
+
(await SecretCommands.open(flags.file ?? secrets.opts().file)).delete(name, flags.confirm ?? false)
|
|
249
|
+
);
|
|
250
|
+
secrets
|
|
251
|
+
.command("encrypt")
|
|
252
|
+
.description("encrypt every value typed into the file in plain text")
|
|
253
|
+
.option(FILE_FLAGS, FILE_HELP)
|
|
254
|
+
.action(async (flags) => (await SecretCommands.open(flags.file ?? secrets.opts().file)).encrypt());
|
|
255
|
+
secrets
|
|
256
|
+
.command("password")
|
|
257
|
+
.description("change the password; every secret is encrypted again and the old password stops working")
|
|
258
|
+
.option("--1password <reference>", "read the password from 1Password instead of asking: op://vault/item/field")
|
|
259
|
+
.option("--forget-1password", "stop reading it from 1Password, and ask for it")
|
|
260
|
+
.option("--confirm", "change without asking; the new password comes from CIRCUIT_SECRETS_NEW_PASSWORD")
|
|
261
|
+
.option(FILE_FLAGS, FILE_HELP)
|
|
262
|
+
.action(async (flags) =>
|
|
263
|
+
(await SecretCommands.open(flags.file ?? secrets.opts().file)).password({
|
|
264
|
+
...(flags["1password"] !== undefined ? { onePassword: flags["1password"] } : {}),
|
|
265
|
+
forgetOnePassword: flags.forget1password ?? false,
|
|
266
|
+
confirm: flags.confirm ?? false,
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
secrets
|
|
271
|
+
.command("forget")
|
|
272
|
+
.description("forget every secret Circuit remembers, now")
|
|
273
|
+
.action(async () => Remember.forget());
|
|
274
|
+
|
|
275
|
+
program.commandsGroup("Circuit:");
|
|
299
276
|
|
|
300
277
|
program
|
|
301
|
-
.command("
|
|
302
|
-
.description(
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
.
|
|
278
|
+
.command("update")
|
|
279
|
+
.description(
|
|
280
|
+
"pick a newer Circuit, the newest first: in a project, for the project and the app with it; elsewhere, for the app"
|
|
281
|
+
)
|
|
282
|
+
.option("--app", "update the installed app, even in a project")
|
|
283
|
+
.option("--confirm", "update to the newest without asking")
|
|
284
|
+
.action(async (flags) => Cli.update(flags.app ?? false, flags.confirm ?? false));
|
|
306
285
|
|
|
307
286
|
program
|
|
308
|
-
.command("
|
|
309
|
-
.description("
|
|
310
|
-
.
|
|
311
|
-
.option(
|
|
312
|
-
.action(async (flags) => (
|
|
287
|
+
.command("new")
|
|
288
|
+
.description("start a new project")
|
|
289
|
+
.argument("[folder]", "asked for when left out")
|
|
290
|
+
.option("--full", "Circuit as a package in the project, run with bun circuit")
|
|
291
|
+
.action(async (folder, flags) => Setup.run(folder, { existing: "refuse", ...(flags.full ? { kind: "full" as const } : {}) }));
|
|
313
292
|
|
|
314
293
|
program
|
|
315
|
-
.command("
|
|
316
|
-
.description("
|
|
317
|
-
.option(
|
|
318
|
-
.action(async (flags) =>
|
|
294
|
+
.command("uninstall")
|
|
295
|
+
.description("remove Circuit from this machine; your projects stay")
|
|
296
|
+
.option("--confirm", "remove without asking")
|
|
297
|
+
.action(async (flags) => App.uninstall(flags.confirm ?? false));
|
|
319
298
|
|
|
320
299
|
program
|
|
321
|
-
.command("
|
|
322
|
-
.description("
|
|
323
|
-
.argument("<
|
|
324
|
-
.
|
|
325
|
-
.option("--file", "
|
|
326
|
-
.option(
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
);
|
|
300
|
+
.command("migrate")
|
|
301
|
+
.description("write a Circuit 0.x project again as a v1 project, and list what did not move")
|
|
302
|
+
.argument("<v0-folder>", "the 0.x project, with its packages installed")
|
|
303
|
+
.option("--out <folder>", "an empty folder for the v1 project; <v0-folder>-v1 by default")
|
|
304
|
+
.option("--config <file>", "0.x's config file in that folder", "circuit.config.ts")
|
|
305
|
+
.option(
|
|
306
|
+
"--yes",
|
|
307
|
+
"copy the 0.x project's .env into the secrets without asking; the password comes from CIRCUIT_SECRETS_PASSWORD"
|
|
308
|
+
)
|
|
309
|
+
.action(async (folder, flags) => Migrate.run(folder, { ...flags, yes: flags.yes ?? false }));
|
|
331
310
|
|
|
332
311
|
program
|
|
333
|
-
.command("
|
|
334
|
-
.description("
|
|
335
|
-
.argument("[
|
|
336
|
-
.
|
|
337
|
-
.option("--format <format>", "json, to print JSON")
|
|
338
|
-
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
339
|
-
.action(async (within, flags) => (await Cli.open(flags.config)).addresses(within, flags.length, flags.format));
|
|
312
|
+
.command("setup", { hidden: true })
|
|
313
|
+
.description("what the installer runs: set up the project in a folder, or offer to start one")
|
|
314
|
+
.argument("[folder]")
|
|
315
|
+
.action(async (folder) => Setup.run(folder, { existing: "ready" }));
|
|
340
316
|
|
|
341
317
|
program
|
|
342
|
-
.command("
|
|
343
|
-
.description("the
|
|
344
|
-
.argument("
|
|
345
|
-
.
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
318
|
+
.command("studio-data", { hidden: true })
|
|
319
|
+
.description("what Studio shows of the project, as JSON: Studio runs it to read the project fresh")
|
|
320
|
+
.argument("<folder>")
|
|
321
|
+
.action(async (folder) => {
|
|
322
|
+
const { StudioModel } = await import("../studio/data");
|
|
323
|
+
process.stdout.write(JSON.stringify(await StudioModel.gather(folder)));
|
|
324
|
+
});
|
|
349
325
|
|
|
350
326
|
program
|
|
351
|
-
.command("
|
|
352
|
-
.description("
|
|
353
|
-
.
|
|
354
|
-
.
|
|
355
|
-
.option("--host <address>", "listen on a server's address, such as 0.0.0.0, instead; needs --allow-host", "127.0.0.1")
|
|
356
|
-
.option("--allow-host <names>", "the names it is reached by, such as studio.example.com, several with commas")
|
|
357
|
-
.option("--json", "print what Studio shows, as JSON, instead")
|
|
358
|
-
.option(Cli.CONFIG_FLAGS, Cli.CONFIG_HELP)
|
|
359
|
-
.action(async (flags) => (await Cli.open(flags.config)).studio(flags));
|
|
327
|
+
.command("hook", { hidden: true })
|
|
328
|
+
.description("what the project's git hooks run")
|
|
329
|
+
.argument("<name>", "pre-commit")
|
|
330
|
+
.action(async (name) => Cli.hook(name));
|
|
360
331
|
|
|
361
332
|
return program;
|
|
362
333
|
}
|
|
363
334
|
|
|
364
|
-
/**
|
|
365
|
-
private
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
return names.map((name) => byName(name) ?? Cli.fail(`no device ${name}; known: ${known}`));
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
/** The device with this name. */
|
|
375
|
-
private one(name: string): Device {
|
|
376
|
-
return this.selected([name], false)[0]!;
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
/** How a device is reached: through its console when `--console` asks, or when it has no management address. */
|
|
380
|
-
private optionsFor(device: Device): ApplyOptions {
|
|
381
|
-
return this.options.console || !device.connection.host ? { ...this.options, console: true } : this.options;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
/** The adapter of a device, which must be able to use its console when the device is reached through it. */
|
|
385
|
-
private adapterOf(device: Device): Platform {
|
|
386
|
-
const adapter = Platforms.find(device.platform) ?? Cli.fail(`no adapter for ${device.platform}`);
|
|
387
|
-
if (!this.optionsFor(device).console) return adapter;
|
|
388
|
-
|
|
389
|
-
if (!adapter.console) Cli.fail(`${device.name}: ${device.platform} cannot be reached through a console yet`);
|
|
390
|
-
if (!device.connection.console) Cli.fail(`${device.name}: --console needs connection.console`);
|
|
391
|
-
return adapter;
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
private statePath(device: Device): string {
|
|
395
|
-
return join(this.stateDirectory, device.name + this.adapterOf(device).extension);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/** Record what a device runs, beside a note that the directory is generated. */
|
|
399
|
-
private writeState(device: Device, text: string): void {
|
|
400
|
-
mkdirSync(this.stateDirectory, { recursive: true });
|
|
401
|
-
writeFileSync(join(this.generated, "README.md"), GENERATED_README);
|
|
402
|
-
writeFileSync(this.statePath(device), text);
|
|
335
|
+
/** `--port`'s value: a port number. */
|
|
336
|
+
private static port(value: string): number {
|
|
337
|
+
const port = Number(value);
|
|
338
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
339
|
+
throw new InvalidArgumentError("Give a port from 1 to 65535, such as 4600.");
|
|
340
|
+
return port;
|
|
403
341
|
}
|
|
404
342
|
|
|
405
|
-
/**
|
|
406
|
-
private
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
for (const device of this.network.devices) {
|
|
410
|
-
const neighbors = Cabling.load(this.stateDirectory, device.name);
|
|
411
|
-
if (neighbors) found.set(device.name, neighbors);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
return found;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/** What `validate` finds: the config's own findings, then where LLDP at the last snapshot disagrees with `link`. */
|
|
418
|
-
private findings(): Finding[] {
|
|
419
|
-
return [...Validator.run(this.network), ...Cabling.compare(this.network.devices, this.neighbors())];
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/** Validate, print what was found, and stop on an error about one of these devices. `diff` and `apply` leave LLDP out: it is what was seen, not config. */
|
|
423
|
-
private guard(devices: Device[], all: readonly Finding[] = Validator.run(this.network)): void {
|
|
424
|
-
const relevant = new Set(devices.map((device) => device.name));
|
|
425
|
-
const findings = all.filter((finding) => !finding.device || relevant.has(finding.device));
|
|
426
|
-
|
|
427
|
-
for (const finding of findings) {
|
|
428
|
-
const where = finding.device ? `${finding.device}: ` : "";
|
|
429
|
-
const level = finding.level === "error" ? "error" : "warn ";
|
|
430
|
-
console.log(` ${level} ${where}${finding.message}`);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
const errors = findings.filter((finding) => finding.level === "error").length;
|
|
434
|
-
if (errors) Cli.fail(`\n${errors} error(s).`);
|
|
435
|
-
}
|
|
343
|
+
/** Serve Studio for the project until Ctrl+C, and open it in the browser. */
|
|
344
|
+
private static async studio(flags: { port: number; host: string; allowHost?: string[]; open: boolean }): Promise<void> {
|
|
345
|
+
const root = ProjectCommands.find() ?? Ui.fail("No Circuit project here: no circuit.ts in this folder or above it.");
|
|
346
|
+
Setup.prepare(root);
|
|
436
347
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
const
|
|
440
|
-
|
|
441
|
-
console.log(`${device.name}: ${summary}`);
|
|
442
|
-
|
|
443
|
-
for (const step of plan.steps) {
|
|
444
|
-
console.log(` ${step.title}`);
|
|
445
|
-
for (const line of step.show) console.log(` ${line.slice(0, 220)}`);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
for (const problem of plan.problems) console.log(` PROBLEM ${problem}`);
|
|
449
|
-
|
|
450
|
-
return commands + plan.problems.length;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
private validate(): void {
|
|
454
|
-
this.guard([...this.network.devices], this.findings());
|
|
455
|
-
console.log(`${this.network.devices.length} device(s), no errors.`);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
private build(name: string): void {
|
|
459
|
-
const device = this.one(name);
|
|
460
|
-
process.stdout.write(this.adapterOf(device).render(device));
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
private diff(names: string[]): void {
|
|
464
|
-
const devices = this.selected(names, true);
|
|
465
|
-
this.guard(devices);
|
|
466
|
-
|
|
467
|
-
let differences = 0;
|
|
468
|
-
|
|
469
|
-
for (const device of devices) {
|
|
470
|
-
const path = this.statePath(device);
|
|
471
|
-
if (!existsSync(path)) {
|
|
472
|
-
console.log(`${device.name}: no snapshot, run snapshot first`);
|
|
473
|
-
continue;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
const adapter = this.adapterOf(device);
|
|
477
|
-
const plan = adapter.plan(adapter.render(device), readFileSync(path, "utf8"), this.options);
|
|
478
|
-
differences += Cli.printPlan(device, plan);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
process.exit(differences ? 1 : 0);
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
private async snapshot(names: string[]): Promise<void> {
|
|
485
|
-
for (const device of this.selected(names, true)) {
|
|
486
|
-
const adapter = this.adapterOf(device);
|
|
487
|
-
const text = await adapter.read(device, this.network.devices, this.optionsFor(device));
|
|
488
|
-
this.writeState(device, text);
|
|
489
|
-
console.log(`${device.name}: ${text.split("\n").length} lines`);
|
|
490
|
-
|
|
491
|
-
await this.snapshotNeighbors(device, adapter);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
// With every device read, a snapshot of a device the config no longer has, such as one renamed, is removed.
|
|
495
|
-
if (names.length || !existsSync(this.stateDirectory)) return;
|
|
496
|
-
|
|
497
|
-
const current = new Set(
|
|
498
|
-
this.network.devices.flatMap((device) => [
|
|
499
|
-
basename(this.statePath(device)),
|
|
500
|
-
basename(Cabling.path(this.stateDirectory, device.name)),
|
|
501
|
-
])
|
|
348
|
+
// Studio carries its page, so it is loaded only when asked for.
|
|
349
|
+
const { StudioServer } = await import("../studio/server");
|
|
350
|
+
const url = await Ui.step("Starting Studio", () =>
|
|
351
|
+
StudioServer.start({ root, port: flags.port, host: flags.host, allowHosts: flags.allowHost ?? [] })
|
|
502
352
|
);
|
|
503
|
-
|
|
504
|
-
if (current.has(file)) continue;
|
|
505
|
-
|
|
506
|
-
rmSync(join(this.stateDirectory, file));
|
|
507
|
-
console.log(`${file}: removed, no such device in the config`);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
/** Save a device's LLDP neighbors beside its config, where its platform can read them. A device without LLDP is left alone. */
|
|
512
|
-
private async snapshotNeighbors(device: Device, adapter: Platform): Promise<void> {
|
|
513
|
-
if (!Cabling.runsAnywhere(device)) return;
|
|
514
|
-
|
|
515
|
-
if (!adapter.neighbors) {
|
|
516
|
-
Cabling.forget(this.stateDirectory, device.name);
|
|
517
|
-
console.log(`${device.name}: LLDP neighbors not read, reading them on ${device.platform} is not proved on a device yet`);
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
const neighbors = await adapter.neighbors(device, this.network.devices, this.optionsFor(device));
|
|
522
|
-
Cabling.save(this.stateDirectory, device.name, neighbors);
|
|
523
|
-
console.log(`${device.name}: ${neighbors.length} LLDP neighbor(s)`);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
private async apply(name: string, confirmed: boolean, force: boolean): Promise<void> {
|
|
527
|
-
const device = this.one(name);
|
|
528
|
-
this.guard([device]);
|
|
529
|
-
const adapter = this.adapterOf(device);
|
|
530
|
-
|
|
531
|
-
// A lossy path also cuts the read short, so the path is checked before the read.
|
|
532
|
-
if (confirmed) await this.checkPath(device, force);
|
|
533
|
-
|
|
534
|
-
// The plan is made against what the device runs now, never against an old snapshot.
|
|
535
|
-
const live = await adapter.read(device, this.network.devices, this.optionsFor(device));
|
|
536
|
-
this.writeState(device, live);
|
|
537
|
-
|
|
538
|
-
const plan = adapter.plan(adapter.render(device), live, this.options);
|
|
539
|
-
if (!Cli.printPlan(device, plan)) return;
|
|
540
|
-
|
|
541
|
-
if (plan.problems.length) Cli.fail("\nNot sent: resolve the problems first.");
|
|
542
|
-
|
|
543
|
-
if (!confirmed) {
|
|
544
|
-
console.log("\nNothing sent. Re-run with --confirm.");
|
|
545
|
-
return;
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
// What the checks after the apply compare against, such as the BGP sessions up now, is read before anything is sent.
|
|
549
|
-
const health = await adapter.health?.(device, plan, this.optionsFor(device));
|
|
550
|
-
|
|
551
|
-
await adapter.apply(device, this.network.devices, plan, this.optionsFor(device));
|
|
552
|
-
|
|
553
|
-
this.writeState(device, await adapter.read(device, this.network.devices, this.optionsFor(device)));
|
|
554
|
-
console.log("snapshot updated");
|
|
555
|
-
|
|
556
|
-
if (!health) return;
|
|
557
|
-
|
|
558
|
-
console.log("checking the device after the apply");
|
|
559
|
-
const problems = await health.run();
|
|
560
|
-
|
|
561
|
-
if (problems.length) {
|
|
562
|
-
Cli.fail(`\nApplied, but the device is not healthy:\n${problems.map((problem) => ` ${problem}`).join("\n")}`);
|
|
563
|
-
}
|
|
353
|
+
const local = StudioServer.isLoopback(flags.host);
|
|
564
354
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
/** Run commands that only read, each under a header with how long it took. Every command is checked before anything connects. */
|
|
569
|
-
private async show(name: string, given: string[], file: string | undefined, timeoutSeconds: number): Promise<void> {
|
|
570
|
-
const device = this.one(name);
|
|
571
|
-
const adapter = this.adapterOf(device);
|
|
572
|
-
|
|
573
|
-
const fromFile = file ? readFileSync(resolve(process.cwd(), file), "utf8").split(/\r?\n/) : [];
|
|
574
|
-
const listed = fromFile.map((line) => line.trim()).filter((line) => line && !line.startsWith("#"));
|
|
575
|
-
const commands = [...given, ...listed];
|
|
576
|
-
if (!commands.length) Cli.fail("give a command to run, or --file");
|
|
577
|
-
|
|
578
|
-
const refused = commands.filter((command) => !adapter.readOnly(command));
|
|
579
|
-
if (refused.length) {
|
|
580
|
-
Cli.fail(
|
|
581
|
-
`not known to only read on ${device.platform}, so nothing was sent:\n${refused.map((command) => ` ${command}`).join("\n")}`
|
|
582
|
-
);
|
|
583
|
-
}
|
|
584
|
-
|
|
585
|
-
const session = await adapter.session(device, this.optionsFor(device));
|
|
586
|
-
const timeout = timeoutSeconds * 1000;
|
|
587
|
-
let failed = false;
|
|
588
|
-
|
|
589
|
-
try {
|
|
590
|
-
for (const command of commands) {
|
|
591
|
-
const started = Date.now();
|
|
592
|
-
let output: string;
|
|
593
|
-
|
|
594
|
-
try {
|
|
595
|
-
output = await session.run(command, timeout);
|
|
596
|
-
} catch (error) {
|
|
597
|
-
output = `stopped: ${(error as Error).message}`;
|
|
598
|
-
failed = true;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
const took = Date.now() - started;
|
|
602
|
-
const stoppedAtLimit = !failed && took >= timeout ? `\n(stopped at the ${timeoutSeconds} second limit)` : "";
|
|
603
|
-
const block = `>>> ${command} (${(took / 1000).toFixed(1)}s)\n${output.replace(/\r/g, "").trimEnd()}${stoppedAtLimit}\n\n`;
|
|
604
|
-
process.stdout.write(Secrets.scrub(block, { network: this.network }));
|
|
605
|
-
|
|
606
|
-
// A shell or a console still runs a command that did not finish, so nothing more can be sent.
|
|
607
|
-
if (failed) break;
|
|
608
|
-
}
|
|
609
|
-
} finally {
|
|
610
|
-
session.close();
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
if (failed) process.exitCode = 1;
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
/**
|
|
617
|
-
* 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.
|
|
618
|
-
*/
|
|
619
|
-
private async bgp(
|
|
620
|
-
deviceName: string | undefined,
|
|
621
|
-
flags: { neighbors?: string; wait?: true | number; max: number; log?: true | number; clearLimit?: true }
|
|
622
|
-
): Promise<void> {
|
|
623
|
-
const device = this.bgpDevice(deviceName);
|
|
624
|
-
const pattern = flags.neighbors;
|
|
625
|
-
const adapter = this.adapterOf(device);
|
|
626
|
-
|
|
627
|
-
const supported = Platforms.all()
|
|
628
|
-
.filter((platform) => platform.bgpSessions)
|
|
629
|
-
.map((platform) => platform.name)
|
|
630
|
-
.join(", ");
|
|
631
|
-
if (!adapter.bgpSessions) Cli.fail(`circuit bgp does not support ${device.platform} yet, only ${supported}`);
|
|
632
|
-
if (flags.log && !adapter.bgpLog) Cli.fail(`circuit bgp --log does not support ${device.platform} yet`);
|
|
633
|
-
if (flags.clearLimit && !adapter.bgpClearLimit) Cli.fail(`circuit bgp --clear-limit does not support ${device.platform} yet`);
|
|
634
|
-
if (flags.clearLimit && flags.wait !== undefined) Cli.fail("--clear-limit and --wait go in two runs: clear first, then wait");
|
|
635
|
-
|
|
636
|
-
let matcher: RegExp;
|
|
637
|
-
try {
|
|
638
|
-
matcher = new RegExp(pattern ?? ".");
|
|
639
|
-
} catch (error) {
|
|
640
|
-
Cli.fail(`${pattern} is not a regular expression: ${(error as Error).message}`);
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
const names = Object.keys(device.routing?.bgp?.neighbors ?? {});
|
|
644
|
-
if (!names.length) Cli.fail(`${device.name} has no BGP neighbors`);
|
|
645
|
-
|
|
646
|
-
const shown = names.filter((name) => matcher.test(name));
|
|
647
|
-
if (!shown.length) Cli.fail(`no BGP neighbor of ${device.name} matches ${matcher}; known: ${names.join(", ")}`);
|
|
648
|
-
|
|
649
|
-
const want = flags.wait === undefined ? 0 : flags.wait === true ? shown.length : flags.wait;
|
|
650
|
-
const session = await adapter.session(device, this.optionsFor(device));
|
|
651
|
-
|
|
652
|
-
try {
|
|
653
|
-
const read = async () => (await adapter.bgpSessions!(device, session)).filter((neighbor) => shown.includes(neighbor.neighbor));
|
|
654
|
-
|
|
655
|
-
if (flags.clearLimit) {
|
|
656
|
-
await Cli.clearLimits(adapter, session, read);
|
|
657
|
-
return;
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
const limits = { seconds: flags.max, every: BgpWait.COMMAND.every };
|
|
661
|
-
const sessions = await BgpWait.until(
|
|
662
|
-
read,
|
|
663
|
-
(sessions) => BgpWait.upCount(sessions) >= want,
|
|
664
|
-
limits,
|
|
665
|
-
(sessions) =>
|
|
666
|
-
console.log(
|
|
667
|
-
`${new Date().toLocaleTimeString()}: ${BgpWait.upCount(sessions)}/${want} up, checking again in ${limits.every}s`
|
|
668
|
-
)
|
|
669
|
-
);
|
|
670
|
-
|
|
671
|
-
Cli.printBgp(sessions);
|
|
672
|
-
if (BgpWait.upCount(sessions) < want) process.exitCode = 1;
|
|
673
|
-
|
|
674
|
-
if (flags.log) await this.printBgpLog(adapter, session, flags.log === true ? 5 : flags.log);
|
|
675
|
-
} finally {
|
|
676
|
-
session.close();
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
|
|
680
|
-
/** Open again the sessions closed on their limit, say which others were left alone, and show them all after. */
|
|
681
|
-
private static async clearLimits(adapter: Platform, session: ReadSession, read: () => Promise<BgpSession[]>): Promise<void> {
|
|
682
|
-
const sessions = await read();
|
|
683
|
-
const closed = sessions.filter((bgp) => bgp.limit);
|
|
684
|
-
|
|
685
|
-
if (!closed.length) {
|
|
686
|
-
Cli.printBgp(sessions);
|
|
687
|
-
console.error("\nno neighbor shown is closed on its prefix limit, so nothing was cleared");
|
|
688
|
-
process.exitCode = 1;
|
|
689
|
-
return;
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
for (const bgp of sessions) if (!bgp.limit) console.log(`${bgp.neighbor}: left alone, not closed on its limit`);
|
|
693
|
-
await adapter.bgpClearLimit!(session, closed);
|
|
694
|
-
console.log(
|
|
695
|
-
`cleared ${closed.map((bgp) => bgp.neighbor).join(", ")}: each connects again, and closes again if it still sends too much\n`
|
|
355
|
+
Ui.done(
|
|
356
|
+
`Circuit Studio at ${url}${local ? "" : `, listening on ${flags.host}:${flags.port}`}. It reads the project again when a file changes. Stop it with Ctrl+C.`
|
|
696
357
|
);
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
/** The device `circuit bgp` reads: the one named, or the only device with BGP neighbors. */
|
|
702
|
-
private bgpDevice(name: string | undefined): Device {
|
|
703
|
-
if (name !== undefined) return this.one(name);
|
|
704
|
-
|
|
705
|
-
const withBgp = this.network.devices.filter((device) => Object.keys(device.routing?.bgp?.neighbors ?? {}).length);
|
|
706
|
-
if (withBgp.length === 1) return withBgp[0]!;
|
|
707
|
-
if (!withBgp.length) Cli.fail("no device has BGP neighbors");
|
|
708
|
-
|
|
709
|
-
return Cli.fail(`several devices have BGP neighbors, so name one: ${withBgp.map((device) => device.name).join(", ")}`);
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
/** One line per neighbor: name, up or down, prefixes received, uptime, remote address. Then how many are up. */
|
|
713
|
-
private static printBgp(sessions: readonly BgpSession[]): void {
|
|
714
|
-
const width = Math.max(...sessions.map((session) => session.neighbor.length));
|
|
715
|
-
|
|
716
|
-
for (const session of sessions) {
|
|
717
|
-
const state = session.established ? "up " : "DOWN";
|
|
718
|
-
const why = session.reason ? ` ${Cli.downReason(session)}` : "";
|
|
719
|
-
console.log(
|
|
720
|
-
`${session.neighbor.padEnd(width)} ${state} ${session.prefixes.padStart(7)} ${session.uptime.padEnd(12)} ${session.remote}${why}`
|
|
358
|
+
if (!local)
|
|
359
|
+
Ui.warn(
|
|
360
|
+
"Studio on a server shows the project to everyone who reaches it, and does not read devices. Put it behind a proxy that asks who people are."
|
|
721
361
|
);
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
console.log(`${BgpWait.upCount(sessions)}/${sessions.length} up`);
|
|
725
|
-
|
|
726
|
-
for (const burst of BgpWait.limitBursts(sessions)) {
|
|
727
|
-
const names = burst.map((session) => session.neighbor);
|
|
728
|
-
const listed = names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}` : names[0];
|
|
729
|
-
const seconds = Math.round((burst.at(-1)!.limit!.stoppedAt! - burst[0]!.limit!.stoppedAt!) / 1000);
|
|
730
|
-
|
|
731
|
-
console.log(
|
|
732
|
-
`
|
|
733
|
-
${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.`
|
|
734
|
-
);
|
|
735
|
-
}
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
/** Why a session is down, with its limit and when it closed when that is why. */
|
|
739
|
-
private static downReason(session: BgpSession): string {
|
|
740
|
-
const { limit } = session;
|
|
741
|
-
if (!limit) return session.reason ?? "";
|
|
742
|
-
|
|
743
|
-
const over = limit.max !== undefined ? `: over ${limit.max} prefixes` : "";
|
|
744
|
-
const when = limit.stopped ? `, closed ${limit.stopped}` : "";
|
|
745
|
-
return `${session.reason}${over}${when}`;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
/** The device's BGP log lines from the last `minutes`, a message repeated folded into one line, secrets hidden. */
|
|
749
|
-
private async printBgpLog(adapter: Platform, session: ReadSession, minutes: number): Promise<void> {
|
|
750
|
-
const lines = Cli.foldLog(await adapter.bgpLog!(session, minutes));
|
|
751
|
-
const text = `\n--- BGP log, last ${minutes} min\n${lines.join("\n") || "(nothing)"}\n`;
|
|
752
|
-
|
|
753
|
-
process.stdout.write(Secrets.scrub(text, { network: this.network }));
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
/** A message repeated, such as one every 10 seconds, becomes one line with when it came first, when last, and how many times. */
|
|
757
|
-
private static foldLog(lines: readonly BgpLogLine[]): string[] {
|
|
758
|
-
const folded = new Map<string, { count: number; first: string; last: string }>();
|
|
759
|
-
|
|
760
|
-
for (const { at, message } of lines) {
|
|
761
|
-
const seen = folded.get(message);
|
|
762
|
-
if (seen) {
|
|
763
|
-
seen.count++;
|
|
764
|
-
seen.last = at;
|
|
765
|
-
} else {
|
|
766
|
-
folded.set(message, { count: 1, first: at, last: at });
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
return [...folded].map(([message, { count, first, last }]) => {
|
|
771
|
-
const lastTime = last.split(" ")[1] ?? last;
|
|
772
|
-
return count > 1 ? `${first}..${lastTime} x${count} ${message}` : `${first} ${message}`;
|
|
773
|
-
});
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
/**
|
|
777
|
-
* 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.
|
|
778
|
-
*/
|
|
779
|
-
private async console(name: string, lines: string[], raw: boolean): Promise<void> {
|
|
780
|
-
const device = this.one(name);
|
|
781
|
-
const dialect = Platforms.find(device.platform)?.console ?? Cli.fail(`${device.platform} cannot be reached through a console yet`);
|
|
782
|
-
const serial = device.connection.console ?? Cli.fail(`${device.name} has no connection.console`);
|
|
783
|
-
const login = raw ? undefined : SerialConsole.of(device);
|
|
784
|
-
|
|
785
|
-
let opened: ConsoleSession;
|
|
786
|
-
try {
|
|
787
|
-
opened = await SerialConsole.open(serial, 1);
|
|
788
|
-
} catch (error) {
|
|
789
|
-
const message = (error as Error).message;
|
|
790
|
-
if (message.startsWith("busy:")) {
|
|
791
|
-
Cli.fail(
|
|
792
|
-
`${device.name}: ${serial.port} is in use. Only one program can hold a console port: close the other one, then try again.`
|
|
793
|
-
);
|
|
794
|
-
}
|
|
795
|
-
Cli.fail(`${device.name}: ${message}`);
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
let loggedIn = false;
|
|
799
|
-
let leaving = false;
|
|
800
|
-
|
|
801
|
-
const leave = async (code: number) => {
|
|
802
|
-
if (leaving) return;
|
|
803
|
-
leaving = true;
|
|
804
|
-
|
|
805
|
-
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
806
|
-
if (loggedIn) opened.channel.write(`${dialect.logout}\n`);
|
|
807
|
-
|
|
808
|
-
await opened.close();
|
|
809
|
-
process.exit(code);
|
|
810
|
-
};
|
|
811
|
-
|
|
812
|
-
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.once(signal, () => void leave(130));
|
|
813
|
-
|
|
814
|
-
opened.channel.on("close", () => {
|
|
815
|
-
if (leaving) return;
|
|
816
|
-
|
|
817
|
-
console.error("\nthe console closed");
|
|
818
|
-
process.exit(1);
|
|
819
|
-
});
|
|
820
|
-
|
|
821
|
-
if (login) {
|
|
822
|
-
try {
|
|
823
|
-
await SerialConsole.login(opened.channel, dialect, login.user, login.password, serial.passwordOnly);
|
|
824
|
-
} catch (error) {
|
|
825
|
-
console.error(`${device.name}: ${(error as Error).message}`);
|
|
826
|
-
await leave(1);
|
|
827
|
-
}
|
|
828
|
-
loggedIn = true;
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
if (lines.length) {
|
|
832
|
-
await ConsoleScript.run(opened.channel, lines, (answer) =>
|
|
833
|
-
process.stdout.write(Secrets.scrub(answer, { network: this.network }))
|
|
834
|
-
);
|
|
835
|
-
await leave(0);
|
|
836
|
-
return;
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
this.join(opened, device, loggedIn ? login?.user : undefined, leave);
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
/** Join the console to this terminal until Ctrl-] or the end of input. Every other key, Ctrl-C too, goes to the device. */
|
|
843
|
-
private join(opened: ConsoleSession, device: Device, user: string | undefined, leave: (code: number) => Promise<void>): void {
|
|
844
|
-
const stdin = process.stdin;
|
|
845
|
-
const as = user ? `, logged in as ${user}` : "";
|
|
846
|
-
console.error(`${device.name}'s console${as}. Ctrl-] leaves.`);
|
|
847
|
-
|
|
848
|
-
opened.channel.on("data", (chunk: Buffer) => process.stdout.write(chunk));
|
|
849
|
-
|
|
850
|
-
if (stdin.isTTY) stdin.setRawMode(true);
|
|
851
|
-
|
|
852
|
-
stdin.on("data", (data: Buffer) => {
|
|
853
|
-
const at = data.indexOf(Cli.LEAVE_KEY);
|
|
854
|
-
const keys = at >= 0 ? data.subarray(0, at) : data;
|
|
855
|
-
|
|
856
|
-
// 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.
|
|
857
|
-
if (keys.length) opened.channel.write(stdin.isTTY ? keys : keys.toString("utf8"));
|
|
858
|
-
if (at >= 0) void leave(0);
|
|
859
|
-
});
|
|
860
|
-
|
|
861
|
-
stdin.on("end", () => void leave(0));
|
|
862
|
-
stdin.resume();
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
/** 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. */
|
|
866
|
-
private async checkPath(device: Device, force: boolean): Promise<void> {
|
|
867
|
-
const host = device.connection.host;
|
|
868
|
-
if (!host || this.optionsFor(device).console) return;
|
|
869
|
-
|
|
870
|
-
const measure = await PathCheck.measure(host);
|
|
871
|
-
if (!measure) {
|
|
872
|
-
console.log(`path: no ping program here, so the path to ${host} was not measured`);
|
|
873
|
-
return;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
const { refuse, summary } = PathCheck.verdict(host, measure);
|
|
877
|
-
console.log(`path: ${summary}`);
|
|
878
|
-
if (!refuse) return;
|
|
879
|
-
|
|
880
|
-
const limits = `at most ${PathCheck.MAX_LOSS_PERCENT}% lost and ${PathCheck.MAX_AVERAGE_MS} ms on average`;
|
|
881
|
-
if (force) {
|
|
882
|
-
console.log(`path: worse than ${limits}; going ahead, as --force asks`);
|
|
883
|
-
return;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
Cli.fail(
|
|
887
|
-
`\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.`
|
|
888
|
-
);
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
private async refresh(name: string, confirmed: boolean): Promise<void> {
|
|
892
|
-
const device = this.one(name);
|
|
893
|
-
const adapter = this.adapterOf(device);
|
|
894
|
-
const fill = adapter.fill?.bind(adapter) ?? Cli.fail(`${device.platform} cannot hold a fetched prefix set`);
|
|
895
|
-
|
|
896
|
-
for (const [name, set] of Object.entries(device.prefix_sets ?? {})) {
|
|
897
|
-
if (!("source" in set)) continue;
|
|
898
|
-
|
|
899
|
-
const { registry, query, family } = set.source;
|
|
900
|
-
const prefixes = await Registries.prefixes(registry, query, family);
|
|
901
|
-
|
|
902
|
-
// An empty or absurd answer is a broken feed, not a policy.
|
|
903
|
-
if (prefixes.length < 1 || prefixes.length > 100_000) {
|
|
904
|
-
Cli.fail(`${name}: ${prefixes.length} prefixes from ${registry}, refusing`);
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
console.log(`${name}: ${prefixes.length} prefixes from ${registry} ${query}`);
|
|
908
|
-
if (confirmed) await fill(device, this.network.devices, name, family, prefixes);
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
if (!confirmed) console.log("\nNothing sent. Re-run with --confirm.");
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
/** A WireGuard public key from its private key, so the device's never has to be read off the box. */
|
|
915
|
-
private static publicKeyOf(privateKey: string): string {
|
|
916
|
-
// The DER prefix of a raw X25519 private key in PKCS#8.
|
|
917
|
-
const pkcs8Prefix = Buffer.from("302e020100300506032b656e04220420", "hex");
|
|
918
|
-
const der = Buffer.concat([pkcs8Prefix, Buffer.from(privateKey, "base64")]);
|
|
919
|
-
|
|
920
|
-
const key = createPrivateKey({ key: der, format: "der", type: "pkcs8" });
|
|
921
|
-
const publicDer = createPublicKey(key).export({ format: "der", type: "spki" });
|
|
922
|
-
|
|
923
|
-
return publicDer.subarray(-32).toString("base64");
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
private wireguard(deviceName: string, interfaceName: string, peerName: string): void {
|
|
927
|
-
const device = this.one(deviceName);
|
|
928
|
-
|
|
929
|
-
const iface = device.interfaces?.[interfaceName];
|
|
930
|
-
if (iface?.type !== "wireguard") Cli.fail(`${deviceName} has no WireGuard interface ${interfaceName}`);
|
|
931
|
-
|
|
932
|
-
const peers = iface.peers ?? [];
|
|
933
|
-
const peerNames = peers.map((candidate) => candidate.name).join(", ");
|
|
934
|
-
const peer =
|
|
935
|
-
peers.find((candidate) => candidate.name === peerName) ?? Cli.fail(`no peer ${peerName} on ${interfaceName}: ${peerNames}`);
|
|
936
|
-
|
|
937
|
-
const tunnelNetworks = (iface.addresses ?? []).map((entry) => Address.prefixOf(typeof entry === "string" ? entry : entry.address));
|
|
938
|
-
|
|
939
|
-
const lines = [
|
|
940
|
-
"[Interface]",
|
|
941
|
-
`# ${peerName}'s own private key. It never leaves their machine.`,
|
|
942
|
-
"PrivateKey = <FILL IN>",
|
|
943
|
-
`Address = ${peer.allowed_addresses.join(", ")}`,
|
|
944
|
-
"",
|
|
945
|
-
"[Peer]",
|
|
946
|
-
`PublicKey = ${Cli.publicKeyOf(Secrets.reveal(iface.private_key))}`,
|
|
947
|
-
`Endpoint = ${iface.endpoint ?? "<FILL IN>"}:${iface.listen_port}`,
|
|
948
|
-
`AllowedIPs = ${(peer.client_allowed_addresses ?? tunnelNetworks).join(", ")}`,
|
|
949
|
-
"PersistentKeepalive = 25",
|
|
950
|
-
"",
|
|
951
|
-
];
|
|
952
|
-
process.stdout.write(lines.join("\n"));
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
private communities(): void {
|
|
956
|
-
const communities = this.network.communities ?? Cli.fail("the network declares no communities");
|
|
957
|
-
|
|
958
|
-
for (const { community, description } of communities) {
|
|
959
|
-
// The format splits each line at its first comma.
|
|
960
|
-
if (community.includes(",")) Cli.fail(`community ${community} holds a comma`);
|
|
961
|
-
console.log(`${community},${description}`);
|
|
962
|
-
}
|
|
963
|
-
}
|
|
964
|
-
|
|
965
|
-
private async peeringdb(confirmed: boolean): Promise<void> {
|
|
966
|
-
const plan = await PeeringDb.plan(this.network);
|
|
967
|
-
|
|
968
|
-
for (const name of plan.unknown) {
|
|
969
|
-
console.log(` PeeringDB lists ${name}, which the config does not. Remove it by hand if the network left.`);
|
|
970
|
-
}
|
|
971
|
-
|
|
972
|
-
for (const change of plan.changes) console.log(` ${change.summary}`);
|
|
973
|
-
|
|
974
|
-
if (!plan.changes.length) {
|
|
975
|
-
console.log("PeeringDB matches the config.");
|
|
976
|
-
return;
|
|
977
|
-
}
|
|
978
|
-
|
|
979
|
-
if (!confirmed) {
|
|
980
|
-
console.log("\nNothing sent. Re-run with --confirm.");
|
|
981
|
-
return;
|
|
982
|
-
}
|
|
983
|
-
|
|
984
|
-
await PeeringDb.send(this.network, plan.changes);
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
private async librenms(confirmed: boolean): Promise<void> {
|
|
988
|
-
const plan = await LibreNms.plan(this.network);
|
|
989
|
-
|
|
990
|
-
for (const hostname of plan.unknown)
|
|
991
|
-
console.log(` LibreNMS monitors ${hostname}, which the config does not. Remove it by hand if it is gone.`);
|
|
992
|
-
for (const device of plan.withoutSnmp)
|
|
993
|
-
console.log(` ${device.name} has no SNMP in its config, so LibreNMS cannot poll it. Add management.snmp first.`);
|
|
994
|
-
for (const name of plan.dynamicGroups)
|
|
995
|
-
console.log(` LibreNMS's group ${name} is dynamic, so Circuit cannot fill it. Make it static, or rename it.`);
|
|
996
|
-
|
|
997
|
-
for (const device of plan.additions) console.log(` + ${device.name}, ${device.connection.host}`);
|
|
998
|
-
for (const change of plan.changes) console.log(` ~ ${change.device.name}: ${change.what.join(", ")}`);
|
|
999
|
-
|
|
1000
|
-
for (const group of plan.groups) {
|
|
1001
|
-
const names = (devices: readonly Device[]) => devices.map((device) => device.name).join(", ");
|
|
1002
|
-
|
|
1003
|
-
if (group.create) console.log(` + group ${group.name}: ${names(group.add)}`);
|
|
1004
|
-
if (!group.create && group.add.length) console.log(` ~ group ${group.name}: add ${names(group.add)}`);
|
|
1005
|
-
if (!group.create && group.remove.length) console.log(` ~ group ${group.name}: remove ${names(group.remove)}`);
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
if (!LibreNms.count(plan)) {
|
|
1009
|
-
console.log("LibreNMS matches the config.");
|
|
1010
|
-
return;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
if (!confirmed) {
|
|
1014
|
-
console.log("\nNothing sent. Re-run with --confirm.");
|
|
1015
|
-
return;
|
|
1016
|
-
}
|
|
1017
|
-
|
|
1018
|
-
await LibreNms.send(this.network, plan);
|
|
362
|
+
if (flags.open && local) StudioServer.openBrowser(url);
|
|
1019
363
|
}
|
|
1020
364
|
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
const
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
console.log("NetBox matches the config.");
|
|
365
|
+
/** In a project: move it to a version the person picks, and the app with it. Elsewhere, or with `--app`: update the app. Either way, versions no project pins any more are removed. */
|
|
366
|
+
private static async update(app: boolean, confirmed: boolean): Promise<void> {
|
|
367
|
+
const root = ProjectCommands.find();
|
|
368
|
+
if (root && Releases.binary()) Versions.remember(root);
|
|
369
|
+
if (app || !root) {
|
|
370
|
+
const target = await Releases.updateApp(confirmed);
|
|
371
|
+
Versions.prune(target ?? VERSION);
|
|
1029
372
|
return;
|
|
1030
373
|
}
|
|
1031
374
|
|
|
1032
|
-
|
|
1033
|
-
|
|
375
|
+
const packaged = Setup.pinned(root);
|
|
376
|
+
if (!packaged) {
|
|
377
|
+
await Versions.updateProject(root, confirmed);
|
|
1034
378
|
return;
|
|
1035
379
|
}
|
|
1036
380
|
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
console.log("\nNetBox still differs:");
|
|
1041
|
-
Cli.showNetBox(left);
|
|
1042
|
-
process.exit(1);
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
/** Each change NetBox would get, then how many of each kind. */
|
|
1046
|
-
private static showNetBox(plan: NetBoxPlan): void {
|
|
1047
|
-
for (const item of plan.leftAlone) console.log(` ${item} exists without the tag ${NetBox.TAG}: left alone`);
|
|
1048
|
-
for (const change of plan.creates) console.log(` + ${change.path} ${change.key}`);
|
|
1049
|
-
|
|
1050
|
-
for (const change of plan.updates) {
|
|
1051
|
-
console.log(` ~ ${change.path} ${change.key}`);
|
|
1052
|
-
for (const line of change.diff) console.log(` ${line}`);
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
for (const change of plan.deletes) console.log(` - ${change.path} ${change.key}`);
|
|
381
|
+
// A full install pins Circuit in package.json.
|
|
382
|
+
const target = await Releases.pick(packaged.replace(/^[\^~]/, ""), confirmed, "This project");
|
|
383
|
+
if (!target) return;
|
|
1056
384
|
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
);
|
|
1066
|
-
}
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
private prometheus(auths: boolean): void {
|
|
1070
|
-
if (auths) {
|
|
1071
|
-
const { yaml, notes } = Prometheus.snmpExporterAuths(this.network);
|
|
1072
|
-
for (const note of notes) console.error(note);
|
|
1073
|
-
process.stdout.write(yaml);
|
|
1074
|
-
return;
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
const { targets, withoutSnmp } = Prometheus.targets(this.network);
|
|
1078
|
-
for (const device of withoutSnmp) console.error(`${device.name} has no SNMP in its config; left out`);
|
|
1079
|
-
console.log(JSON.stringify(targets, null, 4));
|
|
1080
|
-
}
|
|
1081
|
-
|
|
1082
|
-
private secrets(): void {
|
|
1083
|
-
const references = [
|
|
1084
|
-
...Secrets.referencesIn([this.network.devices, this.network.peeringdb, this.network.librenms, this.network.netbox]),
|
|
1085
|
-
].sort();
|
|
1086
|
-
let missing = 0;
|
|
1087
|
-
|
|
1088
|
-
for (const reference of references) {
|
|
1089
|
-
const name = reference.replace(/^file:/, "");
|
|
1090
|
-
|
|
1091
|
-
try {
|
|
1092
|
-
Secrets.reveal(reference);
|
|
1093
|
-
console.log(` ok ${name}, from ${Secrets.sourceOf(reference)}`);
|
|
1094
|
-
} catch (error) {
|
|
1095
|
-
missing++;
|
|
1096
|
-
console.log(` missing ${name}: ${(error as Error).message}`);
|
|
1097
|
-
}
|
|
1098
|
-
}
|
|
1099
|
-
|
|
1100
|
-
console.log(`\n${references.length} secret(s), ${missing} missing.`);
|
|
1101
|
-
process.exit(missing ? 1 : 0);
|
|
1102
|
-
}
|
|
1103
|
-
|
|
1104
|
-
/** How a device compares with its last snapshot, offline, as `diff` plans it. */
|
|
1105
|
-
private driftOf(device: Device): Drift {
|
|
1106
|
-
const path = this.statePath(device);
|
|
1107
|
-
if (!existsSync(path)) return { status: "no snapshot", lines: [] };
|
|
1108
|
-
|
|
1109
|
-
try {
|
|
1110
|
-
const adapter = this.adapterOf(device);
|
|
1111
|
-
const plan = adapter.plan(adapter.render(device), readFileSync(path, "utf8"), this.options);
|
|
1112
|
-
|
|
1113
|
-
const lines = [
|
|
1114
|
-
...plan.steps.flatMap((step) => [step.title, ...step.show.map((line) => ` ${line}`)]),
|
|
1115
|
-
...plan.problems.map((problem) => `PROBLEM ${problem}`),
|
|
1116
|
-
];
|
|
1117
|
-
|
|
1118
|
-
return { status: lines.length ? "differs" : "matches", lines };
|
|
1119
|
-
} catch (error) {
|
|
1120
|
-
return { status: "cannot plan", lines: [(error as Error).message] };
|
|
1121
|
-
}
|
|
385
|
+
await Ui.step(`Pinning Circuit ${target} in package.json`, async () => {
|
|
386
|
+
const result = spawnSync("bun", ["add", "--dev", "--exact", `${Setup.PACKAGE}@${target}`], {
|
|
387
|
+
cwd: root,
|
|
388
|
+
encoding: "utf8",
|
|
389
|
+
shell: process.platform === "win32",
|
|
390
|
+
});
|
|
391
|
+
if (result.status !== 0) throw new Error(`bun add failed: ${(result.stderr || result.stdout).trim()}`);
|
|
392
|
+
});
|
|
1122
393
|
}
|
|
1123
394
|
|
|
1124
|
-
/**
|
|
1125
|
-
private
|
|
1126
|
-
|
|
1127
|
-
const shown = new Set(graph.nodes.map((node) => node.label));
|
|
395
|
+
/** Before a commit: every secret encrypted, and a project that validates. */
|
|
396
|
+
private static async hook(name: string): Promise<void> {
|
|
397
|
+
if (name !== "pre-commit") Ui.fail(`No hook ${name}.`);
|
|
1128
398
|
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
),
|
|
399
|
+
const stop = (files: readonly string[]) => {
|
|
400
|
+
const problems = GitHooks.stagedProblems(ProjectCommands.find() ?? process.cwd(), files);
|
|
401
|
+
if (!problems.length) return;
|
|
402
|
+
for (const problem of problems) Ui.error(problem);
|
|
403
|
+
Ui.fail("\nThe commit was stopped, so no secret reaches git.");
|
|
1135
404
|
};
|
|
1136
|
-
}
|
|
1137
|
-
|
|
1138
|
-
private graph(names: string[], layer: string, format: string): void {
|
|
1139
|
-
const requested = layer.split(",");
|
|
1140
|
-
const layers = requested.includes("all") ? [...Topology.LAYERS] : (requested as Layer[]);
|
|
1141
405
|
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
const
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
const { segments, blocks } = report.elsewhere;
|
|
1194
|
-
if (segments.length || blocks.length) {
|
|
1195
|
-
console.log("Outside the space the network originates");
|
|
1196
|
-
for (const segment of segments) Cli.printSegment(segment, " ");
|
|
1197
|
-
for (const block of blocks) console.log(` ${block.prefix} ${block.what}`);
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
|
-
if (!report.spaces.length && !segments.length && !blocks.length) console.log("The config holds no addresses.");
|
|
1201
|
-
}
|
|
1202
|
-
|
|
1203
|
-
private rename(from: string, to: string, file: boolean, confirmed: boolean): void {
|
|
1204
|
-
const device = this.network.devices.find((candidate) => candidate.name === from);
|
|
1205
|
-
if (!device) Cli.fail(`no device ${from}; known: ${this.network.devices.map((candidate) => candidate.name).join(", ")}`);
|
|
1206
|
-
if (this.network.devices.some((candidate) => candidate.name === to)) Cli.fail(`${to} is already a device`);
|
|
1207
|
-
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(to)) Cli.fail(`${to} is not a hostname: letters, digits, dots, dashes and underscores`);
|
|
1208
|
-
|
|
1209
|
-
const files = [this.statePath(device), Cabling.path(this.stateDirectory, device.name)];
|
|
1210
|
-
const snapshots = files.filter((file) => existsSync(file)).map((file) => relative(this.root, file));
|
|
1211
|
-
|
|
1212
|
-
let plan: RenamePlan;
|
|
1213
|
-
try {
|
|
1214
|
-
plan = Rename.plan(this.root, from, to, { file, snapshots });
|
|
1215
|
-
} catch (error) {
|
|
1216
|
-
Cli.fail((error as Error).message);
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
for (const change of plan.changes) {
|
|
1220
|
-
console.log(`${change.file}:${change.line}`);
|
|
1221
|
-
console.log(` - ${change.before.trim()}`);
|
|
1222
|
-
console.log(` + ${change.after.trim()}`);
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
for (const move of plan.moves) console.log(`move ${move.from} to ${move.to}`);
|
|
1226
|
-
|
|
1227
|
-
if (plan.mentions.length) {
|
|
1228
|
-
console.log(`\nStill naming ${from}, left as they are: change them by hand if they mean the device.`);
|
|
1229
|
-
for (const mention of plan.mentions) console.log(` ${mention.file}:${mention.line} ${mention.text.slice(0, 160)}`);
|
|
1230
|
-
}
|
|
1231
|
-
|
|
1232
|
-
if (!confirmed) {
|
|
1233
|
-
console.log("\nNothing written. Re-run with --confirm.");
|
|
1234
|
-
return;
|
|
1235
|
-
}
|
|
1236
|
-
|
|
1237
|
-
Rename.apply(this.root, plan);
|
|
1238
|
-
console.log(`\nRenamed. Run circuit validate, then circuit apply ${to}: the device keeps its old hostname until then.`);
|
|
1239
|
-
}
|
|
1240
|
-
|
|
1241
|
-
private async studio(flags: { port: string; open: boolean; host: string; allowHost?: string; json?: true }): Promise<void> {
|
|
1242
|
-
if (flags.json) {
|
|
1243
|
-
const sources = ProjectFiles.sources(this.root);
|
|
1244
|
-
const data = StudioModel.build(this.network.devices, this.annotatedGraph(Topology.LAYERS, []), {
|
|
1245
|
-
render: (device) => this.adapterOf(device).render(device),
|
|
1246
|
-
snapshot: (device) => (existsSync(this.statePath(device)) ? readFileSync(this.statePath(device), "utf8") : undefined),
|
|
1247
|
-
files: (device) => ({
|
|
1248
|
-
// Written with forward slashes on every system, as git and the page take them.
|
|
1249
|
-
source: ProjectFiles.deviceFiles(this.root, device.name, sources)[0]?.split(sep).join("/"),
|
|
1250
|
-
snapshot: existsSync(this.statePath(device))
|
|
1251
|
-
? relative(this.root, this.statePath(device)).split(sep).join("/")
|
|
1252
|
-
: undefined,
|
|
1253
|
-
}),
|
|
1254
|
-
});
|
|
1255
|
-
console.log(JSON.stringify(data));
|
|
1256
|
-
return;
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
const port = Number(flags.port);
|
|
1260
|
-
if (!Number.isInteger(port) || port < 1 || port > 65535) Cli.fail("--port takes a number from 1 to 65535");
|
|
1261
|
-
|
|
1262
|
-
const { StudioServer } = await import("../studio/server");
|
|
1263
|
-
const host = flags.host;
|
|
1264
|
-
const allowHosts = (flags.allowHost ?? "").split(",").filter(Boolean);
|
|
1265
|
-
|
|
1266
|
-
// Off this machine, Studio must be told the names it is reached by: every other name is refused.
|
|
1267
|
-
if (!StudioServer.isLoopback(host) && !allowHosts.length) {
|
|
1268
|
-
Cli.fail(`Studio on ${host} needs --allow-host=<name>, the name people reach it by, such as studio.example.com`);
|
|
1269
|
-
}
|
|
1270
|
-
|
|
1271
|
-
await StudioServer.start({
|
|
1272
|
-
configPath: this.configPath,
|
|
1273
|
-
root: this.root,
|
|
1274
|
-
port,
|
|
1275
|
-
open: flags.open,
|
|
1276
|
-
host,
|
|
1277
|
-
allowHosts,
|
|
1278
|
-
});
|
|
406
|
+
// `.circuit/secrets` before the project is read, so a project with errors still has it checked; then the files the networks name.
|
|
407
|
+
stop([SecretsFile.DEFAULT]);
|
|
408
|
+
if (!ProjectCommands.find()) return;
|
|
409
|
+
|
|
410
|
+
const project = await ProjectCommands.open({ experimental: true });
|
|
411
|
+
stop(project.secretsFiles());
|
|
412
|
+
// A field not proved yet is a warning here: it stops `apply`, not a commit.
|
|
413
|
+
project.validate();
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** The menu `circuit` opens alone: pick what to do, answer what it needs, and get the command it runs. */
|
|
417
|
+
private static async menu(): Promise<string[]> {
|
|
418
|
+
Ui.logo(`Your network, written down. Circuit ${VERSION}`);
|
|
419
|
+
Ui.intro(`Circuit ${VERSION}`);
|
|
420
|
+
|
|
421
|
+
if (!ProjectCommands.find()) {
|
|
422
|
+
const choice = await Ui.select("No Circuit project in this folder. What do you want to do?", [
|
|
423
|
+
{ value: "new", label: "Start a new project" },
|
|
424
|
+
{ value: "update", label: "Update Circuit" },
|
|
425
|
+
{ value: "uninstall", label: "Uninstall Circuit", hint: "your projects stay" },
|
|
426
|
+
{ value: "exit", label: "Exit" },
|
|
427
|
+
]);
|
|
428
|
+
if (choice === "exit") process.exit(0);
|
|
429
|
+
return [choice];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const heading = (label: string): Choice<string> => ({ value: `#${label}`, label: Ui.dim(`── ${label} ──`), disabled: true });
|
|
433
|
+
const choice = await Ui.select("What do you want to do?", [
|
|
434
|
+
heading("Core"),
|
|
435
|
+
{ value: "validate", label: "Validate", hint: "check every file of the project" },
|
|
436
|
+
{ value: "diff", label: "Diff", hint: "what apply would change on a device" },
|
|
437
|
+
{ value: "apply", label: "Apply", hint: "make a device match its config" },
|
|
438
|
+
heading("Utilities"),
|
|
439
|
+
{ value: "exec", label: "Execute", hint: "run a command that only reads on a device" },
|
|
440
|
+
{ value: "bgp", label: "BGP", hint: "each BGP session of a device" },
|
|
441
|
+
{ value: "access-console", label: "Access console", hint: "a device's serial console" },
|
|
442
|
+
{ value: "studio", label: "Studio", hint: "the project in your browser" },
|
|
443
|
+
{ value: "rename", label: "Rename", hint: "give a device a new name everywhere" },
|
|
444
|
+
{ value: "fmt", label: "Tidy files", hint: "spacing and line breaks only; what the files say stays the same" },
|
|
445
|
+
heading("Secrets"),
|
|
446
|
+
{ value: "secrets", label: "Secrets", hint: "every secret in a text editor, saved encrypted" },
|
|
447
|
+
heading("Circuit"),
|
|
448
|
+
{ value: "update", label: "Update Circuit" },
|
|
449
|
+
{ value: "uninstall", label: "Uninstall Circuit", hint: "your projects stay" },
|
|
450
|
+
{ value: "exit", label: "Exit" },
|
|
451
|
+
]);
|
|
452
|
+
|
|
453
|
+
if (choice === "exit") process.exit(0);
|
|
454
|
+
return [choice];
|
|
1279
455
|
}
|
|
1280
456
|
}
|
|
1281
457
|
|
|
1282
|
-
|
|
458
|
+
try {
|
|
459
|
+
await Cli.main(process.argv.slice(2));
|
|
460
|
+
} catch (error) {
|
|
461
|
+
Ui.fail((error as Error).message);
|
|
462
|
+
}
|