0

Upsert pool

by
Published today

Declaratively create or update an LTM pool (monitor, load balancing mode, description, members) and apply only the difference. dry_run defaults to true: nothing changes and the diff is returned. When members is set it is the complete list: listed members are added, unlisted ones are removed (nodes are kept). Empty inputs are left unmanaged. Monitor takes full paths, e.g. /Common/http or /Common/http and /Common/tcp. Member names are address:port (IPv6: address.port) or node:port, with address when the node does not exist yet.

Script f5_bigip Verified

The script

Submitted by hugo989 Typescript (fetch-only)
Verified 6 hours ago
1
//native
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
// "/Common/web" -> "~Common~web" (iControl REST object reference); "%" (route domains), "?" and "#" are escaped
47
function ref(fullPath: string) {
48
  return fullPath.replace(/\//g, "~").replace(/[%?#]/g, encodeURIComponent)
49
}
50

51
// "/Common/http  and /Common/tcp " -> "/Common/http and /Common/tcp"
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
  // Intended state. Empty inputs leave that attribute unmanaged.
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
    // Current state
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
    // Difference
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
    // Only an explicit false applies changes (an API caller's null stays a dry run)
168
    const dryRun = dry_run !== false
169
    if (dryRun || !changed) {
170
      return { changed, dry_run: dryRun, applied: false, diff }
171
    }
172

173
    // Apply only the difference
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