//native
/**
* Apply config
* 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.
*/
export async function main(
auth: RT.CiscoNxos,
config: string,
dry_run: boolean = true,
checkpoint_name: string | undefined
) {
const intended = parseConfig(config ?? "")
if (intended.length === 0) {
throw new Error("config must contain at least one configuration line")
}
// NX-API splits input on ';', so a line containing one would be cut in two.
if (/;/.test(config)) {
throw new Error("config lines must not contain ';'")
}
const checkpoint = checkpoint_name?.trim() || undefined
if (checkpoint && /[\s;|]/.test(checkpoint)) {
throw new Error("checkpoint_name must not contain spaces, ';' or '|'")
}
const [running] = await nxapi(auth, "cli_show_ascii", ["show running-config"])
const missing: DiffLine[] = []
diffNodes(intended, parseConfig(String(running?.body ?? "")), 0, missing)
const diff = missing.map((l) => `${" ".repeat(l.depth)}${l.text}`)
// Sent in order: each changed block after its parent lines, with no 'exit'.
// NX-OS returns to the parent mode when a line is not valid in the current
// sub-mode, the command shape Ansible cisco.nxos and NAPALM send.
const commands = missing.map((l) => l.text)
if (missing.length === 0 || dry_run !== false) {
return {
changed: missing.length > 0,
dry_run: dry_run !== false,
diff,
commands,
applied: false,
checkpoint: null,
}
}
if (checkpoint) {
await nxapi(auth, "cli_show_ascii", [`checkpoint ${checkpoint}`])
}
await nxapi(
auth,
"cli_conf",
commands,
checkpoint
? `Configuration may be partially applied; roll back with checkpoint "${checkpoint}".`
: "Configuration may be partially applied."
)
return {
changed: true,
dry_run: false,
diff,
commands,
applied: true,
checkpoint: checkpoint ?? null,
}
}
type ConfigNode = { text: string; children: ConfigNode[] }
type DiffLine = { text: string; depth: number }
// Builds a tree from indentation, ignoring blank lines and "!" comments
function parseConfig(text: string): ConfigNode[] {
const root: ConfigNode[] = []
const stack: { indent: number; children: ConfigNode[] }[] = [
{ indent: -1, children: root },
]
for (const raw of text.split(/\r?\n/)) {
const line = raw.replace(/\t/g, " ").replace(/\s+$/, "")
const trimmed = line.trim()
if (trimmed === "" || trimmed.startsWith("!")) continue
const indent = line.length - line.trimStart().length
while (stack[stack.length - 1].indent >= indent) stack.pop()
const node: ConfigNode = {
text: trimmed.replace(/\s+/g, " "),
children: [],
}
stack[stack.length - 1].children.push(node)
stack.push({ indent, children: node.children })
}
return root
}
// Collects intended lines absent from the running config, each preceded by its parents
function diffNodes(
intended: ConfigNode[],
running: ConfigNode[],
depth: number,
out: DiffLine[]
) {
for (const node of intended) {
const match = running.find((r) => r.text === node.text)
if (!match) {
emitAll(node, depth, out)
continue
}
const sub: DiffLine[] = []
diffNodes(node.children, match.children, depth + 1, sub)
if (sub.length > 0) {
out.push({ text: node.text, depth })
out.push(...sub)
}
}
}
function emitAll(node: ConfigNode, depth: number, out: DiffLine[]) {
out.push({ text: node.text, depth })
for (const child of node.children) emitAll(child, depth + 1, out)
}
type NxapiOutput = {
input?: string
body?: any
code: string
msg?: string
clierror?: string
}
async function nxapi(
auth: RT.CiscoNxos,
type: "cli_show" | "cli_show_ascii" | "cli_conf",
commands: string[],
hint?: string
): Promise<NxapiOutput[]> {
const response = await fetch(new URL("/ins", auth.base_url), {
method: "POST",
headers: {
Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}`,
"Content-Type": "application/json",
Accept: "application/json",
},
// Keep "type" before "input": some NX-OS releases misparse the payload otherwise
body: JSON.stringify({
ins_api: {
version: "1.0",
type,
chunk: "0",
sid: null,
input: commands.join(" ;"),
output_format: "json",
},
}),
})
const suffix = hint ? ` (${hint})` : ""
if (!response.ok) {
throw new Error(`${response.status} ${await response.text()}${suffix}`)
}
const text = await response.text()
if (text.trimStart().startsWith("<")) {
// NX-API falls back to XML when the output exceeds 1 MB
throw new Error(
`NX-API returned XML instead of JSON (outputs over 1 MB come back as XML): ${text.slice(0, 500)}`
)
}
const data = JSON.parse(text)
const raw = data?.ins_api?.outputs?.output
const outputs: NxapiOutput[] =
raw == null ? [] : Array.isArray(raw) ? raw : [raw]
for (const o of outputs) {
if (String(o.code) !== "200") {
throw new Error(
`${o.code} ${o.input ?? ""}: ${o.msg ?? ""} ${o.clierror ?? ""}`.trim() +
suffix
)
}
}
return outputs
}
Submitted by hugo989 3 hours ago