# --- routers/backup.py --- import asyncssh import asyncio import re import json from fastapi import APIRouter, HTTPException, BackgroundTasks from fastapi.responses import StreamingResponse from pydantic import BaseModel from typing import Optional, List from logger import get_logger # 引入 DB 函數 from database import ( get_pool, insert_config_backup, get_config_backup_list, get_config_backup_detail, delete_config_backup, cleanup_old_backups, toggle_config_backup_pin, # 🌟 新增 get_backup_metrics # 🌟 新增 ) from cmts_scraper import fetch_raw_config # 🌟 [Priority 1 修復] 引入 cmts_config_locks from shared import parse_cli_to_tree, cmts_config_locks logger = get_logger("app.backup") 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("/{backup_id}/toggle-pin", summary="切換快照的釘選防護狀態") async def toggle_backup_pin(backup_id: str): new_state = await toggle_config_backup_pin(backup_id) if new_state is None: raise HTTPException(status_code=500, detail="資料庫更新失敗") status_str = "已啟用釘選保護,系統絕不自動淘汰。" if new_state else "已取消釘選,該備份將納入滾動淘汰範圍。" return { "status": "success", "is_pinned": new_state, "message": f"快照狀態更新成功!{status_str}" } @router.post("/snapshot", summary="手動建立設備快照 (串流版)") async def create_snapshot(req: SnapshotRequest, background_tasks: BackgroundTasks): async def backup_streamer(): try: pool = await get_pool() yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {req.host}..."}) + "\n" await asyncio.sleep(0.1) yield json.dumps({"status": "progress", "message": f"📥 正在下載 {req.config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n" raw_cli = await fetch_raw_config(req.host, req.username, req.password, req.config_type) yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n" await asyncio.sleep(0.1) parsed_tree = await asyncio.to_thread(parse_cli_to_tree, raw_cli) yield json.dumps({"status": "progress", "message": "💾 正在將快照寫入資料庫..."}) + "\n" 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: yield json.dumps({"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}) + "\n" return # 🌟 關鍵變更:在同一個串流中「立即執行」清理與指標計算,確保回傳最新數據 yield json.dumps({"status": "progress", "message": "🧹 正在執行備份滾動淘汰與容量計算..."}) + "\n" if pool: # 1. 立即執行清理 await cleanup_old_backups(pool, req.host) # 2. 立即計算最新指標 metrics = await get_backup_metrics(pool, req.host) else: metrics = {"total": 1, "pinned": 0, "unpinned": 1, "remaining": 19} # 🌟 3. 在成功訊息中,將 metrics 字典一併回傳給前端 yield json.dumps({ "status": "success", "message": f"快照 '{req.snapshot_name}' 建立成功!", "backup_id": backup_id, "host": req.host, "metrics": metrics # 🌟 包含容量指標 }) + "\n" except Exception as e: logger.error(f"❌ 建立快照失敗: {e}") yield json.dumps({"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}) + "\n" return StreamingResponse(backup_streamer(), media_type="application/x-ndjson") @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(): # 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖 host_lock = cmts_config_locks[req.host] # 1. 嘗試取得全域寫入鎖 (避免多人同時打指令) if host_lock.locked(): yield json.dumps({"status": "error", "message": "❌ 設備目前正由其他使用者進行配置,請稍後再試。"}) + "\n" return async with host_lock: try: yield json.dumps({"status": "progress", "message": "🔄 正在建立 SSH 安全連線..."}) + "\n" # 🌟 [Priority 2 提前修復] 使用 async with 確保連線與 Process 絕對會被釋放 async with asyncssh.connect(req.host, username=req.username, password=req.password, known_hosts=None) as conn: async with conn.create_process(term_type='xterm-256color', term_size=(200, 24), encoding='utf-8') as process: async def read_until_quiet(timeout=1.0, prompt_pattern: str = None): 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() # 🌟 精準 Prompt 偵測 if prompt_pattern and re.search(prompt_pattern, output): break except asyncio.TimeoutError: break return output # 2. 進入設定模式 process.stdin.write("config\n") await process.stdin.drain() await read_until_quiet(timeout=1.5, prompt_pattern=r"\(config\)#") 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, prompt_pattern=r"\(config.*\)#") 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, prompt_pattern=r"(?:#|>)") # 等待設備處理 abort 並退出 config 模式 # 3. 回報最終錯誤並關閉連線 yield json.dumps({ "status": "error", "message": f"❌ 寫入中斷且已安全撤銷!失敗指令: {cmd}", "output": clean_out }) + "\n" 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, prompt_pattern=r"\(config\)#") # 清理 Commit 回傳的雜訊 clean_commit = re.sub(r'\x1b\[[0-9;]*[mGK]', '', commit_out).strip() # 5. 退出並關閉連線 process.stdin.write("exit\n") await process.stdin.drain() # 🌟 移除手動 conn.close(),交由 async with 處理 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")