320 lines
13 KiB
Python
320 lines
13 KiB
Python
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")
|
||
|