import logging import asyncssh import asyncio import re import json from fastapi import APIRouter, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel from typing import Optional, List # 引入 DB 函數 from database import ( insert_config_backup, get_config_backup_list, get_config_backup_detail, delete_config_backup ) from cmts_scraper import fetch_raw_config from shared import parse_cli_to_tree, cmts_config_lock # <== 引入真正的解析器與全域鎖 logger = logging.getLogger(__name__) router = APIRouter( prefix="/backups", tags=["Backups"] ) # ========================================== # 📦 Pydantic Models # ========================================== class SnapshotRequest(BaseModel): host: str username: str password: str snapshot_name: str description: Optional[str] = "" # 🟢 新增這行,接收前端傳來的描述 config_type: str = "running" class RestoreRequest(BaseModel): host: str username: str password: str class ExecuteRestoreRequest(BaseModel): host: str username: str password: str commands: List[str] # 接收前端確認後的差異指令清單 # ========================================== # 🛠️ 核心演算法:深度差異比對 (Deep Diff) # ========================================== # 🌟 新增小工具:拔除 [0], [1] 虛擬後綴,還原真實 CLI def clean_virtual_key(key: str) -> str: return re.sub(r'\s*\[\d+\]$', '', key) def build_add_commands(node: dict, current_path: str) -> list: """遞迴展開所有需要新增的絕對路徑指令""" commands = [] if not node: commands.append(current_path) return commands for key, val in node.items(): if key.startswith('_'): continue # 🌟 使用乾淨的 key 來組合路徑 real_key = clean_virtual_key(key) path = f"{current_path} {real_key}".strip() if isinstance(val, dict): commands.extend(build_add_commands(val, path)) else: val_str = f" {val}" if val else "" commands.append(f"{path}{val_str}") return commands def generate_diff_commands(current_node: dict, snapshot_node: dict, current_path: str = "") -> list: """比對兩棵樹,產生帶有 no 與絕對路徑的差異指令清單""" commands = [] for key, curr_val in current_node.items(): if key.startswith('_'): continue # 🌟 使用乾淨的 key real_key = clean_virtual_key(key) path = f"{current_path} {real_key}".strip() if key not in snapshot_node: commands.append(f"no {path}") else: snap_val = snapshot_node[key] if isinstance(curr_val, dict) and isinstance(snap_val, dict): commands.extend(generate_diff_commands(curr_val, snap_val, path)) elif isinstance(curr_val, dict) or isinstance(snap_val, dict): commands.append(f"no {path}") if isinstance(snap_val, dict): commands.extend(build_add_commands(snap_val, path)) else: val_str = f" {snap_val}" if snap_val else "" commands.append(f"{path}{val_str}") elif curr_val != snap_val: val_str = f" {snap_val}" if snap_val else "" commands.append(f"{path}{val_str}") for key, snap_val in snapshot_node.items(): if key.startswith('_'): continue # 🌟 使用乾淨的 key real_key = clean_virtual_key(key) path = f"{current_path} {real_key}".strip() if key not in current_node: if isinstance(snap_val, dict): commands.extend(build_add_commands(snap_val, path)) else: val_str = f" {snap_val}" if snap_val else "" commands.append(f"{path}{val_str}") return commands # ========================================== # 🚀 API 路由 # ========================================== @router.get("/", summary="取得歷史快照列表") async def list_backups(host: str, config_type: str = "running"): records = await get_config_backup_list(host, config_type) if records is None: return {"status": "error", "message": "資料庫查詢失敗"} return {"status": "success", "data": records} @router.get("/{backup_id}", summary="取得特定快照詳細內容") async def get_backup_detail(backup_id: str): record = await get_config_backup_detail(backup_id) if not record: return {"status": "error", "message": "找不到該筆備份資料"} return {"status": "success", "data": record} @router.delete("/{backup_id}", summary="刪除特定快照") async def delete_backup(backup_id: str): success = await delete_config_backup(backup_id) if not success: return {"status": "error", "message": "刪除失敗或找不到該筆資料"} return {"status": "success", "message": "快照已成功刪除"} @router.post("/snapshot", summary="手動建立設備快照") async def create_snapshot(req: SnapshotRequest): try: raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type) parsed_tree = parse_cli_to_tree(raw_cli) backup_id = await insert_config_backup( host=req.host, config_type=req.config_type, raw_cli=raw_cli, parsed_tree=parsed_tree, snapshot_name=req.snapshot_name, description=req.description, # 🟢 將描述傳遞給資料庫函數 is_auto=False ) if not backup_id: return {"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"} return { "status": "success", "message": f"快照 '{req.snapshot_name}' 建立成功!", "backup_id": backup_id, "host": req.host } except Exception as e: logger.error(f"❌ 建立快照失敗: {e}") return {"status": "error", "message": f"設備連線或解析失敗: {str(e)}"} @router.post("/{backup_id}/diff", summary="分析設備當前配置與快照的差異") async def analyze_backup_diff(backup_id: str, req: RestoreRequest): try: backup_record = await get_config_backup_detail(backup_id) if not backup_record: return {"status": "error", "message": "找不到指定的備份紀錄"} snapshot_tree = backup_record.get("parsed_tree") if not snapshot_tree: return {"status": "error", "message": "此備份紀錄缺乏樹狀結構資料,無法進行智慧比對"} # 🌟 關鍵修正:動態取得該快照的 config_type,避免蘋果比橘子 snapshot_config_type = backup_record.get("config_type", "running") # 🌟 關鍵修正:將硬編碼的 "running" 改為 snapshot_config_type raw_current = await fetch_raw_config(req.host, req.username, req.password, snapshot_config_type) if not raw_current: return {"status": "error", "message": "無法從設備取得當前配置,請檢查連線狀態"} current_tree = parse_cli_to_tree(raw_current) diff_commands = generate_diff_commands(current_tree, snapshot_tree) return { "status": "success", "data": { "commands": diff_commands } } except Exception as e: logger.error(f"差異分析失敗: {str(e)}") return {"status": "error", "message": f"分析過程發生例外錯誤: {str(e)}"} @router.post("/{backup_id}/restore", summary="執行差異還原 (串流即時回報)") async def execute_restore(backup_id: str, req: ExecuteRestoreRequest): commands = req.commands if not commands: async def empty_success(): yield json.dumps({"status": "success", "message": "沒有需要執行的指令,設備狀態已同步。"}) + "\n" return StreamingResponse(empty_success(), media_type="application/x-ndjson") async def restore_generator(): # 1. 嘗試取得全域寫入鎖 (避免多人同時打指令) if cmts_config_lock.locked(): yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置,請稍後再試。"}) + "\n" return async with cmts_config_lock: try: yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n" conn = await asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) process = await conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') async def read_until_quiet(timeout=1.0): output = "" while True: try: chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout) if not chunk: break output += chunk if "--More--" in chunk or "More" in chunk: process.stdin.write(" ") await process.stdin.drain() except asyncio.TimeoutError: break return output # 2. 進入設定模式 process.stdin.write("config\n") await process.stdin.drain() await read_until_quiet(timeout=1.5) yield json.dumps({"status": "progress", "message": "✅ 成功進入 Global Configuration 模式"}) + "\n" # 3. 逐行寫入並進行 Fail-safe 檢查 for cmd in commands: process.stdin.write(f"{cmd}\n") await process.stdin.drain() out = await read_until_quiet(timeout=0.2) clean_out = re.sub(r'\x1b\[[0-9;]*[mGK]', '', out).strip() # 🚨 Fail-safe 攔截機制與安全撤銷 (Rollback) if "% Invalid" in clean_out or "% Incomplete" in clean_out or "% Ambiguous" in clean_out: # 1. 先通知前端正在撤銷 yield json.dumps({ "status": "progress", "message": "⚠️ 偵測到無效指令,正在執行 abort 放棄所有變更..." }) + "\n" # 2. 對設備下達 abort 指令 process.stdin.write("abort\n") await process.stdin.drain() await read_until_quiet(timeout=1.0) # 等待設備處理 abort 並退出 config 模式 # 3. 回報最終錯誤並關閉連線 yield json.dumps({ "status": "error", "message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}", "output": clean_out }) + "\n" conn.close() return yield json.dumps({ "status": "progress", "message": f"執行: {cmd}", "output": clean_out }) + "\n" # 稍微讓出控制權,確保串流順暢 await asyncio.sleep(0.05) # 4. 提交變更 (Commit) yield json.dumps({"status": "progress", "message": "⏳ 正在寫入 Commit 保存設定..."}) + "\n" process.stdin.write("commit\n") await process.stdin.drain() commit_out = await read_until_quiet(timeout=6.0) clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip() # 5. 退出並關閉連線 process.stdin.write("exit\n") await process.stdin.drain() conn.close() yield json.dumps({ "status": "success", "message": "✅ 所有差異還原指令已成功送出並 Commit!", "output": clean_commit }) + "\n" except Exception as e: logger.error(f"還原執行失敗: {str(e)}") yield json.dumps({"status": "error", "message": f"SSH 連線或執行發生例外錯誤: {str(e)}"}) + "\n" return StreamingResponse(restore_generator(), media_type="application/x-ndjson")