netx/scripts/seed_mcp_acceptance.py
oliver 9085cac742 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>
2026-10-16 18:52:05 +08:00

264 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""离线生成验收样例/非现场故障;评价结论必须来自真实 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())