0

Device check

by
Published today

Pre- or post-upgrade check of one switch: running version, ok|ko, checks passed and failed (flash, redundancy, interface). Throws DeviceUnreachable or AuthFailure.

Script arista_eos Verified

The script

Submitted by hugo989 Python3
Verified 3 hours ago
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://10.0.0.11:8443). pyeapi skips certificate checks unless it is given a context,
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