mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 01:40:51 +08:00
MCP: add bizMonitor server, align ops/topology tools, harden CLI exec
- New packages/netx-bizmonitor-mcp: 8 read-only cutover/biz-state tools mirroring the netxops bizMonitor group. - Align netx-mcp (15) and netx-topology-mcp (16) with backend HTTP contracts; surface partial batch failures as tool errors. - CLI hard gate: single-line show/display only; exec job IDs, path containment, owner and ne:exec checks. - Cisco IOSv collection: stable prompt/echo handling over CR. - Remove legacy netx_api/mcp and mcp_server.py; add mcp.all.json; sync skills and docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d8da7acb7d
commit
9085cac742
63 changed files with 3251 additions and 1092 deletions
264
scripts/seed_mcp_acceptance.py
Normal file
264
scripts/seed_mcp_acceptance.py
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
"""离线生成验收样例/非现场故障;评价结论必须来自真实 netx 业务逻辑。
|
||||
|
||||
用法(在 worktree 根目录,使用已有 netx Python 环境):
|
||||
python scripts/seed_mcp_acceptance.py --database <绝对路径>/artifacts/acceptance.sqlite \
|
||||
--metadata <绝对路径>/artifacts/acceptance.json
|
||||
|
||||
只接受本工人/组长 worktree artifacts 下带 acceptance 的新文件。
|
||||
不启动服务、不导入应用入口、不使用网络、不连接应用数据库。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
ALLOWED_WORKTREES = frozenset({"netx-mcp-bizmonitor-codex", "netx-mcp-lead"})
|
||||
DECLARATION = "验收样例/非现场故障:离线合成接口采集输入,真实执行 netx 业务评价;无真实设备或凭据。"
|
||||
PREFIX = "acceptance-"
|
||||
|
||||
|
||||
def artifact_path(raw: str, *, metadata: bool = False) -> Path:
|
||||
if ROOT.parent.name != "agent-wt" or ROOT.name not in ALLOWED_WORKTREES:
|
||||
raise ValueError("脚本必须位于允许的 agent-wt worktree 内")
|
||||
path = Path(raw)
|
||||
if not path.is_absolute() or "://" in raw:
|
||||
raise ValueError("必须指定 SQLite 文件绝对路径,不能使用数据库 URL")
|
||||
path = path.resolve()
|
||||
allowed = []
|
||||
for name in ALLOWED_WORKTREES:
|
||||
worktree = ROOT.parent / name
|
||||
base = worktree / "artifacts"
|
||||
# Reparse points/symlinks must not turn the allowlist into an external directory.
|
||||
if worktree.resolve() != worktree or base.resolve() != base:
|
||||
continue
|
||||
allowed.append(base)
|
||||
if not any(path.is_relative_to(base) for base in allowed):
|
||||
raise ValueError("仅允许本工人/组长 worktree 的 artifacts 目录")
|
||||
suffixes = {".json"} if metadata else {".sqlite", ".sqlite3", ".db"}
|
||||
if "acceptance" not in path.name.lower() or path.suffix.lower() not in suffixes:
|
||||
raise ValueError("文件名必须含 acceptance,扩展名须为 SQLite 文件或元数据 JSON")
|
||||
if path.exists():
|
||||
raise ValueError("目标已存在,拒绝覆盖;请使用新的验收文件名")
|
||||
return path
|
||||
|
||||
|
||||
def _offline_audit(event: str, _args: tuple[Any, ...]) -> None:
|
||||
if event in {"socket.connect", "socket.getaddrinfo", "socket.bind"}:
|
||||
raise RuntimeError("离线样例脚本禁止网络/监听端口")
|
||||
|
||||
|
||||
def seed(database: Path) -> dict[str, Any]:
|
||||
# Environment must precede imports; explicit sys.path wins over editable main repo.
|
||||
if any(name == "netx_api" or name.startswith("netx_api.") for name in sys.modules):
|
||||
raise RuntimeError("必须通过独立 Python 进程运行,不能复用已导入 netx_api 的进程")
|
||||
url = "sqlite:///" + database.as_posix()
|
||||
os.environ["NETX_DATABASE_URL"] = url
|
||||
os.environ["NETX_CREDENTIAL_SECRET_FILE"] = str(database.with_suffix(".unused-secret"))
|
||||
for setting in ("NE_COLLECT_SCHEDULER_ENABLED", "CONFIG_SYNC_SCHEDULER_ENABLED",
|
||||
"LLDP_COLLECT_SCHEDULER_ENABLED", "FABRIC_RECONCILE_SCHEDULER_ENABLED",
|
||||
"PORT_TRAFFIC_SCHEDULER_ENABLED", "BIZ_STATE_SCHEDULER_ENABLED",
|
||||
"UME_KEEPALIVE_ENABLED", "UME_ALARM_WS_ENABLED",
|
||||
"UME_SYNC_ALARMS_CURRENT_ENABLED", "UME_SYNC_INVENTORY_AUTO_ENABLED",
|
||||
"UME_SYNC_TOPOLOGY_AUTO_ENABLED"):
|
||||
os.environ["NETX_" + setting] = "false"
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.addaudithook(_offline_audit)
|
||||
|
||||
import netx_api
|
||||
from sqlalchemy.engine import make_url
|
||||
from netx_api.db import Base, SessionLocal, engine
|
||||
from netx_api.biz_migration import service as mig
|
||||
from netx_api.biz_state import service as biz
|
||||
from netx_api.models import (
|
||||
BizCompareTemplate, BizMigrationProject, BizMonitorTemplate,
|
||||
BizPortMapping, BizPortMappingRow, BizStateBatch, BizStateBatchCommand,
|
||||
BizStateMetricRow, BizStateTask, BizStateTaskItem,
|
||||
)
|
||||
|
||||
if Path(netx_api.__file__).resolve().parent != ROOT / "netx_api":
|
||||
raise RuntimeError("netx_api 未从当前 worktree 导入,拒绝运行")
|
||||
if engine.url != make_url(url):
|
||||
raise RuntimeError("数据库配置未指向隔离 SQLite 文件,拒绝运行")
|
||||
Base.metadata.create_all(engine)
|
||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
ids = {
|
||||
"project_id": PREFIX + "project",
|
||||
"old_portrait_task_id": PREFIX + "old-portrait",
|
||||
"new_portrait_task_id": PREFIX + "new-portrait",
|
||||
"old_current_task_id": PREFIX + "old-current",
|
||||
"new_current_task_id": PREFIX + "new-current",
|
||||
}
|
||||
batch_ids: dict[str, str] = {}
|
||||
command_ids: dict[str, str] = {}
|
||||
try:
|
||||
with SessionLocal() as db:
|
||||
sheet = {"sheet_id": "interface_brief", "title": "验收样例接口",
|
||||
"metric_id": "interface_brief", "key_fields": ["interface"],
|
||||
"iface_fields": ["interface"], "compare_fields": ["admin", "phy", "prot"],
|
||||
"row_filters": [], "field_rules": []}
|
||||
db.add(BizCompareTemplate(id=PREFIX + "compare", name="验收样例/非现场故障-接口对比",
|
||||
metric_id="interface_brief", key_fields=["interface"],
|
||||
iface_fields=["interface"], compare_fields=["admin", "phy", "prot"],
|
||||
metrics_json=[sheet], iface_normalize_json=[{"from": "GE", "to": "gei"}]))
|
||||
db.add(BizMonitorTemplate(id=PREFIX + "monitor", name="验收样例/非现场故障-接口监控",
|
||||
compare_template_id=PREFIX + "compare",
|
||||
collect_metric_ids_json=["interface_brief"],
|
||||
defaults_json={"out_of_expect": "strict"}, sheet_overrides_json=[{
|
||||
"metric_id": "interface_brief", "sheet_id": "interface_brief",
|
||||
"status_fields": ["admin", "phy", "prot"],
|
||||
"down_values": ["down"], "up_values": ["up"],
|
||||
"success": [{"old": ["removed", "down"], "new": ["added", "up", "unchanged"]}],
|
||||
"anomaly": [],
|
||||
}]))
|
||||
db.add(BizPortMapping(id=PREFIX + "mapping", name="验收样例端口映射", note=DECLARATION))
|
||||
db.add(BizPortMappingRow(id=PREFIX + "mapping-row", mapping_id=PREFIX + "mapping",
|
||||
before_if="gei-old", after_if="xgei-new"))
|
||||
for side in ("old", "new"):
|
||||
for mode in ("portrait", "current"):
|
||||
tid = ids[f"{side}_{mode}_task_id"]
|
||||
task = BizStateTask(
|
||||
id=tid, source="import", ne_id=PREFIX + side + "-offline",
|
||||
ne_name=f"验收样例-{'旧' if side == 'old' else '新'}侧(非现场)", ne_ip="",
|
||||
vendor="zte", device_type="router", note=DECLARATION,
|
||||
purpose="portrait" if mode == "portrait" else "cutover_hf",
|
||||
status="draft", collect_manual_only=True, collect_running=False,
|
||||
interval_sec=3600, retention_days=30,
|
||||
)
|
||||
db.add(task)
|
||||
db.add(BizStateTaskItem(id=PREFIX + side + "-" + mode + "-item", task_id=tid,
|
||||
source_profile_id="zte.interface_brief", kind="catalog",
|
||||
enabled=False, title="验收样例离线输入;禁用采集"))
|
||||
bid = PREFIX + side + "-" + mode + "-collect"
|
||||
cid = PREFIX + side + "-" + mode + "-command"
|
||||
batch_ids[f"{side}_{mode}"] = bid
|
||||
command_ids[f"{side}_{mode}"] = cid
|
||||
at = now - timedelta(minutes=10) if mode == "portrait" else now - timedelta(seconds=2)
|
||||
interface = "GE-old" if side == "old" else "xgei-new"
|
||||
state = "up" if side == "old" else "down"
|
||||
raw = ("# 验收样例/非现场故障;离线合成输出\n"
|
||||
"show interface brief\nInterface Admin Phy Prot\n"
|
||||
f"{interface} {state} {state} {state}\n")
|
||||
db.add(BizStateBatch(id=bid, task_id=tid, source="import", ne_id=task.ne_id,
|
||||
ne_name=task.ne_name, vendor="zte", status="success",
|
||||
started_at=at, ended_at=at + timedelta(seconds=1),
|
||||
row_count=1, command_count=1, message=DECLARATION,
|
||||
is_baseline=mode == "portrait"))
|
||||
db.add(BizStateBatchCommand(id=cid, batch_id=bid,
|
||||
profile_id="zte.interface_brief", parser_id="zte.interface_brief",
|
||||
metric_id="interface_brief", raw_command="show interface brief",
|
||||
parse_status="ok", row_count=1, raw_line_count=len(raw.splitlines()),
|
||||
raw_text=raw, message=DECLARATION, created_at=at))
|
||||
db.add(BizStateMetricRow(id=PREFIX + side + "-" + mode + "-row", batch_id=bid,
|
||||
batch_command_id=cid, task_id=tid, ne_id=task.ne_id,
|
||||
metric_id="interface_brief", seq=0, collected_at=at,
|
||||
data_json={"interface": interface, "admin": state, "phy": state, "prot": state}))
|
||||
db.add(BizMigrationProject(
|
||||
id=ids["project_id"], name="验收样例/非现场故障-旧口仍UP新口DOWN", note=DECLARATION,
|
||||
old_task_id=ids["old_portrait_task_id"], new_task_id=ids["new_portrait_task_id"],
|
||||
old_hf_task_id=ids["old_current_task_id"], new_hf_task_id=ids["new_current_task_id"],
|
||||
old_baseline_batch_id=batch_ids["old_portrait"], new_baseline_batch_id=batch_ids["new_portrait"],
|
||||
mapping_id=PREFIX + "mapping", monitor_template_id=PREFIX + "monitor",
|
||||
collect_metric_ids_json=["interface_brief"], status="draft", hf_interval_sec=3600,
|
||||
))
|
||||
db.commit()
|
||||
cutover = mig.create_batch(db, ids["project_id"], {
|
||||
"batch_label": "验收样例/非现场故障-第一批", "status": "active",
|
||||
"expect_set": {"ports": ["gei-old"]}, "note": DECLARATION,
|
||||
})
|
||||
# This executes real run_evaluate and real red-ticket persistence.
|
||||
finished = mig.finish_batch(db, cutover["id"], mark_done=False)
|
||||
run_id = finished["run"]["id"]
|
||||
diffs = mig.list_run_diffs(db, run_id, color="red", limit=50)
|
||||
reds = mig.list_red_tickets(db, ids["project_id"])
|
||||
if diffs["total"] < 1 or reds["open_count"] < 1 or finished["accept_summary"]["passed"]:
|
||||
raise RuntimeError("真实评价未生成预期红项,样例验证失败")
|
||||
item = diffs["items"][0]
|
||||
if item["old_key"] != "GE-old" or item["new_key"] != "xgei-new":
|
||||
raise RuntimeError("缺少原始 A/B 键,样例验证失败")
|
||||
evidence = item.get("evidence") or {}
|
||||
raw_checks: dict[str, Any] = {}
|
||||
for side in ("old", "new"):
|
||||
ev = evidence.get(side) or {}
|
||||
command = ev.get("command") or {}
|
||||
collect = ev.get("collect") or {}
|
||||
if command.get("raw_command") != "show interface brief" or not collect.get("collected_at"):
|
||||
# Some evidence versions name the timestamp at batch granularity.
|
||||
if command.get("raw_command") != "show interface brief" or not any(collect.get(k) for k in ("started_at", "ended_at", "batch_started_at")):
|
||||
raise RuntimeError("双侧命令/采集时间证据缺失")
|
||||
bid = collect["batch_id"]
|
||||
cid = command.get("id") or command.get("command_id")
|
||||
if not cid:
|
||||
raise RuntimeError("证据没有可请求的命令 ID")
|
||||
raw = biz.get_batch_command(db, bid, cid)
|
||||
if "验收样例/非现场故障" not in raw["raw_text"]:
|
||||
raise RuntimeError("命令原文回读失败")
|
||||
raw_checks[side] = {"batch_id": bid, "command_id": cid, "raw_text": raw["raw_text"],
|
||||
"collected_at": raw["collected_at"]}
|
||||
# Read paths behind all eight MCP tools; HTTP/stdio is the lead's next step.
|
||||
assert mig.list_projects(db) and biz.list_tasks(db)
|
||||
assert mig.list_batches(db, ids["project_id"]) and biz.list_batches(db, ids["old_current_task_id"])
|
||||
assert mig.list_runs(db, cutover["id"])
|
||||
assert mig.get_monitor_context(db, project_id=ids["project_id"])
|
||||
assert mig.board(db, cutover["id"], run_id=run_id)
|
||||
assert biz.get_batch(db, batch_ids["new_current"])["commands"]
|
||||
tasks = db.query(BizStateTask).all()
|
||||
if not all(t.status == "draft" and t.collect_manual_only and not t.collect_running for t in tasks):
|
||||
raise RuntimeError("样例任务安全状态检查失败")
|
||||
db.commit()
|
||||
return {
|
||||
"declaration": DECLARATION, "database": str(database), "database_url": url,
|
||||
"source_root": str(ROOT), "created_at": now.isoformat() + "Z", **ids,
|
||||
"cutover_batch_id": cutover["id"], "run_id": run_id,
|
||||
"collect_batch_ids": batch_ids, "command_ids": command_ids,
|
||||
"expected": {"scenario": "旧侧接口仍UP,新侧映射接口DOWN,割接验收未完成,应产生红项",
|
||||
"old_key": "GE-old", "new_key": "xgei-new", "minimum_red_count": 1},
|
||||
"observed": {"red_diff_count": diffs["total"], "open_red_count": reds["open_count"],
|
||||
"verdict": item["verdict"], "color": item["color"],
|
||||
"accept_status": finished["batch"]["accept_status"],
|
||||
"accept_summary": finished["accept_summary"], "raw_evidence": raw_checks},
|
||||
"safety": {"network_blocked": True, "task_status": "draft", "manual_only": True,
|
||||
"task_items_enabled": False, "device_credentials": False, "real_device_ip": False},
|
||||
}
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--database", required=True, help="artifacts 内新 SQLite 文件的绝对路径,文件名含 acceptance")
|
||||
parser.add_argument("--metadata", help="artifacts 内新 JSON 文件的绝对路径,文件名含 acceptance")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
database = artifact_path(args.database)
|
||||
metadata = artifact_path(args.metadata, metadata=True) if args.metadata else None
|
||||
database.parent.mkdir(parents=True, exist_ok=True)
|
||||
# Exclusive creation prevents overwrite, including races after validation.
|
||||
with database.open("xb"):
|
||||
pass
|
||||
result = seed(database)
|
||||
text = json.dumps(result, ensure_ascii=False, indent=2)
|
||||
if metadata:
|
||||
metadata.parent.mkdir(parents=True, exist_ok=True)
|
||||
with metadata.open("x", encoding="utf-8") as output:
|
||||
output.write(text + "\n")
|
||||
print(text)
|
||||
return 0
|
||||
except Exception as exc:
|
||||
# Known local validation messages are safe; other failures omit internals.
|
||||
message = str(exc) if isinstance(exc, (ValueError, RuntimeError)) else type(exc).__name__
|
||||
print(json.dumps({"ok": False, "error": message, "declaration": DECLARATION}, ensure_ascii=False), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Add table
Add a link
Reference in a new issue