1 | |
2 |
|
3 | |
4 | * Apply configlets |
5 | * Apply provisioning configlets to a device or a container (legacy Network Provisioning API, as cvprac does) and save, which creates the pending tasks returned in taskIds. A device is found by serial number, hostname, FQDN or system MAC address; a container by exact name. Configlets already applied there are kept and left in place. |
6 | */ |
7 | export async function main( |
8 | auth: RT.AristaCloudvision, |
9 | target_type: "device" | "container", |
10 | target: string, |
11 | configlet_names: string[] |
12 | ) { |
13 | if (!configlet_names?.length) throw new Error("configlet_names is empty") |
14 | const wanted = await Promise.all( |
15 | [...new Set(configlet_names)].map(async (name) => { |
16 | const configlet = await cv( |
17 | auth, |
18 | `/cvpservice/configlet/getConfigletByName.do?name=${encodeURIComponent(name)}`, |
19 | { notFound: `Configlet ${name} not found` } |
20 | ) |
21 | return { name: configlet.name ?? name, key: configlet.key as string } |
22 | }) |
23 | ) |
24 |
|
25 | const names: string[] = [] |
26 | const keys: string[] = [] |
27 | const builderNames: string[] = [] |
28 | const builderKeys: string[] = [] |
29 | let action: Record<string, unknown> |
30 | if (target_type === "device") { |
31 | const device = await findDevice(auth, target) |
32 | const current = await cv( |
33 | auth, |
34 | `/cvpservice/provisioning/getConfigletsByNetElementId.do?netElementId=${encodeURIComponent(device.systemMacAddress)}&startIndex=0&endIndex=0` |
35 | ) |
36 | for (const c of current.configletList ?? []) { |
37 | names.push(c.name) |
38 | keys.push(c.key) |
39 | } |
40 | action = { |
41 | info: `Windmill: Configlet Assign: to Device ${device.fqdn}`, |
42 | infoPreview: `<b>Configlet Assign:</b> to Device ${device.fqdn}`, |
43 | toId: device.systemMacAddress, |
44 | toIdType: "netelement", |
45 | toName: device.fqdn, |
46 | nodeIpAddress: device.ipAddress, |
47 | nodeTargetIpAddress: device.ipAddress, |
48 | } |
49 | } else { |
50 | const container = await findContainer(auth, target) |
51 | const current = await cv( |
52 | auth, |
53 | `/cvpservice/provisioning/getConfigletsByContainerId.do?containerId=${encodeURIComponent(container.key)}&startIndex=0&endIndex=0` |
54 | ) |
55 | for (const c of current.configletList ?? []) { |
56 | |
57 | |
58 | if (c.type === "Static") { |
59 | names.push(c.name) |
60 | keys.push(c.key) |
61 | } else if (c.type === "Builder") { |
62 | builderNames.push(c.name) |
63 | builderKeys.push(c.key) |
64 | } |
65 | } |
66 | action = { |
67 | info: `Windmill: Configlet Assign: to Container ${container.name}`, |
68 | infoPreview: `<b>Configlet Assign:</b> to Container ${container.name}`, |
69 | toId: container.key, |
70 | toIdType: "container", |
71 | toName: container.name, |
72 | nodeIpAddress: "", |
73 | nodeTargetIpAddress: "", |
74 | } |
75 | } |
76 |
|
77 | const added = wanted.filter((c) => !keys.includes(c.key)) |
78 | if (!added.length) { |
79 | return { status: "unchanged", taskIds: [], applied: names } |
80 | } |
81 | for (const c of added) { |
82 | names.push(c.name) |
83 | keys.push(c.key) |
84 | } |
85 | await cv( |
86 | auth, |
87 | "/cvpservice/provisioning/addTempAction.do?format=topology&queryParam=&nodeId=root", |
88 | { |
89 | body: { |
90 | data: [ |
91 | { |
92 | note: "", |
93 | action: "associate", |
94 | nodeType: "configlet", |
95 | nodeId: "", |
96 | configletList: keys, |
97 | configletNamesList: names, |
98 | ignoreConfigletNamesList: [], |
99 | ignoreConfigletList: [], |
100 | configletBuilderList: builderKeys, |
101 | configletBuilderNamesList: builderNames, |
102 | ignoreConfigletBuilderList: [], |
103 | ignoreConfigletBuilderNamesList: [], |
104 | fromId: "", |
105 | nodeName: "", |
106 | fromName: "", |
107 | childTasks: [], |
108 | parentTask: "", |
109 | ...action, |
110 | }, |
111 | ], |
112 | }, |
113 | } |
114 | ) |
115 | const saved = await cv(auth, "/cvpservice/provisioning/v2/saveTopology.do", { |
116 | body: [], |
117 | }) |
118 | return { |
119 | status: saved.data?.status ?? "saved", |
120 | taskIds: saved.data?.taskIds ?? [], |
121 | applied: names, |
122 | } |
123 | } |
124 |
|
125 | async function findDevice(auth: RT.AristaCloudvision, target: string) { |
126 | const devices = await cv( |
127 | auth, |
128 | "/cvpservice/inventory/devices?provisioned=true" |
129 | ) |
130 | const t = target.toLowerCase() |
131 | const device = (Array.isArray(devices) ? devices : []).find((d: any) => |
132 | [d.serialNumber, d.hostname, d.fqdn, d.systemMacAddress].some( |
133 | (v) => typeof v === "string" && v.toLowerCase() === t |
134 | ) |
135 | ) |
136 | if (!device) { |
137 | throw new Error( |
138 | `Device ${target} not found among the provisioned devices in CloudVision (match on serial number, hostname, FQDN or system MAC address)` |
139 | ) |
140 | } |
141 | return device |
142 | } |
143 |
|
144 | async function findContainer(auth: RT.AristaCloudvision, name: string) { |
145 | const result = await cv( |
146 | auth, |
147 | `/cvpservice/provisioning/searchTopology.do?queryParam=${encodeURIComponent(name)}&startIndex=0&endIndex=0` |
148 | ) |
149 | const container = (result.containerList ?? []).find( |
150 | (c: any) => c.name === name |
151 | ) |
152 | if (!container) throw new Error(`Container ${name} not found in CloudVision`) |
153 | return container |
154 | } |
155 |
|
156 | |
157 | |
158 | async function cv( |
159 | auth: RT.AristaCloudvision, |
160 | path: string, |
161 | options: { body?: unknown; notFound?: string; optional?: boolean } = {} |
162 | ): Promise<any> { |
163 | const url = new URL(path, auth.base_url) |
164 | |
165 | |
166 | |
167 | const cvaas = auth.deployment |
168 | ? auth.deployment === "cvaas" |
169 | : /(^|\.)arista\.io\.?$/i.test(url.hostname) |
170 | if (!cvaas && url.pathname.startsWith("/cvpservice/")) { |
171 | url.pathname = url.pathname.replace(/^\/cvpservice\//, "/web/") |
172 | } |
173 | const response = await fetch(url, { |
174 | method: options.body === undefined ? "GET" : "POST", |
175 | headers: { |
176 | Authorization: `Bearer ${auth.token}`, |
177 | ...(options.body === undefined |
178 | ? {} |
179 | : { "Content-Type": "application/json" }), |
180 | Accept: "application/json", |
181 | }, |
182 | body: options.body === undefined ? undefined : JSON.stringify(options.body), |
183 | }) |
184 | const text = await response.text() |
185 | if ( |
186 | response.status === 401 || |
187 | response.status === 403 || |
188 | (!response.ok && text.includes("User is unauthorized")) |
189 | ) { |
190 | throw new Error( |
191 | `CloudVision refused the token (HTTP ${response.status}): check that the service-account token is valid, not expired, and that its role allows this call. ${text}` |
192 | ) |
193 | } |
194 | const data = |
195 | text.trim() === "" |
196 | ? {} |
197 | : /^\s*[[{]/.test(text) |
198 | ? JSON.parse(text) |
199 | : undefined |
200 | const error = findError(data) |
201 | if (response.ok && data !== undefined && !error) return data |
202 | const detail = error |
203 | ? `${error.errorCode} ${error.errorMessage ?? (error.errors ?? []).join("; ")}` |
204 | : `HTTP ${response.status} ${text.slice(0, 500)}` |
205 | |
206 | |
207 | if ( |
208 | response.status === 404 || |
209 | (error && (options.optional || options.notFound)) |
210 | ) { |
211 | if (options.optional) return undefined |
212 | if (options.notFound) throw new Error(`${options.notFound} (${detail})`) |
213 | } |
214 | if (response.ok && !error) { |
215 | throw new Error( |
216 | `CloudVision returned a non-JSON response for ${url.pathname}: check that base_url is the CloudVision address. ${detail}` |
217 | ) |
218 | } |
219 | throw new Error(`CloudVision ${url.pathname} failed: ${detail}`) |
220 | } |
221 |
|
222 | function findError(value: any): any { |
223 | if (!value || typeof value !== "object") return undefined |
224 | |
225 | if (!Array.isArray(value) && value.errorCode) return value |
226 | for (const item of Object.values(value)) { |
227 | const error = findError(item) |
228 | if (error) return error |
229 | } |
230 | return undefined |
231 | } |
232 |
|