0

Execute change control

by
Published today

Start an approved change control (resource API changecontrol.v1). One that has already run is refused; one already running is not started again. With wait_timeout_seconds set, wait for it to complete and fail with CloudVision's error if it did not succeed; otherwise return once it is started.

Script arista_cloudvision Verified

The script

Submitted by hugo989 Typescript (fetch-only)
Verified 4 hours ago
1
//native
2

3
/**
4
 * Execute change control
5
 * Start an approved change control (resource API changecontrol.v1). One that has already run is refused; one already running is not started again. With wait_timeout_seconds set, wait for it to complete and fail with CloudVision's error if it did not succeed; otherwise return once it is started.
6
 */
7
export async function main(
8
  auth: RT.AristaCloudvision,
9
  change_control_id: string,
10
  notes: string = "",
11
  wait_timeout_seconds: number = 0
12
) {
13
  const path = `/api/resources/changecontrol/v1/ChangeControl?key.id=${encodeURIComponent(change_control_id)}`
14
  const notFound = `Change control ${change_control_id} not found`
15
  const before = await cv(auth, path, { notFound })
16
  if (!before.value?.approve?.value) {
17
    throw new Error(`Change control ${change_control_id} is not approved`)
18
  }
19
  if (before.value.status === "CHANGE_CONTROL_STATUS_COMPLETED") {
20
    throw new Error(
21
      `Change control ${change_control_id} has already run${before.value.error ? ` and failed: ${before.value.error}` : ""}`
22
    )
23
  }
24
  // A running change control is not started again, only waited for.
25
  if (before.value.status !== "CHANGE_CONTROL_STATUS_RUNNING") {
26
    await cv(auth, "/api/resources/changecontrol/v1/ChangeControlConfig", {
27
      body: {
28
        key: { id: change_control_id },
29
        start: { value: true, notes: notes || "" },
30
      },
31
    })
32
  }
33
  if (!wait_timeout_seconds) return (await cv(auth, path, { notFound })).value
34

35
  const deadline = Date.now() + wait_timeout_seconds * 1000
36
  let cc = (await cv(auth, path, { notFound })).value
37
  while (
38
    cc.status !== "CHANGE_CONTROL_STATUS_COMPLETED" &&
39
    Date.now() < deadline
40
  ) {
41
    await new Promise((resolve) => setTimeout(resolve, 5000))
42
    cc = (await cv(auth, path, { notFound })).value
43
  }
44
  if (cc.status !== "CHANGE_CONTROL_STATUS_COMPLETED") {
45
    throw new Error(
46
      `Change control ${change_control_id} still ${cc.status} after ${wait_timeout_seconds} s`
47
    )
48
  }
49
  if (cc.error || cc.completionReason === "COMPLETION_REASON_FAILED") {
50
    throw new Error(
51
      `Change control ${change_control_id} failed: ${cc.error || cc.completionReason}`
52
    )
53
  }
54
  return cc
55
}
56

57
// Calls CloudVision and turns its failures into readable errors.
58
async function cv(
59
  auth: RT.AristaCloudvision,
60
  path: string,
61
  options: { body?: unknown; notFound?: string; optional?: boolean } = {}
62
): Promise<any> {
63
  const url = new URL(path, auth.base_url)
64
  const response = await fetch(url, {
65
    method: options.body === undefined ? "GET" : "POST",
66
    headers: {
67
      Authorization: `Bearer ${auth.token}`,
68
      ...(options.body === undefined
69
        ? {}
70
        : { "Content-Type": "application/json" }),
71
      Accept: "application/json",
72
    },
73
    body: options.body === undefined ? undefined : JSON.stringify(options.body),
74
  })
75
  const text = await response.text()
76
  if (
77
    response.status === 401 ||
78
    response.status === 403 ||
79
    (!response.ok && text.includes("User is unauthorized"))
80
  ) {
81
    throw new Error(
82
      `CloudVision refused the token (HTTP ${response.status}): check that the service-account token is valid, not expired, and that its role allows this call. ${text}`
83
    )
84
  }
85
  const data =
86
    text.trim() === ""
87
      ? {}
88
      : /^\s*[[{]/.test(text)
89
        ? JSON.parse(text)
90
        : undefined
91
  const error = findError(data)
92
  if (response.ok && data !== undefined && !error) return data
93
  const detail = error
94
    ? `${error.errorCode} ${error.errorMessage ?? (error.errors ?? []).join("; ")}`
95
    : `HTTP ${response.status} ${text.slice(0, 500)}`
96
  // Legacy lookups answer an unknown name or ID with an errorCode (cvprac
97
  // treats any error from getTaskById and getImageBundleByName that way).
98
  if (
99
    response.status === 404 ||
100
    (error && (options.optional || options.notFound))
101
  ) {
102
    if (options.optional) return undefined
103
    if (options.notFound) throw new Error(`${options.notFound} (${detail})`)
104
  }
105
  if (response.ok && !error) {
106
    throw new Error(
107
      `CloudVision returned a non-JSON response for ${url.pathname}: check that base_url is the CloudVision address. ${detail}`
108
    )
109
  }
110
  throw new Error(`CloudVision ${url.pathname} failed: ${detail}`)
111
}
112

113
function findError(value: any): any {
114
  if (!value || typeof value !== "object") return undefined
115
  // Like cvprac, only a truthy errorCode counts, at any depth.
116
  if (!Array.isArray(value) && value.errorCode) return value
117
  for (const item of Object.values(value)) {
118
    const error = findError(item)
119
    if (error) return error
120
  }
121
  return undefined
122
}
123