eos-mcp 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
eos_mcp-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: eos-mcp
3
+ Version: 0.2.0
4
+ Summary: MCP server for Arista EOS device operations via eAPI
5
+ Author: AIKAWA Shigechika
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/shigechika/eos-mcp
8
+ Project-URL: Repository, https://github.com/shigechika/eos-mcp
9
+ Project-URL: Issues, https://github.com/shigechika/eos-mcp/issues
10
+ Keywords: arista,eos,eapi,mcp,model-context-protocol,network,automation
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: System Administrators
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: System :: Networking
20
+ Classifier: Topic :: System :: Systems Administration
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: mcp>=1.0
24
+ Requires-Dist: pyeapi
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest; extra == "dev"
27
+ Requires-Dist: pytest-cov; extra == "dev"
28
+ Requires-Dist: ruff; extra == "dev"
29
+
30
+ # eos-mcp
31
+
32
+ MCP server for Arista EOS device operations via eAPI.
33
+
34
+ Exposes EOS show commands, running-config retrieval, configuration push
35
+ (via configure session with commit timer), and tech-support collection
36
+ to MCP-compatible AI assistants.
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ pip install eos-mcp
42
+ ```
43
+
44
+ ## Configuration
45
+
46
+ Copy `config.ini.example` to `~/.config/eos-mcp/config.ini` and fill in credentials:
47
+
48
+ ```ini
49
+ [DEFAULT]
50
+ username = admin
51
+ password = yourpassword
52
+ transport = https
53
+ verify = false
54
+
55
+ [switch1.example.com]
56
+ tags = main,dc1
57
+
58
+ [switch2.example.com]
59
+ tags = main,dc1
60
+ ```
61
+
62
+ Config file discovery order:
63
+ 1. `--config_path` argument
64
+ 2. `EOS_MCP_CONFIG` environment variable
65
+ 3. `./config.ini` (current directory)
66
+ 4. `~/.config/eos-mcp/config.ini`
67
+
68
+ ## Usage
69
+
70
+ ```bash
71
+ # Verify config and list devices
72
+ eos-mcp --check
73
+
74
+ # Test connectivity to a specific host
75
+ eos-mcp --check --check-host switch1.example.com
76
+
77
+ # Start MCP server (stdio transport, default)
78
+ eos-mcp
79
+ ```
80
+
81
+ ## Tools
82
+
83
+ | Tool | Description |
84
+ |---|---|
85
+ | `get_router_list` | List registered devices (optional tag filter) |
86
+ | `get_device_facts` | Return structured facts for one device (model, serial, EOS version, uptime, memory) |
87
+ | `get_device_facts_batch` | Return device facts for multiple devices in parallel |
88
+ | `get_version` | Return EOS version string (quick connectivity check) |
89
+ | `run_command` | Run a single enable-mode command on one device |
90
+ | `run_commands` | Run multiple enable-mode commands on one device |
91
+ | `run_command_batch` | Run an enable-mode command on multiple devices in parallel |
92
+ | `get_config` | Retrieve running-config |
93
+ | `get_config_diff` | Show config diff vs rollback checkpoint |
94
+ | `list_config_sessions` | List configure sessions and their state |
95
+ | `push_config` | Push config via configure session (dry_run=True by default) |
96
+ | `confirm_config_session` | Confirm a pending commit timer session |
97
+ | `abort_config_session` | Abort a pending session |
98
+ | `collect_tech_support` | Collect show tech-support output |
99
+ | `daily_brief` | Health check (environment, errdisabled, uptime) across multiple devices |
100
+
101
+ ## Requirements
102
+
103
+ - Python >= 3.10
104
+ - Arista EOS with eAPI enabled (`management api http-commands`)
105
+ - Network access to port 443 (HTTPS) on target devices
106
+
107
+ ## License
108
+
109
+ Apache-2.0
@@ -0,0 +1,80 @@
1
+ # eos-mcp
2
+
3
+ MCP server for Arista EOS device operations via eAPI.
4
+
5
+ Exposes EOS show commands, running-config retrieval, configuration push
6
+ (via configure session with commit timer), and tech-support collection
7
+ to MCP-compatible AI assistants.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ pip install eos-mcp
13
+ ```
14
+
15
+ ## Configuration
16
+
17
+ Copy `config.ini.example` to `~/.config/eos-mcp/config.ini` and fill in credentials:
18
+
19
+ ```ini
20
+ [DEFAULT]
21
+ username = admin
22
+ password = yourpassword
23
+ transport = https
24
+ verify = false
25
+
26
+ [switch1.example.com]
27
+ tags = main,dc1
28
+
29
+ [switch2.example.com]
30
+ tags = main,dc1
31
+ ```
32
+
33
+ Config file discovery order:
34
+ 1. `--config_path` argument
35
+ 2. `EOS_MCP_CONFIG` environment variable
36
+ 3. `./config.ini` (current directory)
37
+ 4. `~/.config/eos-mcp/config.ini`
38
+
39
+ ## Usage
40
+
41
+ ```bash
42
+ # Verify config and list devices
43
+ eos-mcp --check
44
+
45
+ # Test connectivity to a specific host
46
+ eos-mcp --check --check-host switch1.example.com
47
+
48
+ # Start MCP server (stdio transport, default)
49
+ eos-mcp
50
+ ```
51
+
52
+ ## Tools
53
+
54
+ | Tool | Description |
55
+ |---|---|
56
+ | `get_router_list` | List registered devices (optional tag filter) |
57
+ | `get_device_facts` | Return structured facts for one device (model, serial, EOS version, uptime, memory) |
58
+ | `get_device_facts_batch` | Return device facts for multiple devices in parallel |
59
+ | `get_version` | Return EOS version string (quick connectivity check) |
60
+ | `run_command` | Run a single enable-mode command on one device |
61
+ | `run_commands` | Run multiple enable-mode commands on one device |
62
+ | `run_command_batch` | Run an enable-mode command on multiple devices in parallel |
63
+ | `get_config` | Retrieve running-config |
64
+ | `get_config_diff` | Show config diff vs rollback checkpoint |
65
+ | `list_config_sessions` | List configure sessions and their state |
66
+ | `push_config` | Push config via configure session (dry_run=True by default) |
67
+ | `confirm_config_session` | Confirm a pending commit timer session |
68
+ | `abort_config_session` | Abort a pending session |
69
+ | `collect_tech_support` | Collect show tech-support output |
70
+ | `daily_brief` | Health check (environment, errdisabled, uptime) across multiple devices |
71
+
72
+ ## Requirements
73
+
74
+ - Python >= 3.10
75
+ - Arista EOS with eAPI enabled (`management api http-commands`)
76
+ - Network access to port 443 (HTTPS) on target devices
77
+
78
+ ## License
79
+
80
+ Apache-2.0
@@ -0,0 +1,8 @@
1
+ """eos-mcp: MCP server for Arista EOS device operations via eAPI."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("eos-mcp")
7
+ except PackageNotFoundError:
8
+ __version__ = "0.2.0"
@@ -0,0 +1,92 @@
1
+ """Allow running as: python -m eos_mcp"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+
9
+ from eos_mcp import __version__
10
+ from eos_mcp import config as cfg_mod
11
+ from eos_mcp import eapi
12
+ from eos_mcp.server import _ensure_config, mcp
13
+
14
+
15
+ def _check_config(check_host: str | None = None) -> int:
16
+ """Verify config.ini is loadable and optionally test eAPI connectivity."""
17
+ err = _ensure_config("")
18
+ if err:
19
+ print(f"Configuration error: {err}", file=sys.stderr)
20
+ return 1
21
+
22
+ cfg, path = cfg_mod.load("")
23
+ hosts = cfg_mod.get_hosts(cfg)
24
+ print(f"OK: config loaded from {path}")
25
+ print(f"Devices ({len(hosts)}): {', '.join(hosts) if hosts else '(none)'}")
26
+
27
+ if check_host is None:
28
+ return 0
29
+
30
+ if not cfg.has_section(check_host):
31
+ print(f"Error: host '{check_host}' not found in config", file=sys.stderr)
32
+ return 2
33
+
34
+ try:
35
+ creds = cfg_mod.get_creds(cfg, check_host)
36
+ node = eapi.get_node(host=check_host, **creds)
37
+ result = node.execute(["show version"])
38
+ model = result["result"][0].get("modelName", "?")
39
+ version = result["result"][0].get("version", "?")
40
+ print(f"OK: connected to {check_host} (model={model}, EOS={version})")
41
+ except Exception as e:
42
+ print(f"Connection error ({check_host}): {e}", file=sys.stderr)
43
+ return 2
44
+
45
+ return 0
46
+
47
+
48
+ def main() -> None:
49
+ """Entry point for console_scripts."""
50
+ parser = argparse.ArgumentParser(
51
+ prog="eos-mcp",
52
+ description=(
53
+ "MCP server for Arista EOS. Exposes EOS device operations "
54
+ "(show commands, config management, tech-support collection) "
55
+ "to AI assistants via eAPI."
56
+ ),
57
+ epilog=(
58
+ "Config file discovery order: --config_path argument > "
59
+ f"{cfg_mod.CONFIG_ENV_VAR} env var > ./config.ini > "
60
+ "~/.config/eos-mcp/config.ini."
61
+ ),
62
+ )
63
+ parser.add_argument("-V", "--version", action="version", version=f"%(prog)s {__version__}")
64
+ parser.add_argument(
65
+ "--check",
66
+ action="store_true",
67
+ help="Verify config.ini is loadable and list devices, then exit.",
68
+ )
69
+ parser.add_argument(
70
+ "--check-host",
71
+ metavar="HOSTNAME",
72
+ help="With --check, also open an eAPI connection to HOSTNAME.",
73
+ )
74
+ parser.add_argument(
75
+ "--transport",
76
+ choices=["stdio", "streamable-http"],
77
+ default="stdio",
78
+ help="Transport protocol (default: stdio)",
79
+ )
80
+ args = parser.parse_args()
81
+
82
+ if args.check or args.check_host:
83
+ sys.exit(_check_config(args.check_host))
84
+
85
+ try:
86
+ mcp.run(transport=args.transport)
87
+ except KeyboardInterrupt:
88
+ os._exit(0)
89
+
90
+
91
+ if __name__ == "__main__":
92
+ main()
@@ -0,0 +1,70 @@
1
+ """Configuration file loader (XDG-compliant).
2
+
3
+ Config file discovery order:
4
+ 1. explicit override argument
5
+ 2. EOS_MCP_CONFIG environment variable
6
+ 3. ./config.ini (current directory)
7
+ 4. ~/.config/eos-mcp/config.ini
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import configparser
13
+ import os
14
+ from pathlib import Path
15
+
16
+ CONFIG_ENV_VAR = "EOS_MCP_CONFIG"
17
+ _XDG_PATH = Path.home() / ".config" / "eos-mcp" / "config.ini"
18
+
19
+
20
+ def find_config_path(override: str = "") -> Path:
21
+ """Resolve config file path using discovery order."""
22
+ if override:
23
+ return Path(override)
24
+ env = os.environ.get(CONFIG_ENV_VAR, "")
25
+ if env:
26
+ return Path(env)
27
+ local = Path("config.ini")
28
+ if local.exists():
29
+ return local
30
+ return _XDG_PATH
31
+
32
+
33
+ def load(config_path: str = "") -> tuple[configparser.ConfigParser, Path]:
34
+ """Load config and return (ConfigParser, resolved_path).
35
+
36
+ Raises FileNotFoundError if the resolved path does not exist.
37
+ """
38
+ path = find_config_path(config_path)
39
+ cfg = configparser.ConfigParser()
40
+ read = cfg.read(path)
41
+ if not read:
42
+ raise FileNotFoundError(f"Config file not found: {path}")
43
+ return cfg, path
44
+
45
+
46
+ def get_hosts(cfg: configparser.ConfigParser, tags: list[str] | None = None) -> list[str]:
47
+ """Return section names (hostnames), optionally filtered by tags.
48
+
49
+ A host matches if it has *any* of the requested tags.
50
+ """
51
+ result = []
52
+ for section in cfg.sections():
53
+ if tags:
54
+ raw = cfg.get(section, "tags", fallback="")
55
+ host_tags = {t.strip() for t in raw.split(",") if t.strip()}
56
+ if not host_tags.intersection(tags):
57
+ continue
58
+ result.append(section)
59
+ return result
60
+
61
+
62
+ def get_creds(cfg: configparser.ConfigParser, host: str) -> dict[str, str | bool]:
63
+ """Return connection credentials for a host (falls back to DEFAULT)."""
64
+ defaults = cfg.defaults()
65
+ return {
66
+ "username": cfg.get(host, "username", fallback=defaults.get("username", "")),
67
+ "password": cfg.get(host, "password", fallback=defaults.get("password", "")),
68
+ "transport": cfg.get(host, "transport", fallback=defaults.get("transport", "https")),
69
+ "verify": cfg.getboolean(host, "verify", fallback=False),
70
+ }
@@ -0,0 +1,307 @@
1
+ """pyeapi client wrapper with TLS compatibility and connection cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ssl
6
+ import threading
7
+ from typing import Any
8
+
9
+ import pyeapi
10
+
11
+ # EOS 4.28.x supports TLS 1.0–1.2 with legacy cipher suites.
12
+ # Python 3.14 raised the default security level, causing
13
+ # SSLV3_ALERT_HANDSHAKE_FAILURE. Patch _create_unverified_context globally
14
+ # so pyeapi's HTTPS transport works with older EOS versions.
15
+ _orig_create_unverified_context = ssl._create_unverified_context
16
+
17
+
18
+ def _tls_compat_context(protocol=ssl.PROTOCOL_TLS_CLIENT, **kwargs):
19
+ ctx = _orig_create_unverified_context(protocol, **kwargs)
20
+ ctx.set_ciphers("DEFAULT:@SECLEVEL=0")
21
+ ctx.minimum_version = ssl.TLSVersion.TLSv1
22
+ return ctx
23
+
24
+
25
+ ssl._create_unverified_context = _tls_compat_context
26
+
27
+ # hostname -> pyeapi.client.Node
28
+ _cache: dict[str, pyeapi.client.Node] = {}
29
+ _lock = threading.Lock()
30
+
31
+
32
+ def get_node(
33
+ host: str,
34
+ username: str,
35
+ password: str,
36
+ transport: str = "https",
37
+ verify: bool = False,
38
+ ) -> pyeapi.client.Node:
39
+ """Return a cached Node, creating a new connection if needed."""
40
+ with _lock:
41
+ if host not in _cache:
42
+ _cache[host] = pyeapi.connect(
43
+ host=host,
44
+ username=username,
45
+ password=password,
46
+ transport=transport,
47
+ verify=verify,
48
+ )
49
+ return _cache[host]
50
+
51
+
52
+ def clear_cache(host: str | None = None) -> None:
53
+ """Remove cached node(s) so the next call creates a fresh connection."""
54
+ with _lock:
55
+ if host is not None:
56
+ _cache.pop(host, None)
57
+ else:
58
+ _cache.clear()
59
+
60
+
61
+ def run_show(node: pyeapi.client.Node, command: str) -> str:
62
+ """Run a single show command and return text output."""
63
+ result = node.execute([command], encoding="text")
64
+ return result["result"][0].get("output", "")
65
+
66
+
67
+ def run_shows(node: pyeapi.client.Node, commands: list[str]) -> str:
68
+ """Run multiple show commands and return labelled concatenated text."""
69
+ result = node.execute(commands, encoding="text")
70
+ parts = [
71
+ f"--- {cmd} ---\n{result['result'][i].get('output', '')}"
72
+ for i, cmd in enumerate(commands)
73
+ ]
74
+ return "\n".join(parts)
75
+
76
+
77
+ def get_running_config(node: pyeapi.client.Node) -> str:
78
+ """Return running-config as text."""
79
+ return run_show(node, "show running-config")
80
+
81
+
82
+ def _timer_str(seconds: int) -> str:
83
+ h, rem = divmod(seconds, 3600)
84
+ m, s = divmod(rem, 60)
85
+ return f"{h:02d}:{m:02d}:{s:02d}"
86
+
87
+
88
+ def push_config(
89
+ node: pyeapi.client.Node,
90
+ session_name: str,
91
+ config_lines: list[str],
92
+ dry_run: bool = True,
93
+ commit_timer: int = 300,
94
+ ) -> dict[str, Any]:
95
+ """Apply config lines via a named configure session.
96
+
97
+ All commands are sent in a single eAPI call to maintain session context.
98
+ When dry_run=True the session is aborted after showing diffs.
99
+ When dry_run=False the session is committed with a rollback timer.
100
+
101
+ Returns dict: {session_name, diffs, committed, timer_seconds}.
102
+ """
103
+ terminate = "abort" if dry_run else f"commit timer {_timer_str(commit_timer)}"
104
+ cmds = (
105
+ [f"configure session {session_name}"]
106
+ + list(config_lines)
107
+ + ["show session-config diffs", terminate]
108
+ )
109
+ result = node.execute(cmds, encoding="text")
110
+ diffs_idx = 1 + len(config_lines)
111
+ diffs = result["result"][diffs_idx].get("output", "(no diffs)")
112
+ return {
113
+ "session_name": session_name,
114
+ "diffs": diffs,
115
+ "committed": not dry_run,
116
+ "timer_seconds": 0 if dry_run else commit_timer,
117
+ }
118
+
119
+
120
+ def confirm_session(node: pyeapi.client.Node, session_name: str) -> str:
121
+ """Confirm (finalize) a pending configure session commit timer."""
122
+ node.execute([f"configure session {session_name} commit"])
123
+ return f"Session '{session_name}' confirmed and committed."
124
+
125
+
126
+ def abort_session(node: pyeapi.client.Node, session_name: str) -> str:
127
+ """Abort a pending configure session."""
128
+ node.execute([f"configure session {session_name} abort"])
129
+ return f"Session '{session_name}' aborted."
130
+
131
+
132
+ def collect_tech_support(node: pyeapi.client.Node) -> str:
133
+ """Return show tech-support output (large — may take 30+ seconds)."""
134
+ return run_show(node, "show tech-support")
135
+
136
+
137
+ def get_device_facts(node: pyeapi.client.Node) -> dict[str, Any]:
138
+ """Return structured device facts from show version + show hostname.
139
+
140
+ Keys: hostname, fqdn, model, serial, version, hardware_revision,
141
+ uptime_seconds, memory_total_kb, memory_free_kb, mac, architecture.
142
+ """
143
+ result = node.execute(["show version", "show hostname"])
144
+ v = result["result"][0]
145
+ h = result["result"][1]
146
+ return {
147
+ "hostname": h.get("hostname", ""),
148
+ "fqdn": h.get("fqdn", ""),
149
+ "model": v.get("modelName", ""),
150
+ "serial": v.get("serialNumber", ""),
151
+ "version": v.get("version", ""),
152
+ "hardware_revision": v.get("hardwareRevision", ""),
153
+ "uptime_seconds": v.get("uptime", 0),
154
+ "memory_total_kb": v.get("memTotal", 0),
155
+ "memory_free_kb": v.get("memFree", 0),
156
+ "mac": v.get("systemMacAddress", ""),
157
+ "architecture": v.get("architecture", ""),
158
+ }
159
+
160
+
161
+ def get_config_diff(node: pyeapi.client.Node, rollback_id: int = 1) -> str:
162
+ """Return config diff between running-config and a rollback checkpoint.
163
+
164
+ rollback_id=1 → diff vs most recent rollback checkpoint (default)
165
+ rollback_id=N → diff vs Nth rollback checkpoint
166
+
167
+ Note: 'show running-config diffs' (vs startup) requires EOS 4.30+.
168
+ This function uses 'show rollback config <N>' which is available on
169
+ EOS 4.28+, falling back gracefully when no rollbacks exist.
170
+ """
171
+ candidates = [
172
+ f"show rollback config {rollback_id}",
173
+ f"show running-config diffs",
174
+ ]
175
+ last_err = ""
176
+ for cmd in candidates:
177
+ try:
178
+ return run_show(node, cmd)
179
+ except Exception as e:
180
+ last_err = str(e)
181
+ return f"Config diff not available on this EOS version: {last_err}"
182
+
183
+
184
+ def list_config_sessions(node: pyeapi.client.Node) -> str:
185
+ """Return configure session list with state (show configuration sessions detail)."""
186
+ return run_show(node, "show configuration sessions detail")
187
+
188
+
189
+ def _get_environment_text(node: pyeapi.client.Node) -> str:
190
+ """Return environment status text, trying EOS 4.28+ and older syntax."""
191
+ for cmd in ("show system environment all", "show environment all"):
192
+ try:
193
+ return run_show(node, cmd)
194
+ except Exception:
195
+ continue
196
+ return ""
197
+
198
+
199
+ def check_health(node: pyeapi.client.Node) -> dict[str, Any]:
200
+ """Run health checks for daily_brief.
201
+
202
+ Checks: device facts (uptime, memory), environment (temperature/cooling/fans/PSUs),
203
+ errdisabled interfaces, and MLAG status (when active).
204
+
205
+ Returns dict: {anomalies: list[str], info: dict}.
206
+ anomalies entries are prefixed with 'CRITICAL:' or 'WARNING:'.
207
+ """
208
+ anomalies: list[str] = []
209
+ info: dict[str, Any] = {}
210
+
211
+ # Device facts (uptime + memory — no extra API call)
212
+ try:
213
+ facts = get_device_facts(node)
214
+ info["hostname"] = facts["hostname"]
215
+ info["model"] = facts["model"]
216
+ info["version"] = facts["version"]
217
+ uptime_s = int(facts["uptime_seconds"])
218
+ uptime_d, rem = divmod(uptime_s, 86400)
219
+ uptime_h, rem2 = divmod(rem, 3600)
220
+ uptime_m = rem2 // 60
221
+ if uptime_d == 0 and uptime_h == 0:
222
+ info["uptime"] = f"{uptime_m}m"
223
+ elif uptime_d == 0:
224
+ info["uptime"] = f"{uptime_h}h"
225
+ else:
226
+ info["uptime"] = f"{uptime_d}d {uptime_h}h"
227
+ if uptime_d == 0:
228
+ display = f"{uptime_m}m" if uptime_h == 0 else f"{uptime_h}h"
229
+ anomalies.append(f"WARNING: uptime {display} — recent reboot?")
230
+ total_kb = facts.get("memory_total_kb", 0)
231
+ free_kb = facts.get("memory_free_kb", 0)
232
+ if total_kb > 0:
233
+ used_pct = (total_kb - free_kb) / total_kb * 100
234
+ info["memory_pct"] = round(used_pct, 1)
235
+ if used_pct >= 90:
236
+ anomalies.append(
237
+ f"CRITICAL: memory {used_pct:.0f}% used ({free_kb // 1024} MB free)"
238
+ )
239
+ elif used_pct >= 80:
240
+ anomalies.append(
241
+ f"WARNING: memory {used_pct:.0f}% used ({free_kb // 1024} MB free)"
242
+ )
243
+ except Exception as exc:
244
+ anomalies.append(f"CRITICAL: cannot fetch facts: {exc}")
245
+
246
+ # Environment (temperature / cooling / fans / PSUs)
247
+ try:
248
+ env = _get_environment_text(node)
249
+ if env:
250
+ for _prefix in ("System temperature status", "System cooling status"):
251
+ _sline = next((l.strip() for l in env.splitlines() if l.startswith(_prefix)), None)
252
+ if _sline is not None and "Ok" not in _sline:
253
+ anomalies.append(f"CRITICAL: {_sline}")
254
+ for line in env.splitlines():
255
+ stripped = line.strip()
256
+ # Fan/PSU failure: lines starting with a digit, PowerSupply, or Fan (FanTray, Fan1/1…)
257
+ if stripped and " Fail" in stripped:
258
+ if stripped[0].isdigit() or stripped.startswith(("PowerSupply", "Fan")):
259
+ anomalies.append(f"CRITICAL: hardware Fail: {stripped}")
260
+ else:
261
+ anomalies.append("WARNING: environment status unavailable")
262
+ except Exception as exc:
263
+ anomalies.append(f"WARNING: environment check failed: {exc}")
264
+
265
+ # Errdisabled interfaces
266
+ try:
267
+ intf_text = run_show(node, "show interfaces status")
268
+ errdisabled = [
269
+ line.split()[0]
270
+ for line in intf_text.splitlines()
271
+ if "errdisabled" in line and line.strip()
272
+ ]
273
+ info["errdisabled"] = errdisabled
274
+ if errdisabled:
275
+ anomalies.append(f"WARNING: errdisabled: {', '.join(errdisabled)}")
276
+ except Exception as exc:
277
+ anomalies.append(f"WARNING: interface check failed: {exc}")
278
+
279
+ # MLAG status (ignore when disabled or not configured)
280
+ try:
281
+ mlag_text = run_show(node, "show mlag")
282
+ mlag_state = ""
283
+ mlag_neg = ""
284
+ mlag_peer = ""
285
+ for line in mlag_text.splitlines():
286
+ stripped = line.strip()
287
+ if stripped.startswith("state") and ":" in stripped:
288
+ mlag_state = stripped.split(":", 1)[1].strip().lower()
289
+ elif stripped.startswith("negotiation status") and ":" in stripped:
290
+ mlag_neg = stripped.split(":", 1)[1].strip().lower()
291
+ elif stripped.startswith("peer-link status") and ":" in stripped:
292
+ mlag_peer = stripped.split(":", 1)[1].strip().lower()
293
+ if mlag_state and mlag_state != "disabled":
294
+ info["mlag_state"] = mlag_state
295
+ info["mlag_neg"] = mlag_neg
296
+ info["mlag_peer"] = mlag_peer
297
+ if mlag_state != "active":
298
+ anomalies.append(f"CRITICAL: MLAG state={mlag_state}")
299
+ else:
300
+ if mlag_neg and mlag_neg != "connected":
301
+ anomalies.append(f"CRITICAL: MLAG negotiation={mlag_neg}")
302
+ if mlag_peer and mlag_peer != "up":
303
+ anomalies.append(f"CRITICAL: MLAG peer-link={mlag_peer}")
304
+ except Exception:
305
+ pass # MLAG not configured on this device
306
+
307
+ return {"anomalies": anomalies, "info": info}