0

Apply config

by
Published today

Declaratively apply configuration lines to a Cisco Nexus switch: diff against the running configuration, return the missing lines and push only those (dry run by default, optional named checkpoint).

Script cisco_nxos Verified

The script

Submitted by hugo989 Typescript (fetch-only)
Verified 3 hours ago
1
//native
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
  // NX-API splits input on ';', so a line containing one would be cut in two.
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
  // Sent in order: each changed block after its parent lines, with no 'exit'.
32
  // NX-OS returns to the parent mode when a line is not valid in the current
33
  // sub-mode, the command shape Ansible cisco.nxos and NAPALM send.
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
// Builds a tree from indentation, ignoring blank lines and "!" comments
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
// Collects intended lines absent from the running config, each preceded by its parents
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
    // Keep "type" before "input": some NX-OS releases misparse the payload otherwise
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
    // NX-API falls back to XML when the output exceeds 1 MB
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