scm-harmonic-cmts-admin/routers/backup.py

273 lines
9.7 KiB
Python
Raw Normal View History

import logging
2026-05-28 02:24:17 +00:00
import asyncssh
import asyncio
import re
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
2026-05-28 02:24:17 +00:00
from typing import Optional, List
# 引入 DB 函數
from database import (
insert_config_backup,
get_config_backup_list,
get_config_backup_detail,
delete_config_backup
)
2026-05-28 02:24:17 +00:00
from cmts_scraper import fetch_raw_config
from shared import parse_cli_to_tree # <== 引入真正的解析器
logger = logging.getLogger(__name__)
router = APIRouter(
2026-05-25 08:53:40 +00:00
prefix="/backups",
tags=["Backups"]
)
2026-05-28 02:24:17 +00:00
# ==========================================
# 📦 Pydantic Models
# ==========================================
class SnapshotRequest(BaseModel):
host: str
username: str
password: str
snapshot_name: str
description: Optional[str] = "" # 🟢 新增這行,接收前端傳來的描述
config_type: str = "running"
2026-05-28 02:24:17 +00:00
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)
2026-05-28 02:24:17 +00:00
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()
2026-05-28 02:24:17 +00:00
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()
2026-05-28 02:24:17 +00:00
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()
2026-05-28 02:24:17 +00:00
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)
2026-05-28 02:24:17 +00:00
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)}"}
2026-05-28 02:24:17 +00:00
@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)
2026-05-28 02:24:17 +00:00
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:
return {"status": "success", "message": "沒有需要執行的指令,設備狀態已同步。"}
output_log = []
try:
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
# 進入設定模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(timeout=1.5)
# 逐行寫入精準的差異指令
for cmd in commands:
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
out = await read_until_quiet(timeout=0.2)
output_log.append(out)
# 提交變更
process.stdin.write("commit\n")
await process.stdin.drain()
commit_out = await read_until_quiet(timeout=2.0)
output_log.append(commit_out)
# 退出並關閉連線
process.stdin.write("exit\n")
await process.stdin.drain()
conn.close()
clean_log = re.sub(r'\x1b\[[0-9;]*[mGK]', '', "".join(output_log))
return {
"status": "success",
"message": "差異還原指令已成功送出並 Commit",
"output": clean_log
}
except Exception as e:
logger.error(f"還原執行失敗: {str(e)}")
return {"status": "error", "message": f"SSH 執行失敗: {str(e)}"}