1 | # py312 |
2 | # requirements: |
3 | # pyeapi==1.0.4 |
4 | # wmill==1.851.0 |
5 |
|
6 | import logging |
7 | import os |
8 | import re |
9 | import ssl |
10 | import time |
11 | import urllib.error |
12 | import urllib.request |
13 | from typing import Literal, TypedDict |
14 | from urllib.parse import urlsplit |
15 |
|
16 | import pyeapi |
17 | import wmill |
18 |
|
19 |
|
20 | class arista_eos(TypedDict): |
21 | host: str |
22 | username: str |
23 | password: str |
24 | port: int |
25 | verify_tls: bool |
26 |
|
27 |
|
28 | def main( |
29 | auth: arista_eos, |
30 | phase: Literal["pre", "post"], |
31 | target_version: str = "", |
32 | image_base_url: str = "", |
33 | required_free_mb: int = 1024, |
34 | return_timeout_seconds: int = 900, |
35 | stabilisation_seconds: int = 120, |
36 | poll_interval_seconds: int = 15, |
37 | ): |
38 | """ |
39 | Device check |
40 | Pre- or post-upgrade check of one switch: returns { running_version, status ok|ko, checks_ok, checks_ko[{check, message}] }. Pre: flash space for the target image, MLAG redundancy, and a snapshot of interfaces/LLDP/MLAG kept in the flow run's state. Post: waits for the switch to answer and for the MLAG reload-delay, then compares with the snapshot over a bounded stabilisation window. Throws DeviceUnreachable or AuthFailure. Does not compare the version with the target: the workflow does. |
41 | """ |
42 | poll = poll_interval_seconds or 15 |
43 | durations = {} |
44 | key = "arista_eos_" + re.sub(r"\W", "_", auth["host"]) |
45 | state = load_state(key) or {} |
46 |
|
47 | if phase == "post": |
48 | # Wait for the switch to answer on a boot later than the one each reload of this run was sent on: `reload now` |
49 | # can answer before EOS goes down, and the old boot must not pass for the new one. AuthFailure stops at once. |
50 | markers = [state[k] for k in ("reload", "rollback") if state.get(k)] |
51 | timeout = return_timeout_seconds or 900 |
52 | start = time.time() |
53 | while True: |
54 | try: |
55 | [v] = eapi(auth, ["show version"]) |
56 | m = next( |
57 | (m for m in markers if m["boot_timestamp"] == v["bootupTimestamp"]), |
58 | None, |
59 | ) |
60 | if not m: |
61 | break |
62 | pending = ( |
63 | f"still on the boot the reload sent at {m['sent_at']} was sent on" |
64 | ) |
65 | except DeviceUnreachable as e: |
66 | pending = str(e) |
67 | if time.time() - start > timeout: |
68 | raise named( |
69 | DeviceUnreachable, |
70 | f"{auth['host']}: not back after {timeout} s ({pending})", |
71 | ) |
72 | time.sleep(poll) |
73 | durations["return_timeout_seconds"] = timeout |
74 | durations["returned_after_seconds"] = round(time.time() - start) |
75 |
|
76 | version, mlag = eapi(auth, ["show version", "show mlag"]) |
77 | running_version = short_version(version["version"]) |
78 | durations["reload_delay_seconds"] = max( |
79 | mlag.get("reloadDelay") or 0, mlag.get("reloadDelayNonMlag") or 0 |
80 | ) |
81 |
|
82 | ok, ko = [], [] |
83 |
|
84 | def record(check, problems): |
85 | if problems: |
86 | ko.append({"check": check, "message": "; ".join(problems)}) |
87 | else: |
88 | ok.append(check) |
89 |
|
90 | if phase == "pre": |
91 | record( |
92 | "flash", |
93 | flash_problems(auth, target_version, image_base_url, required_free_mb), |
94 | ) |
95 | [sanity] = eapi(auth, ["show mlag config-sanity"]) |
96 | record("redundancy", redundancy_problems(mlag, sanity)) |
97 | save_state( |
98 | key, |
99 | { |
100 | **state, |
101 | "snapshot": snapshot(auth), |
102 | "snapshot_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), |
103 | }, |
104 | ) |
105 | ok.append("interface") |
106 | elif not state.get("snapshot"): |
107 | ko.append( |
108 | { |
109 | "check": "interface", |
110 | "message": f"No pre-check snapshot for {auth['host']} in this flow run: run the pre-check first, in the same run", |
111 | } |
112 | ) |
113 | else: |
114 | # Ports stay errdisabled until the MLAG reload-delay has elapsed since boot: wait it out. |
115 | wait = durations["reload_delay_seconds"] - version["uptime"] |
116 | if wait > 0: |
117 | time.sleep(wait) |
118 | durations["reload_delay_waited_seconds"] = max(0, round(wait)) |
119 | stab = 120 if stabilisation_seconds is None else stabilisation_seconds |
120 | start = time.time() |
121 | problems = diff(state["snapshot"], snapshot(auth)) |
122 | while problems and time.time() - start < stab: |
123 | time.sleep(poll) |
124 | problems = diff(state["snapshot"], snapshot(auth)) |
125 | durations["stabilisation_seconds"] = stab |
126 | durations["stabilised_after_seconds"] = round(time.time() - start) |
127 | record("interface", problems) |
128 |
|
129 | return { |
130 | "running_version": running_version, |
131 | "status": "ko" if ko else "ok", |
132 | "checks_ok": ok, |
133 | "checks_ko": ko, |
134 | "durations": durations, |
135 | } |
136 |
|
137 |
|
138 | def flash_problems(auth, target, base, required_mb): |
139 | [d] = eapi(auth, [{"cmd": "dir flash:/", "revision": 2}]) |
140 | flash = d["urls"]["flash:/"] |
141 | image = f"EOS-{target}.swi" if target else "" |
142 | if image and image in flash["entries"]: |
143 | return [] |
144 | required = (required_mb or 1024) * 1e6 |
145 | if image and base: |
146 | try: |
147 | with urllib.request.urlopen( |
148 | urllib.request.Request(f"{base.rstrip('/')}/{image}", method="HEAD"), |
149 | timeout=30, |
150 | ) as head: |
151 | required = int(head.headers.get("content-length") or 0) or required |
152 | except urllib.error.HTTPError as e: |
153 | return [f"Target image {image} not found on the image server ({e.code})"] |
154 | except OSError as e: |
155 | return [ |
156 | f"Image server unreachable from the worker: {getattr(e, 'reason', e)}" |
157 | ] |
158 | free = flash["filesystem"]["freeBytes"] |
159 | if free < required: |
160 | return [ |
161 | f"Insufficient flash space ({mb(free)} free, {mb(required)} required{f' for {image}' if image else ''})" |
162 | ] |
163 | return [] |
164 |
|
165 |
|
166 | def redundancy_problems(mlag, sanity): |
167 | if mlag.get("state") == "disabled": |
168 | return [ |
169 | "MLAG is not configured: no peer to carry the traffic during the reload" |
170 | ] |
171 | p = [] |
172 | if mlag.get("state") != "active": |
173 | p.append(f"MLAG state {mlag.get('state')} (expected active)") |
174 | if mlag.get("negStatus") != "connected": |
175 | p.append(f"MLAG negotiation {mlag.get('negStatus')} (expected connected)") |
176 | if mlag.get("peerLinkStatus") != "up": |
177 | p.append(f"peer-link {mlag.get('peerLink')} {mlag.get('peerLinkStatus')}") |
178 | if mlag.get("localIntfStatus") != "up": |
179 | p.append( |
180 | f"MLAG local interface {mlag.get('localInterface')} {mlag.get('localIntfStatus')}" |
181 | ) |
182 | inconsistent = [ |
183 | *(sanity.get("globalConfiguration") or {}), |
184 | *(sanity.get("interfaceConfiguration") or {}), |
185 | ] |
186 | if inconsistent: |
187 | p.append(f"config-sanity inconsistent: {', '.join(inconsistent)}") |
188 | partial = (mlag.get("mlagPorts") or {}).get("Active-partial") |
189 | if partial: |
190 | p.append(f"{partial} MLAG port(s) active-partial") |
191 | if mlag.get("portsErrdisabled"): |
192 | p.append("MLAG ports errdisabled") |
193 | return p |
194 |
|
195 |
|
196 | def snapshot(auth): |
197 | status, lldp, mlag, mlag_intf = eapi( |
198 | auth, |
199 | [ |
200 | "show interfaces status", |
201 | "show lldp neighbors", |
202 | "show mlag", |
203 | "show mlag interfaces", |
204 | ], |
205 | ) |
206 | return { |
207 | "interfaces_up": sorted( |
208 | n |
209 | for n, i in status["interfaceStatuses"].items() |
210 | if i.get("linkStatus") == "connected" |
211 | ), |
212 | "lldp": [ |
213 | { |
214 | "port": n["port"], |
215 | "neighbor": f"{n['neighborDevice']} {n['neighborPort']}", |
216 | } |
217 | for n in lldp["lldpNeighbors"] |
218 | ], |
219 | "mlag": { |
220 | "state": mlag.get("state"), |
221 | "negStatus": mlag.get("negStatus"), |
222 | "peerLinkStatus": mlag.get("peerLinkStatus"), |
223 | "ports": { |
224 | f"{i['localInterface']} (mlag {id})": i["status"] |
225 | for id, i in (mlag_intf.get("interfaces") or {}).items() |
226 | }, |
227 | }, |
228 | } |
229 |
|
230 |
|
231 | def diff(before, after): |
232 | p = [] |
233 | for i in before["interfaces_up"]: |
234 | if i not in after["interfaces_up"]: |
235 | p.append(f"{i} down after the upgrade (up before)") |
236 | for n in before["lldp"]: |
237 | if not any( |
238 | a["port"] == n["port"] and a["neighbor"] == n["neighbor"] |
239 | for a in after["lldp"] |
240 | ): |
241 | p.append(f"LLDP neighbor {n['neighbor']} no longer seen on {n['port']}") |
242 | for k in ("state", "negStatus", "peerLinkStatus"): |
243 | if before["mlag"][k] != after["mlag"][k]: |
244 | p.append(f"MLAG {k} {after['mlag'][k]} (was {before['mlag'][k]})") |
245 | for port, s in before["mlag"]["ports"].items(): |
246 | if after["mlag"]["ports"].get(port) != s: |
247 | p.append(f"{port} {after['mlag']['ports'].get(port, 'missing')} (was {s})") |
248 | return p |
249 |
|
250 |
|
251 | # The snapshot lives in the root flow run's user state, keyed by switch: pre- and post-check are different steps |
252 | # (often in nested subflows) of the same run. Outside a flow, it falls back to this script's own state. |
253 | # The endpoint is called through the client so an unreachable state raises (wmill.get_flow_user_state returns None). |
254 | # ponytail: the fallback is one map per script, so concurrent standalone runs can race; flows don't. |
255 | def load_state(key): |
256 | if os.environ.get("WM_FLOW_JOB_ID"): |
257 | c = wmill.Windmill() |
258 | return c.get( |
259 | f"/w/{c.workspace}/jobs/flow/user_states/{c.get_root_job_id()}/{key}" |
260 | ).json() |
261 | return (wmill.get_state() or {}).get(key) |
262 |
|
263 |
|
264 | def save_state(key, value): |
265 | if os.environ.get("WM_FLOW_JOB_ID"): |
266 | c = wmill.Windmill() |
267 | c.post( |
268 | f"/w/{c.workspace}/jobs/flow/user_states/{c.get_root_job_id()}/{key}", |
269 | json=value, |
270 | ) |
271 | else: |
272 | wmill.set_state({**(wmill.get_state() or {}), key: value}) |
273 |
|
274 |
|
275 | # "4.34.9M-51132493.4349M (engineering build)" on cEOS, "4.34.9M" on hardware. |
276 | def short_version(v): |
277 | m = re.match(r"\d+\.\d+\.\d+(\.\d+)?[A-Z]*", v) |
278 | return m.group(0) if m else v |
279 |
|
280 |
|
281 | def mb(n): |
282 | return f"{n / 1e9:.1f} GB" if n >= 1e9 else f"{round(n / 1e6)} MB" |
283 |
|
284 |
|
285 | class DeviceUnreachable(Exception): |
286 | pass |
287 |
|
288 |
|
289 | class AuthFailure(Exception): |
290 | pass |
291 |
|
292 |
|
293 | def named(cls, message: str): |
294 | return cls(f"{cls.__name__}: {message}") |
295 |
|
296 |
|
297 | # pyeapi logs a traceback for every socket error, and the post-check polls a switch that is rebooting. |
298 | logging.getLogger("pyeapi").addHandler(logging.NullHandler()) |
299 |
|
300 |
|
301 | def eapi(auth: arista_eos, cmds: list, fmt: str = "json", timeout: float = 60): |
302 | eos = node(auth, timeout) |
303 | try: |
304 | return eos.run_commands(cmds, fmt) |
305 | except pyeapi.eapilib.ConnectionError as e: |
306 | raise connection_error(auth, eos, e) |
307 |
|
308 |
|
309 | # pyeapi 1.0.4 raises ConnectionError for a socket error (kept in socket_error), an answer that isn't JSON (eAPI |
310 | # still starting, 5xx) and a 401, the only one with neither. |
311 | def connection_error(auth, eos, e): |
312 | if eos.connection.socket_error is None and e.message != "unable to connect to eAPI": |
313 | return named( |
314 | AuthFailure, f"{auth['host']}: eAPI refused the credential ({e.message})" |
315 | ) |
316 | return named(DeviceUnreachable, f"{auth['host']}: {e.message}") |
317 |
|
318 |
|
319 | # host is an address or a URL (https: |
320 | # so one is always passed: verifying, or not when the resource unticks verify_tls (EOS ships a self-signed cert). |
321 | def node(auth: arista_eos, timeout: float = 60): |
322 | url = urlsplit(auth["host"] if "://" in auth["host"] else f"https://{auth['host']}") |
323 | context = ssl.create_default_context() |
324 | if auth.get("verify_tls") is False: |
325 | context.check_hostname = False |
326 | context.verify_mode = ssl.CERT_NONE |
327 | return pyeapi.connect( |
328 | transport=url.scheme, |
329 | host=url.hostname, |
330 | port=url.port or auth.get("port") or None, |
331 | username=auth["username"], |
332 | password=auth["password"], |
333 | context=context, |
334 | timeout=timeout, |
335 | return_node=True, |
336 | ) |
337 |
|