1 | |
2 |
|
3 | async function connect(auth: RT.F5Bigip): Promise<{ |
4 | base: string |
5 | headers: { [key: string]: string } |
6 | release: () => Promise<void> |
7 | }> { |
8 | const base = auth.base_url.replace(/\/+$/, "") |
9 | if (auth.auth_method === "basic") { |
10 | return { |
11 | base, |
12 | headers: { |
13 | Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}`, |
14 | }, |
15 | release: async () => {}, |
16 | } |
17 | } |
18 | const login = await fetch(`${base}/mgmt/shared/authn/login`, { |
19 | method: "POST", |
20 | headers: { "Content-Type": "application/json", Accept: "application/json" }, |
21 | body: JSON.stringify({ |
22 | username: auth.username, |
23 | password: auth.password, |
24 | loginProviderName: auth.login_provider_name || "tmos", |
25 | }), |
26 | }) |
27 | if (!login.ok) { |
28 | throw new Error(`${login.status} ${await login.text()}`) |
29 | } |
30 | const token: string = (await login.json()).token.token |
31 | return { |
32 | base, |
33 | headers: { "X-F5-Auth-Token": token }, |
34 | release: async () => { |
35 | await fetch( |
36 | `${base}/mgmt/shared/authz/tokens/${encodeURIComponent(token)}`, |
37 | { |
38 | method: "DELETE", |
39 | headers: { "X-F5-Auth-Token": token }, |
40 | } |
41 | ) |
42 | }, |
43 | } |
44 | } |
45 |
|
46 | |
47 | function ref(fullPath: string) { |
48 | return fullPath.replace(/\//g, "~").replace(/[%?#]/g, encodeURIComponent) |
49 | } |
50 |
|
51 | |
52 | function normalizeMonitor(monitor: string | undefined) { |
53 | return (monitor ?? "").trim().replace(/\s+/g, " ") |
54 | } |
55 |
|
56 | |
57 | * Upsert pool |
58 | * Declaratively converge an LTM pool: read the current pool and members, compute the difference with the intended monitor, load balancing mode, description and member list, and apply only that difference. With dry_run (the default) nothing is changed and the diff is returned. |
59 | */ |
60 | export async function main( |
61 | auth: RT.F5Bigip, |
62 | name: string, |
63 | partition: string | undefined, |
64 | monitor: string | undefined, |
65 | load_balancing_mode: string | undefined, |
66 | description: string | undefined, |
67 | members: { name: string; address?: string }[] | undefined, |
68 | dry_run: boolean = true |
69 | ) { |
70 | const poolPartition = partition || "Common" |
71 | const poolPath = `/${poolPartition}/${name}` |
72 | const poolUrl = (f5: { base: string }) => |
73 | `${f5.base}/mgmt/tm/ltm/pool/${ref(poolPath)}` |
74 |
|
75 | |
76 | const wantAttrs: { [key: string]: string } = {} |
77 | if (monitor) wantAttrs.monitor = normalizeMonitor(monitor) |
78 | if (load_balancing_mode) wantAttrs.loadBalancingMode = load_balancing_mode |
79 | if (description) wantAttrs.description = description |
80 | const manageMembers = members != null && members.length > 0 |
81 | const wantMembers = (members ?? []).map((m) => { |
82 | const fullPath = m.name.startsWith("/") |
83 | ? m.name |
84 | : `/${poolPartition}/${m.name}` |
85 | const parts = fullPath.split("/") |
86 | return { |
87 | fullPath, |
88 | body: { |
89 | name: parts[parts.length - 1], |
90 | partition: parts[1], |
91 | ...(m.address ? { address: m.address } : {}), |
92 | }, |
93 | } |
94 | }) |
95 |
|
96 | const f5 = await connect(auth) |
97 | try { |
98 | const json = { |
99 | ...f5.headers, |
100 | "Content-Type": "application/json", |
101 | Accept: "application/json", |
102 | } |
103 | const call = async (url: string, method: string, body?: unknown) => { |
104 | const response = await fetch(url, { |
105 | method, |
106 | headers: |
107 | body === undefined |
108 | ? { ...f5.headers, Accept: "application/json" } |
109 | : json, |
110 | body: body === undefined ? undefined : JSON.stringify(body), |
111 | }) |
112 | if (!response.ok) { |
113 | throw new Error( |
114 | `${method} ${url}: ${response.status} ${await response.text()}` |
115 | ) |
116 | } |
117 | return response |
118 | } |
119 |
|
120 | |
121 | const poolResponse = await fetch(poolUrl(f5), { |
122 | headers: { ...f5.headers, Accept: "application/json" }, |
123 | }) |
124 | if (!poolResponse.ok && poolResponse.status !== 404) { |
125 | throw new Error(`${poolResponse.status} ${await poolResponse.text()}`) |
126 | } |
127 | const exists = poolResponse.ok |
128 | const current = exists ? await poolResponse.json() : null |
129 | const currentMembers: string[] = [] |
130 | if (exists) { |
131 | const membersResponse = await call(`${poolUrl(f5)}/members`, "GET") |
132 | for (const m of (await membersResponse.json()).items ?? []) { |
133 | currentMembers.push(m.fullPath ?? `/${m.partition}/${m.name}`) |
134 | } |
135 | } |
136 |
|
137 | |
138 | const attributes: { [key: string]: { from: string | null; to: string } } = |
139 | {} |
140 | for (const [key, to] of Object.entries(wantAttrs)) { |
141 | const raw = current?.[key] ?? null |
142 | const from = |
143 | key === "monitor" && raw != null ? normalizeMonitor(raw) : raw |
144 | if (from !== to) attributes[key] = { from, to } |
145 | } |
146 | const membersToAdd = manageMembers |
147 | ? wantMembers.filter((m) => !currentMembers.includes(m.fullPath)) |
148 | : [] |
149 | const membersToRemove = manageMembers |
150 | ? currentMembers.filter((p) => !wantMembers.some((m) => m.fullPath === p)) |
151 | : [] |
152 | const diff = { |
153 | pool: poolPath, |
154 | action: !exists |
155 | ? "create" |
156 | : Object.keys(attributes).length || |
157 | membersToAdd.length || |
158 | membersToRemove.length |
159 | ? "update" |
160 | : "none", |
161 | attributes, |
162 | members_to_add: membersToAdd.map((m) => m.fullPath), |
163 | members_to_remove: membersToRemove, |
164 | } |
165 | const changed = diff.action !== "none" |
166 |
|
167 | |
168 | const dryRun = dry_run !== false |
169 | if (dryRun || !changed) { |
170 | return { changed, dry_run: dryRun, applied: false, diff } |
171 | } |
172 |
|
173 | |
174 | if (!exists) { |
175 | await call(`${f5.base}/mgmt/tm/ltm/pool`, "POST", { |
176 | name, |
177 | partition: poolPartition, |
178 | ...wantAttrs, |
179 | }) |
180 | } else if (Object.keys(attributes).length) { |
181 | const patch: { [key: string]: string } = {} |
182 | for (const [key, change] of Object.entries(attributes)) |
183 | patch[key] = change.to |
184 | await call(poolUrl(f5), "PATCH", patch) |
185 | } |
186 | for (const m of membersToAdd) { |
187 | await call(`${poolUrl(f5)}/members`, "POST", m.body) |
188 | } |
189 | for (const path of membersToRemove) { |
190 | await call(`${poolUrl(f5)}/members/${ref(path)}`, "DELETE") |
191 | } |
192 |
|
193 | return { changed, dry_run: false, applied: true, diff } |
194 | } finally { |
195 | await f5.release() |
196 | } |
197 | } |
198 |
|