Edits history of script submission #24147 for ' Upsert network device (cisco_ise)'

  • bunnative
    One script reply has been approved by the moderators
    Ap­pro­ved
    //native
    
    /**
     * Upsert network device
     * Declaratively set a network access device by name: reads the current device, returns the difference and, when dry_run is false, creates the device or updates the fields that differ. Fields left empty are not managed. Creating a new device requires ip_addresses. Shared secrets are write-only in ISE (never returned by GET), so they cannot be compared: they are set on create, and update_secrets true rotates them on an existing device. Updating a device that has RADIUS or TACACS+ settings requires radius_shared_secret or tacacs_shared_secret (the current secret, or a new one), because the update resends it; without it the update is refused. Devices with TrustSec settings, RADIUS key wrap or a second shared secret are not updated.
     */
    export async function main(
      auth: RT.CiscoIse,
      name: string,
      ip_addresses: { ipaddress: string; mask?: number }[] | undefined,
      network_device_groups: string[] | undefined,
      description: string | undefined,
      radius_shared_secret: string | undefined,
      tacacs_shared_secret: string | undefined,
      profile_name: string | undefined,
      coa_port: number | undefined,
      model_name: string | undefined,
      software_version: string | undefined,
      update_secrets: boolean = false,
      dry_run: boolean = true
    ) {
      const base = new URL(auth.base_url)
      if (auth.ers_port) base.port = String(auth.ers_port)
      const headers = {
        Authorization: `Basic ${btoa(`${auth.username}:${auth.password}`)}`,
        "Content-Type": "application/json",
        Accept: "application/json",
      }
      const ers = async (method: string, url: string | URL, body?: unknown) => {
        const response = await fetch(new URL(url, base), {
          method,
          headers,
          body: body === undefined ? undefined : JSON.stringify(body),
        })
        if (!response.ok) {
          throw new Error(`${response.status} ${await response.text()}`)
        }
        return response
      }
    
      // Current state. The name filter is matched again client-side and pages are followed,
      // so the lookup stays correct on ISE versions that ignore the filter.
      let id: string | null = null
      const filter = encodeURIComponent(`name.EQ.${name}`)
      for (let page = 1; !id; page++) {
        const { SearchResult } = await (
          await ers(
            "GET",
            `/ers/config/networkdevice?size=100&page=${page}&filter=${filter}`
          )
        ).json()
        const resources = (SearchResult.resources ?? []) as {
          id: string
          name: string
        }[]
        id = resources.find((r) => r.name === name)?.id ?? null
        if (!SearchResult.nextPage) break
      }
      const current: { [key: string]: any } | null = id
        ? (await (await ers("GET", `/ers/config/networkdevice/${id}`)).json())
            .NetworkDevice
        : null
    
      // Non-secret fields: the value to send and a comparable form of desired and current
      type Field = { send: any; want: any; have: any }
      const fields: { [key: string]: Field } = {}
      const ipKey = (l: { ipaddress: string; mask?: number }[] = []) =>
        l.map((ip) => `${ip.ipaddress}/${ip.mask ?? 32}`).sort()
      if (ip_addresses?.length) {
        fields.NetworkDeviceIPList = {
          send: ip_addresses.map((ip) => ({
            ipaddress: ip.ipaddress,
            mask: ip.mask ?? 32,
          })),
          want: ipKey(ip_addresses),
          have: current ? ipKey(current.NetworkDeviceIPList) : null,
        }
      }
      if (network_device_groups?.length) {
        fields.NetworkDeviceGroupList = {
          send: network_device_groups,
          want: [...network_device_groups].sort(),
          have: current ? [...(current.NetworkDeviceGroupList ?? [])].sort() : null,
        }
      }
      const scalars: { [key: string]: string | number | undefined } = {
        description,
        profileName: profile_name,
        coaPort: coa_port,
        modelName: model_name,
        softwareVersion: software_version,
      }
      for (const [key, value] of Object.entries(scalars)) {
        if (value != null && value !== "") {
          fields[key] = { send: value, want: value, have: current?.[key] ?? null }
        }
      }
    
      const diff: { field: string; current: any; desired: any }[] = Object.entries(
        fields
      )
        .filter(([, f]) => JSON.stringify(f.want) !== JSON.stringify(f.have))
        .map(([field, f]) => ({ field, current: f.have, desired: f.want }))
      const changes: { [key: string]: any } = Object.fromEntries(
        diff.map((d) => [d.field, fields[d.field].send])
      )
    
      // Write-only secrets: ISE never returns them, so they can't be compared or read back.
      const radiusSection = () => {
        const settings = {
          ...(current?.authenticationSettings ?? { networkProtocol: "RADIUS" }),
        }
        if (
          settings.enableKeyWrap === true ||
          settings.enableMultiSecret === true
        ) {
          throw new Error(
            "The device uses RADIUS key wrap or a second shared secret: their write-only keys would have to be resent, which this action does not manage"
          )
        }
        for (const k of [
          "radiusSharedSecret",
          "secondRadiusSharedSecret",
          "keyEncryptionKey",
          "messageAuthenticatorCodeKey",
        ]) {
          delete settings[k]
        }
        return { ...settings, radiusSharedSecret: radius_shared_secret }
      }
      const tacacsSection = () => {
        const settings = { ...(current?.tacacsSettings ?? {}) }
        delete settings.sharedSecret
        return { ...settings, sharedSecret: tacacs_shared_secret }
      }
    
      // Set the secrets on create, and on an existing device when update_secrets is true
      // (a rotation, which counts as a change).
      const setSecrets = !current || update_secrets === true
      if (radius_shared_secret && setSecrets) {
        changes.authenticationSettings = radiusSection()
        diff.push({
          field: "authenticationSettings.radiusSharedSecret",
          current: current ? "(write-only)" : null,
          desired: "********",
        })
      }
      if (tacacs_shared_secret && setSecrets) {
        changes.tacacsSettings = tacacsSection()
        diff.push({
          field: "tacacsSettings.sharedSecret",
          current: current ? "(write-only)" : null,
          desired: "********",
        })
      }
    
      // An update is a full PUT, and a PUT has to carry every configured secret: leaving a
      // section out, or sending it without its secret, is not documented to keep the old one.
      // So the secrets of a configured section are resent, and the update is refused when
      // the caller didn't pass them.
      if (current && diff.length > 0) {
        const has = (section: any) =>
          section != null &&
          typeof section === "object" &&
          Object.keys(section).length > 0
        if (
          has(current.authenticationSettings) &&
          !changes.authenticationSettings
        ) {
          if (!radius_shared_secret) {
            throw new Error(
              "This device has RADIUS settings, whose shared secret ISE never returns: pass radius_shared_secret (the current secret, or a new one) so the update resends it"
            )
          }
          changes.authenticationSettings = radiusSection()
        }
        if (has(current.tacacsSettings) && !changes.tacacsSettings) {
          if (!tacacs_shared_secret) {
            throw new Error(
              "This device has TACACS+ settings, whose shared secret ISE never returns: pass tacacs_shared_secret (the current secret, or a new one) so the update resends it"
            )
          }
          changes.tacacsSettings = tacacsSection()
        }
        if (has(current.trustsecsettings)) {
          throw new Error(
            "This device has TrustSec settings, whose write-only keys this action does not manage: update it in ISE"
          )
        }
      }
    
      if (!current) {
        if (!changes.NetworkDeviceIPList) {
          throw new Error(
            "Network device does not exist: ip_addresses is required to create it"
          )
        }
        const result = {
          changed: true,
          action: "create",
          id: null as string | null,
          diff,
          applied: false,
        }
        if (dry_run !== false) return result
        const response = await ers("POST", "/ers/config/networkdevice", {
          NetworkDevice: { name, ...changes },
        })
        result.id = response.headers.get("Location")?.split("/").pop() ?? null
        result.applied = true
        return result
      }
    
      const result = {
        changed: diff.length > 0,
        action: diff.length ? "update" : "none",
        id: current.id as string,
        diff,
        applied: false,
      }
      if (dry_run !== false || diff.length === 0) return result
      // Send the full current object with the changes, as Cisco's Ansible module and Terraform
      // provider do. The secret sections come only from changes, with their secrets (see above).
      const {
        link: _link,
        authenticationSettings: _auth,
        tacacsSettings: _tacacs,
        trustsecsettings: _trustsec,
        ...unchanged
      } = current
      await ers("PUT", `/ers/config/networkdevice/${current.id}`, {
        NetworkDevice: { ...unchanged, ...changes },
      })
      result.applied = true
      return result
    }
    

    Submitted by hugo989 6 hours ago