//native
async function connect(auth: RT.F5Bigip): Promise<{
base: string
headers: { [key: string]: string }
release: () => Promise<void>
}> {
const base = auth.base_url.replace(/\/+$/, "")
if (auth.auth_method === "basic") {
return {
base,
headers: {
Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}`,
},
release: async () => {},
}
}
const login = await fetch(`${base}/mgmt/shared/authn/login`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({
username: auth.username,
password: auth.password,
loginProviderName: auth.login_provider_name || "tmos",
}),
})
if (!login.ok) {
throw new Error(`${login.status} ${await login.text()}`)
}
const token: string = (await login.json()).token.token
return {
base,
headers: { "X-F5-Auth-Token": token },
release: async () => {
await fetch(
`${base}/mgmt/shared/authz/tokens/${encodeURIComponent(token)}`,
{
method: "DELETE",
headers: { "X-F5-Auth-Token": token },
}
)
},
}
}
// "/Common/web" -> "~Common~web" (iControl REST object reference); "%" (route domains), "?" and "#" are escaped
function ref(fullPath: string) {
return fullPath.replace(/\//g, "~").replace(/[%?#]/g, encodeURIComponent)
}
// "/Common/http and /Common/tcp " -> "/Common/http and /Common/tcp"
function normalizeMonitor(monitor: string | undefined) {
return (monitor ?? "").trim().replace(/\s+/g, " ")
}
/**
* Upsert pool
* 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.
*/
export async function main(
auth: RT.F5Bigip,
name: string,
partition: string | undefined,
monitor: string | undefined,
load_balancing_mode: string | undefined,
description: string | undefined,
members: { name: string; address?: string }[] | undefined,
dry_run: boolean = true
) {
const poolPartition = partition || "Common"
const poolPath = `/${poolPartition}/${name}`
const poolUrl = (f5: { base: string }) =>
`${f5.base}/mgmt/tm/ltm/pool/${ref(poolPath)}`
// Intended state. Empty inputs leave that attribute unmanaged.
const wantAttrs: { [key: string]: string } = {}
if (monitor) wantAttrs.monitor = normalizeMonitor(monitor)
if (load_balancing_mode) wantAttrs.loadBalancingMode = load_balancing_mode
if (description) wantAttrs.description = description
const manageMembers = members != null && members.length > 0
const wantMembers = (members ?? []).map((m) => {
const fullPath = m.name.startsWith("/")
? m.name
: `/${poolPartition}/${m.name}`
const parts = fullPath.split("/")
return {
fullPath,
body: {
name: parts[parts.length - 1],
partition: parts[1],
...(m.address ? { address: m.address } : {}),
},
}
})
const f5 = await connect(auth)
try {
const json = {
...f5.headers,
"Content-Type": "application/json",
Accept: "application/json",
}
const call = async (url: string, method: string, body?: unknown) => {
const response = await fetch(url, {
method,
headers:
body === undefined
? { ...f5.headers, Accept: "application/json" }
: json,
body: body === undefined ? undefined : JSON.stringify(body),
})
if (!response.ok) {
throw new Error(
`${method} ${url}: ${response.status} ${await response.text()}`
)
}
return response
}
// Current state
const poolResponse = await fetch(poolUrl(f5), {
headers: { ...f5.headers, Accept: "application/json" },
})
if (!poolResponse.ok && poolResponse.status !== 404) {
throw new Error(`${poolResponse.status} ${await poolResponse.text()}`)
}
const exists = poolResponse.ok
const current = exists ? await poolResponse.json() : null
const currentMembers: string[] = []
if (exists) {
const membersResponse = await call(`${poolUrl(f5)}/members`, "GET")
for (const m of (await membersResponse.json()).items ?? []) {
currentMembers.push(m.fullPath ?? `/${m.partition}/${m.name}`)
}
}
// Difference
const attributes: { [key: string]: { from: string | null; to: string } } =
{}
for (const [key, to] of Object.entries(wantAttrs)) {
const raw = current?.[key] ?? null
const from =
key === "monitor" && raw != null ? normalizeMonitor(raw) : raw
if (from !== to) attributes[key] = { from, to }
}
const membersToAdd = manageMembers
? wantMembers.filter((m) => !currentMembers.includes(m.fullPath))
: []
const membersToRemove = manageMembers
? currentMembers.filter((p) => !wantMembers.some((m) => m.fullPath === p))
: []
const diff = {
pool: poolPath,
action: !exists
? "create"
: Object.keys(attributes).length ||
membersToAdd.length ||
membersToRemove.length
? "update"
: "none",
attributes,
members_to_add: membersToAdd.map((m) => m.fullPath),
members_to_remove: membersToRemove,
}
const changed = diff.action !== "none"
// Only an explicit false applies changes (an API caller's null stays a dry run)
const dryRun = dry_run !== false
if (dryRun || !changed) {
return { changed, dry_run: dryRun, applied: false, diff }
}
// Apply only the difference
if (!exists) {
await call(`${f5.base}/mgmt/tm/ltm/pool`, "POST", {
name,
partition: poolPartition,
...wantAttrs,
})
} else if (Object.keys(attributes).length) {
const patch: { [key: string]: string } = {}
for (const [key, change] of Object.entries(attributes))
patch[key] = change.to
await call(poolUrl(f5), "PATCH", patch)
}
for (const m of membersToAdd) {
await call(`${poolUrl(f5)}/members`, "POST", m.body)
}
for (const path of membersToRemove) {
await call(`${poolUrl(f5)}/members/${ref(path)}`, "DELETE")
}
return { changed, dry_run: false, applied: true, diff }
} finally {
await f5.release()
}
}
Submitted by hugo989 7 hours ago