1 | |
2 |
|
3 | |
4 | * Apply config |
5 | * Declaratively apply configuration lines: compare the intended lines (indented like `show running-config`, two spaces per level) with the running configuration, return the lines missing from it with their parent blocks, and push only those. With dry_run (the default) nothing is changed. Set checkpoint_name to create a named checkpoint before applying, so the change can be undone with Rollback to checkpoint; NX-OS refuses an existing name, so use one per run (e.g. with the job ID). Lines must match the switch's own output: negations and default lines (no shutdown) never appear there, so they are always reported as changes. |
6 | */ |
7 | export async function main( |
8 | auth: RT.CiscoNxos, |
9 | config: string, |
10 | dry_run: boolean = true, |
11 | checkpoint_name: string | undefined |
12 | ) { |
13 | const intended = parseConfig(config ?? "") |
14 | if (intended.length === 0) { |
15 | throw new Error("config must contain at least one configuration line") |
16 | } |
17 | |
18 | if (/;/.test(config)) { |
19 | throw new Error("config lines must not contain ';'") |
20 | } |
21 | const checkpoint = checkpoint_name?.trim() || undefined |
22 | if (checkpoint && /[\s;|]/.test(checkpoint)) { |
23 | throw new Error("checkpoint_name must not contain spaces, ';' or '|'") |
24 | } |
25 |
|
26 | const [running] = await nxapi(auth, "cli_show_ascii", ["show running-config"]) |
27 | const missing: DiffLine[] = [] |
28 | diffNodes(intended, parseConfig(String(running?.body ?? "")), 0, missing) |
29 |
|
30 | const diff = missing.map((l) => `${" ".repeat(l.depth)}${l.text}`) |
31 | |
32 | |
33 | |
34 | const commands = missing.map((l) => l.text) |
35 |
|
36 | if (missing.length === 0 || dry_run !== false) { |
37 | return { |
38 | changed: missing.length > 0, |
39 | dry_run: dry_run !== false, |
40 | diff, |
41 | commands, |
42 | applied: false, |
43 | checkpoint: null, |
44 | } |
45 | } |
46 |
|
47 | if (checkpoint) { |
48 | await nxapi(auth, "cli_show_ascii", [`checkpoint ${checkpoint}`]) |
49 | } |
50 | await nxapi( |
51 | auth, |
52 | "cli_conf", |
53 | commands, |
54 | checkpoint |
55 | ? `Configuration may be partially applied; roll back with checkpoint "${checkpoint}".` |
56 | : "Configuration may be partially applied." |
57 | ) |
58 |
|
59 | return { |
60 | changed: true, |
61 | dry_run: false, |
62 | diff, |
63 | commands, |
64 | applied: true, |
65 | checkpoint: checkpoint ?? null, |
66 | } |
67 | } |
68 |
|
69 | type ConfigNode = { text: string; children: ConfigNode[] } |
70 | type DiffLine = { text: string; depth: number } |
71 |
|
72 | |
73 | function parseConfig(text: string): ConfigNode[] { |
74 | const root: ConfigNode[] = [] |
75 | const stack: { indent: number; children: ConfigNode[] }[] = [ |
76 | { indent: -1, children: root }, |
77 | ] |
78 | for (const raw of text.split(/\r?\n/)) { |
79 | const line = raw.replace(/\t/g, " ").replace(/\s+$/, "") |
80 | const trimmed = line.trim() |
81 | if (trimmed === "" || trimmed.startsWith("!")) continue |
82 | const indent = line.length - line.trimStart().length |
83 | while (stack[stack.length - 1].indent >= indent) stack.pop() |
84 | const node: ConfigNode = { |
85 | text: trimmed.replace(/\s+/g, " "), |
86 | children: [], |
87 | } |
88 | stack[stack.length - 1].children.push(node) |
89 | stack.push({ indent, children: node.children }) |
90 | } |
91 | return root |
92 | } |
93 |
|
94 | |
95 | function diffNodes( |
96 | intended: ConfigNode[], |
97 | running: ConfigNode[], |
98 | depth: number, |
99 | out: DiffLine[] |
100 | ) { |
101 | for (const node of intended) { |
102 | const match = running.find((r) => r.text === node.text) |
103 | if (!match) { |
104 | emitAll(node, depth, out) |
105 | continue |
106 | } |
107 | const sub: DiffLine[] = [] |
108 | diffNodes(node.children, match.children, depth + 1, sub) |
109 | if (sub.length > 0) { |
110 | out.push({ text: node.text, depth }) |
111 | out.push(...sub) |
112 | } |
113 | } |
114 | } |
115 |
|
116 | function emitAll(node: ConfigNode, depth: number, out: DiffLine[]) { |
117 | out.push({ text: node.text, depth }) |
118 | for (const child of node.children) emitAll(child, depth + 1, out) |
119 | } |
120 |
|
121 | type NxapiOutput = { |
122 | input?: string |
123 | body?: any |
124 | code: string |
125 | msg?: string |
126 | clierror?: string |
127 | } |
128 |
|
129 | async function nxapi( |
130 | auth: RT.CiscoNxos, |
131 | type: "cli_show" | "cli_show_ascii" | "cli_conf", |
132 | commands: string[], |
133 | hint?: string |
134 | ): Promise<NxapiOutput[]> { |
135 | const response = await fetch(new URL("/ins", auth.base_url), { |
136 | method: "POST", |
137 | headers: { |
138 | Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}`, |
139 | "Content-Type": "application/json", |
140 | Accept: "application/json", |
141 | }, |
142 | |
143 | body: JSON.stringify({ |
144 | ins_api: { |
145 | version: "1.0", |
146 | type, |
147 | chunk: "0", |
148 | sid: null, |
149 | input: commands.join(" ;"), |
150 | output_format: "json", |
151 | }, |
152 | }), |
153 | }) |
154 |
|
155 | const suffix = hint ? ` (${hint})` : "" |
156 | if (!response.ok) { |
157 | throw new Error(`${response.status} ${await response.text()}${suffix}`) |
158 | } |
159 | const text = await response.text() |
160 | if (text.trimStart().startsWith("<")) { |
161 | |
162 | throw new Error( |
163 | `NX-API returned XML instead of JSON (outputs over 1 MB come back as XML): ${text.slice(0, 500)}` |
164 | ) |
165 | } |
166 |
|
167 | const data = JSON.parse(text) |
168 | const raw = data?.ins_api?.outputs?.output |
169 | const outputs: NxapiOutput[] = |
170 | raw == null ? [] : Array.isArray(raw) ? raw : [raw] |
171 | for (const o of outputs) { |
172 | if (String(o.code) !== "200") { |
173 | throw new Error( |
174 | `${o.code} ${o.input ?? ""}: ${o.msg ?? ""} ${o.clierror ?? ""}`.trim() + |
175 | suffix |
176 | ) |
177 | } |
178 | } |
179 | return outputs |
180 | } |
181 |
|