@takodotid/circuit 0.18.5 → 0.19.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takodotid/circuit",
3
- "version": "0.18.5",
3
+ "version": "0.19.0",
4
4
  "description": "Declarative, vendor-neutral network configuration: describe each device in TypeScript, and Circuit makes the device match. Alpha.",
5
5
  "keywords": [
6
6
  "network",
@@ -0,0 +1,103 @@
1
+ // What `circuit graph` prints: the graph of the network with what `validate` found and how each device compares with its last snapshot, as Mermaid, DOT or JSON.
2
+
3
+ import type { Finding } from "../core/define";
4
+ import { deviceId, type Graph, type GraphNode } from "../core/graph";
5
+
6
+ /** How a device compares with its last snapshot, found offline as `diff` finds it. */
7
+ export type Drift = {
8
+ status: "matches" | "differs" | "no snapshot" | "cannot plan";
9
+ /** The plan `diff` would show, when it differs. */
10
+ lines: string[];
11
+ };
12
+
13
+ export type Annotated = Graph & {
14
+ findings: Finding[];
15
+ /** By device name. */
16
+ drift: Record<string, Drift>;
17
+ };
18
+
19
+ /** `error` when validate found an error on the device, `warning` for a warning or a difference from its snapshot. */
20
+ export function markOf(graph: Annotated, node: GraphNode): "error" | "warning" | undefined {
21
+ if (node.kind !== "device") return undefined;
22
+
23
+ const findings = graph.findings.filter((finding) => finding.device && deviceId(finding.device) === node.id);
24
+ if (findings.some((finding) => finding.level === "error")) return "error";
25
+
26
+ const drift = graph.drift[node.label]?.status;
27
+ if (findings.length || drift === "differs" || drift === "cannot plan") return "warning";
28
+
29
+ return undefined;
30
+ }
31
+
32
+ /** Mermaid, which GitHub draws wherever Markdown is shown. */
33
+ export function mermaid(graph: Annotated): string {
34
+ const ids = new Map(graph.nodes.map((node, index) => [node.id, `n${index}`]));
35
+ // Lines of text, each escaped, as one quoted label.
36
+ const text = (...lines: string[]) =>
37
+ `"${lines.map((line) => line.replace(/"/g, "#quot;").replace(/</g, "#lt;").replace(/>/g, "#gt;")).join("<br/>")}"`;
38
+
39
+ const shapes: Record<GraphNode["kind"], (label: string) => string> = {
40
+ device: (label) => `[${label}]`,
41
+ segment: (label) => `([${label}])`,
42
+ outside: (label) => `{{${label}}}`,
43
+ };
44
+
45
+ const lines = ["graph LR"];
46
+
47
+ for (const node of graph.nodes) {
48
+ const label = node.kind === "device" ? text(node.label, node.detail[0] ?? "") : text(node.label);
49
+ lines.push(` ${ids.get(node.id)}${shapes[node.kind](label)}`);
50
+ }
51
+
52
+ for (const edge of graph.edges) {
53
+ const arrow = edge.oneSided ? "-.-" : "---";
54
+ lines.push(` ${ids.get(edge.from)} ${arrow}|${text(edge.label)}| ${ids.get(edge.to)}`);
55
+ }
56
+
57
+ lines.push(
58
+ " classDef error stroke:#d1242f,stroke-width:3px",
59
+ " classDef warning stroke:#bf8700,stroke-width:3px",
60
+ " classDef outside stroke-dasharray:4 3"
61
+ );
62
+
63
+ for (const node of graph.nodes) {
64
+ const mark = markOf(graph, node) ?? (node.kind === "outside" ? "outside" : undefined);
65
+ if (mark) lines.push(` class ${ids.get(node.id)} ${mark}`);
66
+ }
67
+
68
+ return lines.join("\n") + "\n";
69
+ }
70
+
71
+ /** DOT, which Graphviz turns into an image: `circuit graph --format=dot | dot -Tsvg > network.svg`. */
72
+ export function dot(graph: Annotated): string {
73
+ const text = (value: string) => `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
74
+ const colors = { error: "#d1242f", warning: "#bf8700" };
75
+
76
+ const shapes: Record<GraphNode["kind"], string> = {
77
+ device: "shape=box, style=rounded",
78
+ segment: "shape=ellipse",
79
+ outside: "shape=box, style=dashed",
80
+ };
81
+
82
+ const lines = [
83
+ "graph network {",
84
+ " rankdir=LR;",
85
+ ' node [fontname="Helvetica"];',
86
+ ' edge [fontname="Helvetica", fontsize=10];',
87
+ ];
88
+
89
+ for (const node of graph.nodes) {
90
+ const label = node.kind === "device" ? `${node.label}\n${node.detail[0] ?? ""}` : node.label;
91
+ const mark = markOf(graph, node);
92
+ const color = mark ? `, color=${text(colors[mark])}, penwidth=2` : "";
93
+ lines.push(` ${text(node.id)} [label=${text(label)}, ${shapes[node.kind]}${color}];`);
94
+ }
95
+
96
+ for (const edge of graph.edges) {
97
+ const style = edge.oneSided ? ", style=dashed" : "";
98
+ lines.push(` ${text(edge.from)} -- ${text(edge.to)} [label=${text(edge.label)}${style}];`);
99
+ }
100
+
101
+ lines.push("}");
102
+ return lines.join("\n") + "\n";
103
+ }
package/src/cli/index.ts CHANGED
@@ -12,9 +12,11 @@ import { planPeeringDb, sendPeeringDb } from "../adapters/registries/peeringdb";
12
12
  import { prometheusTargets, snmpExporterAuths } from "../adapters/registries/prometheus";
13
13
  import { network as prefixOf } from "../core/addr";
14
14
  import type { Network } from "../core/define";
15
+ import { graphOf, LAYERS, only, type Layer } from "../core/graph";
15
16
  import { isSecret, referenceOf, resolve as reveal, sourceOf, useRoot } from "../core/secrets";
16
17
  import { validate } from "../core/validate";
17
18
  import type { Device } from "../schema";
19
+ import { type Annotated, dot, type Drift, mermaid } from "./graph";
18
20
 
19
21
  const HELP = `usage: circuit <command> [device...] [options]
20
22
 
@@ -35,6 +37,12 @@ const HELP = `usage: circuit <command> [device...] [options]
35
37
  prometheus every device with SNMP, as Prometheus targets for snmp_exporter
36
38
  --auths snmp_exporter's auths for them instead, with secrets as \${NAME}
37
39
  secrets every secret the config uses, and whether each can be read
40
+ graph [device...] the network as a graph, from the config alone, without contacting a device
41
+ --layer=l1 l1, l2, l3, bgp, ospf or tunnels, several with commas, or all
42
+ --format=mermaid mermaid, dot or json
43
+ studio the graph in the browser, drawn again whenever the config changes
44
+ --port=4600 the port it listens on, on this machine only
45
+ --no-open do not open the browser
38
46
 
39
47
  --config <path> the network's config file, circuit.config.ts in the working directory by default`;
40
48
 
@@ -395,6 +403,65 @@ ${references.length} secret(s), ${missing} missing.`);
395
403
  process.exit(missing ? 1 : 0);
396
404
  }
397
405
 
406
+ /** The value of `--name=value`, or undefined when it is not given. */
407
+ const flagValue = (name: string) => flags.find((flag) => flag.startsWith(`--${name}=`))?.slice(name.length + 3);
408
+
409
+ /** How a device compares with its last snapshot, offline, as `diff` plans it. */
410
+ function driftOf(device: Device): Drift {
411
+ const path = statePath(device);
412
+ if (!existsSync(path)) return { status: "no snapshot", lines: [] };
413
+
414
+ try {
415
+ const adapter = adapterOf(device);
416
+ const plan = adapter.plan(adapter.render(device), readFileSync(path, "utf8"), options);
417
+
418
+ const lines = [
419
+ ...plan.steps.flatMap((step) => [step.title, ...step.show.map((line) => ` ${line}`)]),
420
+ ...plan.problems.map((problem) => `PROBLEM ${problem}`),
421
+ ];
422
+
423
+ return { status: lines.length ? "differs" : "matches", lines };
424
+ } catch (error) {
425
+ return { status: "cannot plan", lines: [(error as Error).message] };
426
+ }
427
+ }
428
+
429
+ function graphCommand(): void {
430
+ const requested = (flagValue("layer") ?? "l1").split(",");
431
+ const layers = requested.includes("all") ? [...LAYERS] : (requested as Layer[]);
432
+
433
+ const unknown = layers.filter((layer) => !LAYERS.includes(layer));
434
+ if (unknown.length) fail(`no layer ${unknown.join(", ")}; known: ${LAYERS.join(", ")}, all`);
435
+
436
+ const format = flagValue("format") ?? "mermaid";
437
+ const devices = selected(true);
438
+
439
+ const graph = only(graphOf(network.devices), layers, names.length ? devices.map((device) => device.name) : []);
440
+ const shown = new Set(graph.nodes.map((node) => node.label));
441
+
442
+ // Each device drawn is compared with its snapshot; findings are kept for those, and for the network as a whole.
443
+ const annotated: Annotated = {
444
+ ...graph,
445
+ findings: validate(network).filter((finding) => !finding.device || shown.has(finding.device)),
446
+ drift: Object.fromEntries(
447
+ network.devices.filter((device) => shown.has(device.name)).map((device) => [device.name, driftOf(device)])
448
+ ),
449
+ };
450
+
451
+ if (format === "mermaid") process.stdout.write(mermaid(annotated));
452
+ else if (format === "dot") process.stdout.write(dot(annotated));
453
+ else if (format === "json") console.log(JSON.stringify(annotated, null, 4));
454
+ else fail(`no format ${format}; known: mermaid, dot, json`);
455
+ }
456
+
457
+ async function studioCommand(): Promise<void> {
458
+ const port = Number(flagValue("port") ?? 4600);
459
+ if (!Number.isInteger(port) || port < 1 || port > 65535) fail("--port takes a number from 1 to 65535");
460
+
461
+ const { studio } = await import("./studio");
462
+ await studio({ configPath, root, port, open: !flags.includes("--no-open") });
463
+ }
464
+
398
465
  const commands: Record<string, () => void | Promise<void>> = {
399
466
  validate: validateCommand,
400
467
  build: buildCommand,
@@ -408,6 +475,8 @@ const commands: Record<string, () => void | Promise<void>> = {
408
475
  librenms: librenmsCommand,
409
476
  prometheus: prometheusCommand,
410
477
  secrets: secretsCommand,
478
+ graph: graphCommand,
479
+ studio: studioCommand,
411
480
  };
412
481
 
413
482
  const run = commands[command];