1 | |
2 |
|
3 | |
4 | * Set device variables |
5 | * Declaratively set the template variables of a device: reads the current variables, returns the diff and applies only the difference (nothing changes while dry_run is true). mode "merge" only sets the given keys; "replace" also removes keys not given (except _sys_*). For a device with no variables yet, _sys_lan_mac is required. |
6 | */ |
7 | export async function main( |
8 | auth: RT.ArubaCentralOnprem, |
9 | device_serial: string, |
10 | variables: { [key: string]: any }, |
11 | mode: "merge" | "replace" | undefined, |
12 | dry_run: boolean = true |
13 | ) { |
14 | const dryRun = dry_run !== false |
15 | const replace = mode === "replace" |
16 | const token = await getAccessToken(auth, dryRun ? "read" : "all") |
17 | const base = auth.base_url.replace(/\/+$/, "") |
18 | const url = `${base}/configuration/v1/devices/${encodeURIComponent(device_serial)}/template_variables` |
19 | const headers = { |
20 | Authorization: `Bearer ${token}`, |
21 | "Content-Type": "application/json", |
22 | Accept: "application/json", |
23 | } |
24 |
|
25 | |
26 | const current = await fetch(url, { |
27 | headers: { Authorization: headers.Authorization, Accept: headers.Accept }, |
28 | }) |
29 | if (!current.ok) { |
30 | throw new Error(`${current.status} ${await current.text()}`) |
31 | } |
32 | const currentBody = (await current.json()) as { |
33 | data?: { total?: number; variables?: { [key: string]: any } } |
34 | } |
35 | const existing: { [key: string]: any } = currentBody?.data?.variables ?? {} |
36 | const intended: { [key: string]: any } = variables ?? {} |
37 |
|
38 | |
39 | const same = (a: any, b: any) => JSON.stringify(a) === JSON.stringify(b) |
40 | const added: { [key: string]: any } = {} |
41 | const changed: { [key: string]: { from: any; to: any } } = {} |
42 | const removed: { [key: string]: any } = {} |
43 | for (const [k, v] of Object.entries(intended)) { |
44 | if (!(k in existing)) { |
45 | added[k] = v |
46 | } else if (!same(existing[k], v)) { |
47 | changed[k] = { from: existing[k], to: v } |
48 | } |
49 | } |
50 | if (replace) { |
51 | for (const [k, v] of Object.entries(existing)) { |
52 | if (!(k in intended) && !k.startsWith("_sys_")) { |
53 | removed[k] = v |
54 | } |
55 | } |
56 | } |
57 | const diff = { added, changed, removed } |
58 | const hasChanges = |
59 | Object.keys(added).length + |
60 | Object.keys(changed).length + |
61 | Object.keys(removed).length > |
62 | 0 |
63 |
|
64 | const creating = Object.keys(existing).length === 0 |
65 | |
66 | if (creating && hasChanges && !intended._sys_lan_mac) { |
67 | throw new Error( |
68 | "device has no variables yet: include _sys_lan_mac (the device MAC address) to create them" |
69 | ) |
70 | } |
71 |
|
72 | if (!hasChanges || dryRun) { |
73 | return { changed: hasChanges, diff, applied: false, dry_run: dryRun } |
74 | } |
75 |
|
76 | |
77 | const sys = Object.fromEntries( |
78 | Object.entries(existing).filter(([k]) => k.startsWith("_sys_")) |
79 | ) |
80 | let method: "POST" | "PUT" | "PATCH" |
81 | let payload: { [key: string]: any } |
82 | if (creating) { |
83 | |
84 | method = "POST" |
85 | payload = { _sys_serial: device_serial, ...intended } |
86 | } else if (Object.keys(removed).length > 0) { |
87 | |
88 | method = "PUT" |
89 | payload = { ...sys, ...intended } |
90 | } else { |
91 | method = "PATCH" |
92 | payload = { |
93 | ...sys, |
94 | ...added, |
95 | ...Object.fromEntries(Object.entries(changed).map(([k, c]) => [k, c.to])), |
96 | } |
97 | } |
98 |
|
99 | const response = await fetch(url, { |
100 | method, |
101 | headers, |
102 | body: JSON.stringify({ |
103 | total: Object.keys(payload).length, |
104 | variables: payload, |
105 | }), |
106 | }) |
107 | if (!response.ok) { |
108 | throw new Error(`${response.status} ${await response.text()}`) |
109 | } |
110 |
|
111 | return { changed: true, diff, applied: true, dry_run: false, method } |
112 | } |
113 |
|
114 | |
115 | |
116 | async function getAccessToken( |
117 | auth: RT.ArubaCentralOnprem, |
118 | scope: "read" | "all" |
119 | ): Promise<string> { |
120 | const base = auth.base_url.replace(/\/+$/, "") |
121 |
|
122 | const loginUrl = new URL(`${base}/oauth2/authorize/central/api/login`) |
123 | loginUrl.searchParams.append("client_id", auth.client_id) |
124 | const login = await fetch(loginUrl, { |
125 | method: "POST", |
126 | headers: { "Content-Type": "application/json", Accept: "application/json" }, |
127 | body: JSON.stringify({ username: auth.username, password: auth.password }), |
128 | }) |
129 | if (!login.ok) { |
130 | throw new Error(`login: ${login.status} ${await login.text()}`) |
131 | } |
132 | const cookies = login.headers.get("set-cookie") ?? "" |
133 | const csrf = cookies.match(/csrftoken=([^;,\s]+)/)?.[1] |
134 | const session = cookies.match(/session=([^;,\s]+)/)?.[1] |
135 | if (!csrf || !session) { |
136 | throw new Error( |
137 | "login: response did not set the csrftoken and session cookies" |
138 | ) |
139 | } |
140 |
|
141 | const authorizeUrl = new URL(`${base}/oauth2/authorize/central/api`) |
142 | authorizeUrl.searchParams.append("client_id", auth.client_id) |
143 | authorizeUrl.searchParams.append("response_type", "code") |
144 | authorizeUrl.searchParams.append("scope", scope) |
145 | const authorize = await fetch(authorizeUrl, { |
146 | method: "POST", |
147 | headers: { |
148 | "Content-Type": "application/json", |
149 | Accept: "application/json", |
150 | Cookie: `session=${session}`, |
151 | "X-CSRF-Token": csrf, |
152 | }, |
153 | body: JSON.stringify({ customer_id: auth.customer_id }), |
154 | }) |
155 | if (!authorize.ok) { |
156 | throw new Error(`authorize: ${authorize.status} ${await authorize.text()}`) |
157 | } |
158 | const { auth_code } = (await authorize.json()) as { auth_code: string } |
159 |
|
160 | const tokenUrl = new URL(`${base}/oauth2/token`) |
161 | tokenUrl.searchParams.append("client_id", auth.client_id) |
162 | tokenUrl.searchParams.append("client_secret", auth.client_secret) |
163 | tokenUrl.searchParams.append("grant_type", "authorization_code") |
164 | tokenUrl.searchParams.append("code", auth_code) |
165 | const token = await fetch(tokenUrl, { |
166 | method: "POST", |
167 | headers: { Accept: "application/json" }, |
168 | }) |
169 | if (!token.ok) { |
170 | throw new Error(`token: ${token.status} ${await token.text()}`) |
171 | } |
172 | const { access_token } = (await token.json()) as { access_token: string } |
173 | return access_token |
174 | } |
175 |
|