1 | |
2 |
|
3 | type Change = { path: string; current: any; intended: any } |
4 |
|
5 | |
6 | |
7 | |
8 | function lookup(obj: { [key: string]: any }, key: string) { |
9 | if (key in obj) return obj[key] |
10 | const local = key.split(":").pop() |
11 | const match = Object.keys(obj).find((k) => k.split(":").pop() === local) |
12 | return match === undefined ? undefined : obj[match] |
13 | } |
14 |
|
15 | |
16 | |
17 | function scalarEqual(a: any, b: any) { |
18 | return a === b || (a != null && b != null && String(a) === String(b)) |
19 | } |
20 |
|
21 | |
22 | |
23 | |
24 | |
25 | function diff(intended: any, current: any, at: string, out: Change[]) { |
26 | if (Array.isArray(intended)) { |
27 | const entries = Array.isArray(current) ? current : [] |
28 | intended.forEach((item, i) => { |
29 | if (!entries.some((entry) => diff(item, entry, "", []).length === 0)) { |
30 | out.push({ path: `${at}[${i}]`, current: null, intended: item }) |
31 | } |
32 | }) |
33 | } else if (intended !== null && typeof intended === "object") { |
34 | |
35 | if (Array.isArray(current) && current.length === 1) { |
36 | current = current[0] |
37 | } |
38 | if ( |
39 | current === null || |
40 | typeof current !== "object" || |
41 | Array.isArray(current) |
42 | ) { |
43 | out.push({ path: at || "/", current: current ?? null, intended }) |
44 | } else { |
45 | for (const [key, value] of Object.entries(intended)) { |
46 | diff(value, lookup(current, key), `${at}/${key}`, out) |
47 | } |
48 | } |
49 | } else if (!scalarEqual(intended, current)) { |
50 | out.push({ path: at || "/", current: current ?? null, intended }) |
51 | } |
52 | return out |
53 | } |
54 |
|
55 | |
56 | * Apply config |
57 | * Declaratively apply configuration at a RESTCONF data path: read the current config there, diff it against the intended JSON, and merge (PATCH) only when they differ. intended is the RFC 7951 body wrapped in the target node, as a GET of path returns it, e.g. {"Cisco-IOS-XE-native:Loopback": {"name": 100, "description": "x"}} for path Cisco-IOS-XE-native:native/interface/Loopback=100. With dry_run (the default) nothing is changed and the diff is returned. |
58 | */ |
59 | export async function main( |
60 | auth: RT.CiscoIosxe, |
61 | path: string, |
62 | intended: { [key: string]: any }, |
63 | dry_run: boolean = true |
64 | ) { |
65 | const url = new URL( |
66 | `${auth.base_url.replace(/\/+$/, "")}/restconf/data/${path.replace(/^\/+/, "")}` |
67 | ) |
68 | const authorization = `Basic ${btoa(`${auth.username}:${auth.password}`)}` |
69 |
|
70 | const currentResponse = await fetch(url, { |
71 | headers: { |
72 | Authorization: authorization, |
73 | Accept: "application/yang-data+json", |
74 | }, |
75 | }) |
76 | let current: any = null |
77 | if (currentResponse.ok) { |
78 | current = currentResponse.status === 204 ? {} : await currentResponse.json() |
79 | } else if (currentResponse.status !== 404) { |
80 | throw new Error(`${currentResponse.status} ${await currentResponse.text()}`) |
81 | } |
82 |
|
83 | const changes = diff(intended, current ?? {}, "", []) |
84 | if (changes.length === 0 || dry_run !== false) { |
85 | return { changed: changes.length > 0, diff: changes, applied: false } |
86 | } |
87 |
|
88 | |
89 | |
90 | |
91 | const method = current === null ? "PUT" : "PATCH" |
92 | const response = await fetch(url, { |
93 | method, |
94 | headers: { |
95 | Authorization: authorization, |
96 | "Content-Type": "application/yang-data+json", |
97 | Accept: "application/yang-data+json", |
98 | }, |
99 | body: JSON.stringify(intended), |
100 | }) |
101 |
|
102 | if (!response.ok) { |
103 | throw new Error(`${response.status} ${await response.text()}`) |
104 | } |
105 |
|
106 | return { changed: true, diff: changes, applied: true, method } |
107 | } |
108 |
|