1 | |
2 |
|
3 | |
4 | * Apply image bundle |
5 | * Apply an EOS image bundle to a device (legacy Network Provisioning API, as cvprac does) and save, which creates the pending upgrade task returned in taskIds. The device is found by serial number, hostname, FQDN or system MAC address. Nothing runs on the switch until the task is executed, alone or in a change control. |
6 | */ |
7 | export async function main( |
8 | auth: RT.AristaCloudvision, |
9 | bundle_name: string, |
10 | device: string |
11 | ) { |
12 | const bundle = await cv( |
13 | auth, |
14 | `/cvpservice/image/getImageBundleByName.do?name=${encodeURIComponent(bundle_name)}`, |
15 | { notFound: `Image bundle ${bundle_name} not found` } |
16 | ) |
17 | const dev = await findDevice(auth, device) |
18 | const info = `Apply image: ${bundle_name} to netelement ${dev.fqdn}` |
19 | await cv( |
20 | auth, |
21 | "/cvpservice/provisioning/addTempAction.do?format=topology&queryParam=&nodeId=root", |
22 | { |
23 | body: { |
24 | data: [ |
25 | { |
26 | info, |
27 | infoPreview: info, |
28 | note: "", |
29 | action: "associate", |
30 | nodeType: "imagebundle", |
31 | nodeId: bundle.id ?? bundle.key, |
32 | toId: dev.systemMacAddress, |
33 | toIdType: "netelement", |
34 | fromId: "", |
35 | nodeName: bundle_name, |
36 | fromName: "", |
37 | toName: dev.fqdn, |
38 | childTasks: [], |
39 | parentTask: "", |
40 | }, |
41 | ], |
42 | }, |
43 | } |
44 | ) |
45 | const saved = await cv(auth, "/cvpservice/provisioning/v2/saveTopology.do", { |
46 | body: [], |
47 | }) |
48 | return { |
49 | status: saved.data?.status ?? "saved", |
50 | taskIds: saved.data?.taskIds ?? [], |
51 | } |
52 | } |
53 |
|
54 | async function findDevice(auth: RT.AristaCloudvision, target: string) { |
55 | const devices = await cv( |
56 | auth, |
57 | "/cvpservice/inventory/devices?provisioned=true" |
58 | ) |
59 | const t = target.toLowerCase() |
60 | const device = (Array.isArray(devices) ? devices : []).find((d: any) => |
61 | [d.serialNumber, d.hostname, d.fqdn, d.systemMacAddress].some( |
62 | (v) => typeof v === "string" && v.toLowerCase() === t |
63 | ) |
64 | ) |
65 | if (!device) { |
66 | throw new Error( |
67 | `Device ${target} not found among the provisioned devices in CloudVision (match on serial number, hostname, FQDN or system MAC address)` |
68 | ) |
69 | } |
70 | return device |
71 | } |
72 |
|
73 | |
74 | |
75 | async function cv( |
76 | auth: RT.AristaCloudvision, |
77 | path: string, |
78 | options: { body?: unknown; notFound?: string; optional?: boolean } = {} |
79 | ): Promise<any> { |
80 | const url = new URL(path, auth.base_url) |
81 | |
82 | |
83 | |
84 | const cvaas = auth.deployment |
85 | ? auth.deployment === "cvaas" |
86 | : /(^|\.)arista\.io\.?$/i.test(url.hostname) |
87 | if (!cvaas && url.pathname.startsWith("/cvpservice/")) { |
88 | url.pathname = url.pathname.replace(/^\/cvpservice\//, "/web/") |
89 | } |
90 | const response = await fetch(url, { |
91 | method: options.body === undefined ? "GET" : "POST", |
92 | headers: { |
93 | Authorization: `Bearer ${auth.token}`, |
94 | ...(options.body === undefined |
95 | ? {} |
96 | : { "Content-Type": "application/json" }), |
97 | Accept: "application/json", |
98 | }, |
99 | body: options.body === undefined ? undefined : JSON.stringify(options.body), |
100 | }) |
101 | const text = await response.text() |
102 | if ( |
103 | response.status === 401 || |
104 | response.status === 403 || |
105 | (!response.ok && text.includes("User is unauthorized")) |
106 | ) { |
107 | throw new Error( |
108 | `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}` |
109 | ) |
110 | } |
111 | const data = |
112 | text.trim() === "" |
113 | ? {} |
114 | : /^\s*[[{]/.test(text) |
115 | ? JSON.parse(text) |
116 | : undefined |
117 | const error = findError(data) |
118 | if (response.ok && data !== undefined && !error) return data |
119 | const detail = error |
120 | ? `${error.errorCode} ${error.errorMessage ?? (error.errors ?? []).join("; ")}` |
121 | : `HTTP ${response.status} ${text.slice(0, 500)}` |
122 | |
123 | |
124 | if ( |
125 | response.status === 404 || |
126 | (error && (options.optional || options.notFound)) |
127 | ) { |
128 | if (options.optional) return undefined |
129 | if (options.notFound) throw new Error(`${options.notFound} (${detail})`) |
130 | } |
131 | if (response.ok && !error) { |
132 | throw new Error( |
133 | `CloudVision returned a non-JSON response for ${url.pathname}: check that base_url is the CloudVision address. ${detail}` |
134 | ) |
135 | } |
136 | throw new Error(`CloudVision ${url.pathname} failed: ${detail}`) |
137 | } |
138 |
|
139 | function findError(value: any): any { |
140 | if (!value || typeof value !== "object") return undefined |
141 | |
142 | if (!Array.isArray(value) && value.errorCode) return value |
143 | for (const item of Object.values(value)) { |
144 | const error = findError(item) |
145 | if (error) return error |
146 | } |
147 | return undefined |
148 | } |
149 |
|