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 +109 -0
- eos_mcp-0.2.0/README.md +80 -0
- eos_mcp-0.2.0/eos_mcp/__init__.py +8 -0
- eos_mcp-0.2.0/eos_mcp/__main__.py +92 -0
- eos_mcp-0.2.0/eos_mcp/config.py +70 -0
- eos_mcp-0.2.0/eos_mcp/eapi.py +307 -0
- eos_mcp-0.2.0/eos_mcp/server.py +430 -0
- eos_mcp-0.2.0/eos_mcp.egg-info/PKG-INFO +109 -0
- eos_mcp-0.2.0/eos_mcp.egg-info/SOURCES.txt +16 -0
- eos_mcp-0.2.0/eos_mcp.egg-info/dependency_links.txt +1 -0
- eos_mcp-0.2.0/eos_mcp.egg-info/entry_points.txt +2 -0
- eos_mcp-0.2.0/eos_mcp.egg-info/requires.txt +7 -0
- eos_mcp-0.2.0/eos_mcp.egg-info/top_level.txt +1 -0
- eos_mcp-0.2.0/pyproject.toml +59 -0
- eos_mcp-0.2.0/setup.cfg +4 -0
- eos_mcp-0.2.0/tests/test_config.py +153 -0
- eos_mcp-0.2.0/tests/test_eapi.py +383 -0
- eos_mcp-0.2.0/tests/test_server.py +81 -0
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
|
eos_mcp-0.2.0/README.md
ADDED
|
@@ -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,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}
|