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