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

273 lines
9.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import logging
import asyncssh
import asyncio
import re
from fastapi import APIRouter, HTTPException
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 # <== 引入真正的解析器
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:
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)}"}