mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 19:23:22 +08:00
- 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>
264 lines
16 KiB
Python
264 lines
16 KiB
Python
"""离线生成验收样例/非现场故障;评价结论必须来自真实 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())
|