network-aiops 0.1.0__py3-none-any.whl
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.
- mcp_server/__init__.py +0 -0
- mcp_server/_shared.py +113 -0
- mcp_server/server.py +33 -0
- mcp_server/tools/__init__.py +0 -0
- mcp_server/tools/config_ops.py +107 -0
- mcp_server/tools/facts.py +87 -0
- mcp_server/tools/netbox.py +39 -0
- network_aiops/__init__.py +11 -0
- network_aiops/cli/__init__.py +9 -0
- network_aiops/cli/_common.py +84 -0
- network_aiops/cli/_root.py +50 -0
- network_aiops/cli/config.py +111 -0
- network_aiops/cli/device.py +86 -0
- network_aiops/cli/doctor.py +21 -0
- network_aiops/cli/netbox.py +39 -0
- network_aiops/config.py +159 -0
- network_aiops/connection.py +202 -0
- network_aiops/doctor.py +76 -0
- network_aiops/governance/__init__.py +40 -0
- network_aiops/governance/audit.py +377 -0
- network_aiops/governance/budget.py +225 -0
- network_aiops/governance/decorators.py +474 -0
- network_aiops/governance/paths.py +23 -0
- network_aiops/governance/patterns.py +378 -0
- network_aiops/governance/policy.py +411 -0
- network_aiops/governance/sanitize.py +39 -0
- network_aiops/governance/undo.py +218 -0
- network_aiops/ops/__init__.py +0 -0
- network_aiops/ops/_shared.py +38 -0
- network_aiops/ops/config_ops.py +135 -0
- network_aiops/ops/facts.py +130 -0
- network_aiops/ops/netbox_ops.py +50 -0
- network_aiops-0.1.0.dist-info/METADATA +150 -0
- network_aiops-0.1.0.dist-info/RECORD +37 -0
- network_aiops-0.1.0.dist-info/WHEEL +4 -0
- network_aiops-0.1.0.dist-info/entry_points.txt +3 -0
- network_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
mcp_server/__init__.py
ADDED
|
File without changes
|
mcp_server/_shared.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Shared MCP server primitives: the FastMCP instance, manager helper,
|
|
2
|
+
error sanitisation, and the ``@tool_errors`` decorator.
|
|
3
|
+
|
|
4
|
+
Tool modules under ``mcp_server/tools/`` import ``mcp`` from here and register
|
|
5
|
+
their ``@mcp.tool()`` functions onto it. ``mcp_server/server.py`` then imports
|
|
6
|
+
those modules and runs the server.
|
|
7
|
+
|
|
8
|
+
Keep ``Optional[X]`` (never PEP 604 ``X | None``) in any FastMCP-reflected
|
|
9
|
+
tool signature — on older mcp/pydantic the union eval'd to ``types.UnionType``
|
|
10
|
+
crashes FastMCP's ``issubclass`` check.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import functools
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
|
|
20
|
+
from mcp.server.fastmcp import FastMCP
|
|
21
|
+
|
|
22
|
+
from network_aiops.config import load_config
|
|
23
|
+
from network_aiops.connection import ConnectionManager, NetworkApiError
|
|
24
|
+
from network_aiops.governance import sanitize
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_DOCTOR_HINT = "Run 'network-aiops doctor' to verify device config and reachability."
|
|
29
|
+
|
|
30
|
+
_SUPPORTED = "ios, nxos, nxos_ssh, iosxr, eos, junos"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _safe_error(exc: Exception, tool: str) -> str:
|
|
34
|
+
"""Return an agent-safe error string; log full detail server-side only."""
|
|
35
|
+
logger.error("Tool %s failed", tool, exc_info=True)
|
|
36
|
+
_passthrough = (
|
|
37
|
+
ValueError,
|
|
38
|
+
FileNotFoundError,
|
|
39
|
+
KeyError,
|
|
40
|
+
PermissionError,
|
|
41
|
+
TimeoutError,
|
|
42
|
+
ConnectionError,
|
|
43
|
+
NetworkApiError,
|
|
44
|
+
)
|
|
45
|
+
if isinstance(exc, _passthrough):
|
|
46
|
+
return sanitize(str(exc), 300)
|
|
47
|
+
return f"{type(exc).__name__}: operation failed."
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def tool_errors(shape: str = "dict") -> Callable:
|
|
51
|
+
"""Wrap a tool body in the canonical try/except → ``_safe_error`` pattern.
|
|
52
|
+
|
|
53
|
+
Place this *between* ``@governed_tool`` and the function so the audit
|
|
54
|
+
decorator and FastMCP still see the original signature.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
def decorator(func: Callable) -> Callable:
|
|
58
|
+
name = func.__name__
|
|
59
|
+
|
|
60
|
+
@functools.wraps(func)
|
|
61
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
62
|
+
try:
|
|
63
|
+
return func(*args, **kwargs)
|
|
64
|
+
except Exception as e: # noqa: BLE001 — sanitised below
|
|
65
|
+
msg = _safe_error(e, name)
|
|
66
|
+
if shape == "list":
|
|
67
|
+
return [{"error": msg, "hint": _DOCTOR_HINT}]
|
|
68
|
+
if shape == "str":
|
|
69
|
+
return f"Error: {msg} {_DOCTOR_HINT}"
|
|
70
|
+
return {"error": msg, "hint": _DOCTOR_HINT}
|
|
71
|
+
|
|
72
|
+
return wrapper
|
|
73
|
+
|
|
74
|
+
return decorator
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
mcp = FastMCP(
|
|
78
|
+
"network-aiops",
|
|
79
|
+
instructions=(
|
|
80
|
+
"Governed multi-vendor network device operations (preview) over NAPALM. "
|
|
81
|
+
f"Officially supported drivers: {_SUPPORTED} (Cisco IOS/IOS-XE, Nexus "
|
|
82
|
+
"NX-OS, IOS-XR, Arista EOS, Juniper Junos). Read tools: device facts, "
|
|
83
|
+
"interfaces, IP addresses, BGP/LLDP neighbors, ARP table, config backup, "
|
|
84
|
+
"and config diff (dry-run). Write tools: config merge, config replace, "
|
|
85
|
+
"config rollback. An optional NetBox block adds source-of-truth device "
|
|
86
|
+
"lookups. A 'target' selects a device from config. Every tool runs "
|
|
87
|
+
"through the network-aiops governance harness (audit / budget / risk-tier "
|
|
88
|
+
"/ undo). Need another platform (Nokia SR OS, Huawei VRP) or action? "
|
|
89
|
+
"Request it via a GitHub issue or PR."
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
_conn_mgr: Optional[ConnectionManager] = None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _manager() -> ConnectionManager:
|
|
97
|
+
"""Return the connection manager, lazily initialising it from config."""
|
|
98
|
+
global _conn_mgr # noqa: PLW0603
|
|
99
|
+
if _conn_mgr is None:
|
|
100
|
+
config_path_str = os.environ.get("NETWORK_AIOPS_CONFIG")
|
|
101
|
+
config_path = Path(config_path_str) if config_path_str else None
|
|
102
|
+
_conn_mgr = ConnectionManager(load_config(config_path))
|
|
103
|
+
return _conn_mgr
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _target(name: Optional[str] = None) -> Any:
|
|
107
|
+
"""Resolve a device target by name (or the default device)."""
|
|
108
|
+
return _manager().target(name)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _netbox() -> Any:
|
|
112
|
+
"""Return a NetBox client (raises a teaching NetworkApiError if unconfigured)."""
|
|
113
|
+
return _manager().netbox()
|
mcp_server/server.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""MCP server wrapping network-aiops operations (stdio transport).
|
|
2
|
+
|
|
3
|
+
Thin adapter layer: each ``@mcp.tool()`` function (in ``mcp_server/tools/``)
|
|
4
|
+
delegates to the ``network_aiops`` ops package and is wrapped with the
|
|
5
|
+
network-aiops ``@governed_tool`` harness (audit / budget / undo / risk-tier).
|
|
6
|
+
|
|
7
|
+
Standalone, self-governed network device operations (preview) over NAPALM —
|
|
8
|
+
Cisco IOS/IOS-XE, Nexus NX-OS, IOS-XR, Arista EOS, Juniper Junos, plus optional
|
|
9
|
+
NetBox source-of-truth.
|
|
10
|
+
|
|
11
|
+
Source: https://github.com/AIops-tools/Network-AIops
|
|
12
|
+
License: MIT
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
from mcp_server._shared import _safe_error, mcp, tool_errors
|
|
18
|
+
|
|
19
|
+
# Importing the tool modules registers every @mcp.tool() onto the shared
|
|
20
|
+
# `mcp` instance. Order does not matter; each module is self-contained.
|
|
21
|
+
from mcp_server.tools import ( # noqa: F401 — side effects
|
|
22
|
+
config_ops,
|
|
23
|
+
facts,
|
|
24
|
+
netbox,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__all__ = ["mcp", "main", "_safe_error", "tool_errors"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> None:
|
|
31
|
+
"""Run the MCP server over stdio."""
|
|
32
|
+
logging.basicConfig(level=logging.INFO)
|
|
33
|
+
mcp.run(transport="stdio")
|
|
File without changes
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Config MCP tools: backup + diff (read/dry-run), merge + replace + rollback (write).
|
|
2
|
+
|
|
3
|
+
Every tool is wrapped with ``@governed_tool`` (the network-aiops harness):
|
|
4
|
+
policy pre-check, budget/runaway guard, risk-tier gate, audit logging to
|
|
5
|
+
~/.network-aiops/audit.db, and undo-token recording. ``config_merge`` and
|
|
6
|
+
``config_replace`` capture the pre-change running config and pass an ``undo=``
|
|
7
|
+
lambda so the harness records a ``config_replace``-to-backup reversal (the
|
|
8
|
+
device must support config replace for the undo to apply). ``config_rollback``
|
|
9
|
+
records no undo.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from mcp_server._shared import _target, mcp, tool_errors
|
|
15
|
+
from network_aiops.governance import governed_tool
|
|
16
|
+
from network_aiops.ops import config_ops as ops
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _restore_undo(params: dict, result) -> Optional[dict]:
|
|
20
|
+
"""Build the inverse of a committed change: replace config back to the backup."""
|
|
21
|
+
if not isinstance(result, dict) or "backup" not in result:
|
|
22
|
+
return None
|
|
23
|
+
return {
|
|
24
|
+
"tool": "config_replace",
|
|
25
|
+
"params": {"target": params.get("target"), "config_text": result["backup"]},
|
|
26
|
+
"skill": "network-aiops",
|
|
27
|
+
"note": (
|
|
28
|
+
"Inverse: restore the captured pre-change running config via "
|
|
29
|
+
"config_replace. The device must support config replace."
|
|
30
|
+
),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@mcp.tool()
|
|
35
|
+
@governed_tool(risk_level="low")
|
|
36
|
+
@tool_errors("dict")
|
|
37
|
+
def config_backup(target: Optional[str] = None) -> dict:
|
|
38
|
+
"""[READ] Return the device running config (a note explains how to save it).
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
target: Device name from config; omit to use the default device.
|
|
42
|
+
"""
|
|
43
|
+
return ops.config_backup(_target(target))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@mcp.tool()
|
|
47
|
+
@governed_tool(risk_level="low")
|
|
48
|
+
@tool_errors("dict")
|
|
49
|
+
def config_diff(
|
|
50
|
+
config_text: str, replace: bool = False, target: Optional[str] = None
|
|
51
|
+
) -> dict:
|
|
52
|
+
"""[READ] DRY-RUN: stage a candidate, return the diff, then discard it.
|
|
53
|
+
|
|
54
|
+
Nothing is committed. This is the dry-run primitive for previewing a change.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
config_text: The configuration snippet (merge) or full config (replace).
|
|
58
|
+
replace: True to diff as a full-config replacement; False (default) to merge.
|
|
59
|
+
target: Device name from config.
|
|
60
|
+
"""
|
|
61
|
+
return ops.config_diff(_target(target), config_text, replace=replace)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@mcp.tool()
|
|
65
|
+
@governed_tool(risk_level="medium", undo=_restore_undo)
|
|
66
|
+
@tool_errors("dict")
|
|
67
|
+
def config_merge(config_text: str, target: Optional[str] = None) -> dict:
|
|
68
|
+
"""[WRITE] Merge a config snippet and commit. Captures running config for undo.
|
|
69
|
+
|
|
70
|
+
Returns ``diff`` (what changed) and ``backup`` (pre-change running config).
|
|
71
|
+
The recorded undo restores ``backup`` via config_replace.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
config_text: The configuration snippet to merge.
|
|
75
|
+
target: Device name from config.
|
|
76
|
+
"""
|
|
77
|
+
return ops.config_merge(_target(target), config_text)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@mcp.tool()
|
|
81
|
+
@governed_tool(risk_level="high", undo=_restore_undo)
|
|
82
|
+
@tool_errors("dict")
|
|
83
|
+
def config_replace(config_text: str, target: Optional[str] = None) -> dict:
|
|
84
|
+
"""[WRITE] Replace the full config and commit. HIGH RISK. Captures running for undo.
|
|
85
|
+
|
|
86
|
+
Returns ``diff`` and ``backup``. The recorded undo replaces the config back
|
|
87
|
+
to ``backup`` (the device must support config replace).
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
config_text: The full replacement configuration.
|
|
91
|
+
target: Device name from config.
|
|
92
|
+
"""
|
|
93
|
+
return ops.config_replace(_target(target), config_text)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@mcp.tool()
|
|
97
|
+
@governed_tool(risk_level="medium")
|
|
98
|
+
@tool_errors("dict")
|
|
99
|
+
def config_rollback(target: Optional[str] = None) -> dict:
|
|
100
|
+
"""[WRITE] Revert the last committed change via NAPALM rollback(). No undo.
|
|
101
|
+
|
|
102
|
+
Device support varies (rollback depth is platform-dependent).
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
target: Device name from config.
|
|
106
|
+
"""
|
|
107
|
+
return ops.config_rollback(_target(target))
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Read-only device facts MCP tools (NAPALM getters).
|
|
2
|
+
|
|
3
|
+
Every tool is wrapped with ``@governed_tool`` (the network-aiops harness):
|
|
4
|
+
policy pre-check, budget/runaway guard, graduated-autonomy risk-tier gate, and
|
|
5
|
+
audit logging to ~/.network-aiops/audit.db. These are all READ tools (no undo).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from mcp_server._shared import _target, mcp, tool_errors
|
|
11
|
+
from network_aiops.governance import governed_tool
|
|
12
|
+
from network_aiops.ops import facts as ops
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@mcp.tool()
|
|
16
|
+
@governed_tool(risk_level="low")
|
|
17
|
+
@tool_errors("dict")
|
|
18
|
+
def device_facts(target: Optional[str] = None) -> dict:
|
|
19
|
+
"""[READ] Core device facts: hostname, vendor, model, OS version, serial, uptime.
|
|
20
|
+
|
|
21
|
+
Also returns the interface name list. Use get_interfaces for per-interface
|
|
22
|
+
state/speed.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
target: Device name from config; omit to use the default device.
|
|
26
|
+
"""
|
|
27
|
+
return ops.device_facts(_target(target))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@mcp.tool()
|
|
31
|
+
@governed_tool(risk_level="low")
|
|
32
|
+
@tool_errors("list")
|
|
33
|
+
def get_interfaces(target: Optional[str] = None) -> list:
|
|
34
|
+
"""[READ] Interfaces with up/down state, enabled flag, speed, and description.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
target: Device name from config.
|
|
38
|
+
"""
|
|
39
|
+
return ops.get_interfaces(_target(target))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@mcp.tool()
|
|
43
|
+
@governed_tool(risk_level="low")
|
|
44
|
+
@tool_errors("list")
|
|
45
|
+
def get_interfaces_ip(target: Optional[str] = None) -> list:
|
|
46
|
+
"""[READ] Per-interface IPv4/IPv6 addresses and prefix lengths.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
target: Device name from config.
|
|
50
|
+
"""
|
|
51
|
+
return ops.get_interfaces_ip(_target(target))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@mcp.tool()
|
|
55
|
+
@governed_tool(risk_level="low")
|
|
56
|
+
@tool_errors("list")
|
|
57
|
+
def get_bgp_neighbors(target: Optional[str] = None) -> list:
|
|
58
|
+
"""[READ] BGP neighbors per VRF: peer, remote AS, up state, prefix counts.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
target: Device name from config.
|
|
62
|
+
"""
|
|
63
|
+
return ops.get_bgp_neighbors(_target(target))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@mcp.tool()
|
|
67
|
+
@governed_tool(risk_level="low")
|
|
68
|
+
@tool_errors("list")
|
|
69
|
+
def get_lldp_neighbors(target: Optional[str] = None) -> list:
|
|
70
|
+
"""[READ] LLDP neighbors: local port, remote hostname, remote port.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
target: Device name from config.
|
|
74
|
+
"""
|
|
75
|
+
return ops.get_lldp_neighbors(_target(target))
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@mcp.tool()
|
|
79
|
+
@governed_tool(risk_level="low")
|
|
80
|
+
@tool_errors("list")
|
|
81
|
+
def get_arp_table(target: Optional[str] = None) -> list:
|
|
82
|
+
"""[READ] ARP table entries: interface, IP, MAC, age.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
target: Device name from config.
|
|
86
|
+
"""
|
|
87
|
+
return ops.get_arp_table(_target(target))
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""NetBox source-of-truth MCP tools (read-only, optional).
|
|
2
|
+
|
|
3
|
+
Degrade gracefully: a clear ``NetworkApiError`` (surfaced via ``tool_errors``)
|
|
4
|
+
when NetBox is not configured, instead of an opaque traceback.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Optional
|
|
8
|
+
|
|
9
|
+
from mcp_server._shared import _netbox, mcp, tool_errors
|
|
10
|
+
from network_aiops.governance import governed_tool
|
|
11
|
+
from network_aiops.ops import netbox_ops as ops
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@mcp.tool()
|
|
15
|
+
@governed_tool(risk_level="low")
|
|
16
|
+
@tool_errors("list")
|
|
17
|
+
def netbox_list_devices(name: Optional[str] = None, limit: int = 50) -> list:
|
|
18
|
+
"""[READ] List NetBox devices (name, role, site, status, primary IP).
|
|
19
|
+
|
|
20
|
+
Requires a configured NetBox block. Use this to confirm intended state
|
|
21
|
+
before pushing config to a device.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
name: Optional name filter (contains match).
|
|
25
|
+
limit: Maximum devices to return (default 50).
|
|
26
|
+
"""
|
|
27
|
+
return ops.netbox_list_devices(_netbox(), limit=limit, name=name)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@mcp.tool()
|
|
31
|
+
@governed_tool(risk_level="low")
|
|
32
|
+
@tool_errors("dict")
|
|
33
|
+
def netbox_get_device(name: str) -> dict:
|
|
34
|
+
"""[READ] Return a single NetBox device by exact name.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
name: Exact NetBox device name (see netbox_list_devices).
|
|
38
|
+
"""
|
|
39
|
+
return ops.netbox_get_device(_netbox(), name)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""network-aiops — governed multi-vendor network device operations for AI agents.
|
|
2
|
+
|
|
3
|
+
Standalone and self-contained: the governance harness (audit, token budget,
|
|
4
|
+
undo-token recording, graduated risk tiers, prompt-injection sanitize) is
|
|
5
|
+
bundled under ``network_aiops.governance`` — this package has no external
|
|
6
|
+
skill-family dependency. Devices are reached over NAPALM (Cisco IOS/IOS-XE,
|
|
7
|
+
Nexus NX-OS, IOS-XR, Arista EOS, Juniper Junos); an optional NetBox block adds
|
|
8
|
+
source-of-truth lookups. Preview: not yet full-coverage.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Shared helpers for network-aiops CLI sub-modules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated, Any
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
# ─── Shared Option types ───────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
TargetOption = Annotated[
|
|
18
|
+
str | None, typer.Option("--target", "-t", help="Device name from config")
|
|
19
|
+
]
|
|
20
|
+
DryRunOption = Annotated[
|
|
21
|
+
bool, typer.Option("--dry-run", help="Preview the diff without committing")
|
|
22
|
+
]
|
|
23
|
+
OutputOption = Annotated[
|
|
24
|
+
Path | None, typer.Option("--output", "-o", help="Write output to a file")
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _cli_error_types() -> tuple[type[BaseException], ...]:
|
|
29
|
+
"""Exceptions translated to a one-line teaching error instead of a traceback."""
|
|
30
|
+
from network_aiops.connection import NetworkApiError
|
|
31
|
+
|
|
32
|
+
return (NetworkApiError, KeyError, OSError, ValueError)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cli_errors(fn: Callable) -> Callable:
|
|
36
|
+
"""Translate known exceptions into one red line + exit code 1."""
|
|
37
|
+
|
|
38
|
+
@functools.wraps(fn)
|
|
39
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
40
|
+
try:
|
|
41
|
+
return fn(*args, **kwargs)
|
|
42
|
+
except (typer.Exit, typer.Abort):
|
|
43
|
+
raise
|
|
44
|
+
except _cli_error_types() as e:
|
|
45
|
+
message = str(e)
|
|
46
|
+
if isinstance(e, KeyError):
|
|
47
|
+
message = f"Missing required key: {message}"
|
|
48
|
+
console.print(f"[red]Error: {message}[/]")
|
|
49
|
+
raise typer.Exit(1) from e
|
|
50
|
+
|
|
51
|
+
return wrapper
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_manager(config_path: Path | None = None):
|
|
55
|
+
"""Return a ConnectionManager built from config."""
|
|
56
|
+
from network_aiops.config import load_config
|
|
57
|
+
from network_aiops.connection import ConnectionManager
|
|
58
|
+
|
|
59
|
+
return ConnectionManager(load_config(config_path))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def read_config_text(path: Path) -> str:
|
|
63
|
+
"""Read a config-snippet file for merge/replace/diff."""
|
|
64
|
+
return path.read_text()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def dry_run_print(*, operation: str, detail: str, parameters: dict | None = None) -> None:
|
|
68
|
+
"""Print a dry-run preview header (the diff is printed by the caller)."""
|
|
69
|
+
console.print("\n[bold magenta][DRY-RUN] No changes will be committed.[/]")
|
|
70
|
+
console.print(f"[magenta] Operation: {operation}[/]")
|
|
71
|
+
console.print(f"[magenta] Detail: {detail}[/]")
|
|
72
|
+
for k, v in (parameters or {}).items():
|
|
73
|
+
console.print(f"[magenta] Param: {k} = {v}[/]")
|
|
74
|
+
console.print("[magenta] Run without --dry-run to commit.[/]\n")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def double_confirm(action: str, resource: str) -> None:
|
|
78
|
+
"""Require two confirmations for a destructive operation."""
|
|
79
|
+
console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
|
|
80
|
+
typer.confirm(f"Confirm 1/2: {action} '{resource}'?", abort=True)
|
|
81
|
+
typer.confirm(
|
|
82
|
+
f"Confirm 2/2: really {action} '{resource}'? This may be disruptive.",
|
|
83
|
+
abort=True,
|
|
84
|
+
)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Top-level Typer app: assembles sub-apps and top-level commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
|
|
7
|
+
from network_aiops.cli.config import config_app
|
|
8
|
+
from network_aiops.cli.device import device_app
|
|
9
|
+
from network_aiops.cli.doctor import doctor_cmd
|
|
10
|
+
from network_aiops.cli.netbox import netbox_app
|
|
11
|
+
|
|
12
|
+
app = typer.Typer(
|
|
13
|
+
name="network-aiops",
|
|
14
|
+
help="Governed multi-vendor network device operations for AI agents (NAPALM).",
|
|
15
|
+
no_args_is_help=True,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
app.add_typer(device_app, name="device")
|
|
19
|
+
app.add_typer(config_app, name="config")
|
|
20
|
+
app.add_typer(netbox_app, name="netbox")
|
|
21
|
+
app.command("doctor")(doctor_cmd)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("mcp")
|
|
25
|
+
def mcp_cmd() -> None:
|
|
26
|
+
"""Start the MCP server (stdio transport).
|
|
27
|
+
|
|
28
|
+
Single-command entry point for MCP clients (does not go through uvx/PyPI
|
|
29
|
+
resolution at launch):
|
|
30
|
+
network-aiops mcp
|
|
31
|
+
"""
|
|
32
|
+
import sys
|
|
33
|
+
|
|
34
|
+
if sys.version_info < (3, 11):
|
|
35
|
+
typer.echo(
|
|
36
|
+
f"ERROR: network-aiops requires Python >= 3.11 "
|
|
37
|
+
f"(got {sys.version_info.major}.{sys.version_info.minor}).\n"
|
|
38
|
+
f"Fix: uv python install 3.12 && "
|
|
39
|
+
f"uv tool install --python 3.12 --force network-aiops",
|
|
40
|
+
err=True,
|
|
41
|
+
)
|
|
42
|
+
raise typer.Exit(2)
|
|
43
|
+
|
|
44
|
+
from mcp_server.server import main as _mcp_main
|
|
45
|
+
|
|
46
|
+
_mcp_main()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
app()
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""``network-aiops config ...`` sub-commands (backup / diff / merge / replace / rollback)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from network_aiops.cli._common import (
|
|
11
|
+
DryRunOption,
|
|
12
|
+
OutputOption,
|
|
13
|
+
TargetOption,
|
|
14
|
+
cli_errors,
|
|
15
|
+
double_confirm,
|
|
16
|
+
dry_run_print,
|
|
17
|
+
get_manager,
|
|
18
|
+
read_config_text,
|
|
19
|
+
)
|
|
20
|
+
from network_aiops.ops import config_ops
|
|
21
|
+
|
|
22
|
+
config_app = typer.Typer(help="Device configuration operations.", no_args_is_help=True)
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _resolve(target: str | None):
|
|
27
|
+
return get_manager().target(target)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@config_app.command("backup")
|
|
31
|
+
@cli_errors
|
|
32
|
+
def config_backup_cmd(target: TargetOption = None, output: OutputOption = None) -> None:
|
|
33
|
+
"""Fetch the running config (optionally save it to a file with -o)."""
|
|
34
|
+
result = config_ops.config_backup(_resolve(target))
|
|
35
|
+
if output is not None:
|
|
36
|
+
Path(output).write_text(result["config"])
|
|
37
|
+
console.print(f"[green]Saved running config of {result['name']} -> {output}[/]")
|
|
38
|
+
else:
|
|
39
|
+
console.print(result["config"])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@config_app.command("diff")
|
|
43
|
+
@cli_errors
|
|
44
|
+
def config_diff_cmd(
|
|
45
|
+
config_file: Path,
|
|
46
|
+
target: TargetOption = None,
|
|
47
|
+
replace: bool = typer.Option(False, "--replace", help="Diff as a full replace"),
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Dry-run: show the diff a config file would produce (nothing is committed)."""
|
|
50
|
+
text = read_config_text(config_file)
|
|
51
|
+
result = config_ops.config_diff(_resolve(target), text, replace=replace)
|
|
52
|
+
console.print(f"[bold]Diff ({result['mode']}, not committed):[/]")
|
|
53
|
+
console.print(result["diff"] or "[dim](no changes)[/]")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@config_app.command("merge")
|
|
57
|
+
@cli_errors
|
|
58
|
+
def config_merge_cmd(
|
|
59
|
+
config_file: Path,
|
|
60
|
+
target: TargetOption = None,
|
|
61
|
+
dry_run: DryRunOption = False,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Merge a config snippet and commit (double confirm; --dry-run shows the diff)."""
|
|
64
|
+
text = read_config_text(config_file)
|
|
65
|
+
tgt = _resolve(target)
|
|
66
|
+
if dry_run:
|
|
67
|
+
dry_run_print(operation="config_merge", detail=f"merge into {tgt.name}")
|
|
68
|
+
result = config_ops.config_diff(tgt, text, replace=False)
|
|
69
|
+
console.print(result["diff"] or "[dim](no changes)[/]")
|
|
70
|
+
return
|
|
71
|
+
double_confirm("merge config into", tgt.name)
|
|
72
|
+
result = config_ops.config_merge(tgt, text)
|
|
73
|
+
console.print(f"[green]Committed merge to {result['name']}[/]")
|
|
74
|
+
console.print(result["diff"] or "[dim](no changes)[/]")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@config_app.command("replace")
|
|
78
|
+
@cli_errors
|
|
79
|
+
def config_replace_cmd(
|
|
80
|
+
config_file: Path,
|
|
81
|
+
target: TargetOption = None,
|
|
82
|
+
dry_run: DryRunOption = False,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Replace the full config and commit (HIGH RISK — double confirm; --dry-run shows diff)."""
|
|
85
|
+
text = read_config_text(config_file)
|
|
86
|
+
tgt = _resolve(target)
|
|
87
|
+
if dry_run:
|
|
88
|
+
dry_run_print(operation="config_replace", detail=f"replace config of {tgt.name}")
|
|
89
|
+
result = config_ops.config_diff(tgt, text, replace=True)
|
|
90
|
+
console.print(result["diff"] or "[dim](no changes)[/]")
|
|
91
|
+
return
|
|
92
|
+
double_confirm("REPLACE config of", tgt.name)
|
|
93
|
+
result = config_ops.config_replace(tgt, text)
|
|
94
|
+
console.print(f"[green]Committed replace to {result['name']}[/]")
|
|
95
|
+
console.print(result["diff"] or "[dim](no changes)[/]")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@config_app.command("rollback")
|
|
99
|
+
@cli_errors
|
|
100
|
+
def config_rollback_cmd(
|
|
101
|
+
target: TargetOption = None,
|
|
102
|
+
dry_run: DryRunOption = False,
|
|
103
|
+
) -> None:
|
|
104
|
+
"""Revert the last committed change (double confirm; device support varies)."""
|
|
105
|
+
tgt = _resolve(target)
|
|
106
|
+
if dry_run:
|
|
107
|
+
dry_run_print(operation="config_rollback", detail=f"rollback {tgt.name}")
|
|
108
|
+
return
|
|
109
|
+
double_confirm("rollback last commit on", tgt.name)
|
|
110
|
+
config_ops.config_rollback(tgt)
|
|
111
|
+
console.print(f"[green]Rolled back the last commit on {tgt.name}[/]")
|