feat: 實作 Phase 1 & 2 備份資料庫 Schema 與 API 路由

This commit is contained in:
swpa 2026-05-25 16:40:55 +08:00
parent d53f90efff
commit d11f70b898
5 changed files with 287 additions and 1 deletions

View File

@ -338,3 +338,54 @@ async def sync_cmts_leaves_async(host, username, password, leaf_paths: list, con
except Exception as e:
yield json.dumps({"event": "error", "message": f"爬蟲嚴重錯誤: {str(e)}"}) + "\n"
async def fetch_raw_config(host: str, username: str, password: str, config_type: str = "running") -> str:
"""連線至設備並抓取完整的純文字設定檔"""
conn = None
try:
conn = await asyncssh.connect(host, username=username, password=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=2.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
# 根據 config_type 決定指令 (這裡以 running 為例,您可依設備指令集調整)
cmd = "show running-config" if config_type == "running" else f"show config {config_type}"
process.stdin.write(f"{cmd}\n")
await process.stdin.drain()
raw_output = await read_until_quiet(timeout=3.0)
process.stdin.write("exit\n")
await process.stdin.drain()
# 簡單清理頭尾的雜訊 (例如指令本身的 echo)
lines = raw_output.splitlines()
clean_lines = [line for line in lines if not line.startswith(cmd) and not line.startswith("admin@")]
return "\n".join(clean_lines).strip()
except Exception as e:
raise Exception(f"SSH 連線或抓取設定失敗: {str(e)}")
finally:
if conn:
conn.close()
def parse_config_to_tree(raw_cli: str) -> dict:
"""將純文字設定檔轉換為簡單的階層式 JSON 樹狀圖 (Phase 3 會用到)"""
# 這裡先實作一個基礎的縮排解析器,未來可依據您的設備格式優化
tree = {}
# 暫時回傳空字典,確保 Phase 1 & 2 能順利走通
# 真正的樹狀解析邏輯我們可以在 Phase 3 完善
return {"_raw_length": len(raw_cli), "status": "pending_parser"}

View File

@ -1,6 +1,7 @@
import asyncpg
import json
import logging
import uuid
from typing import Dict, List, Optional, Any
# ==========================================
@ -241,3 +242,133 @@ async def get_tree_filters(config_type: str) -> Optional[List[str]]:
except Exception as e:
logger.error(f"❌ Unknown Error (get_tree_filters): {e}")
return None
# ==========================================
# CRUD Functions for config_backups (Phase 1 & 2)
# ==========================================
async def insert_config_backup(
host: str,
config_type: str,
raw_cli: str,
parsed_tree: dict,
snapshot_name: Optional[str] = None,
is_auto: bool = False
) -> Optional[str]:
"""新增一筆設備配置備份,回傳產生的 Backup ID"""
pool = await get_pool()
if not pool:
return None
backup_id = str(uuid.uuid4())
query = """
INSERT INTO config_backups
(id, host, config_type, snapshot_name, is_auto, raw_cli, parsed_tree)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
"""
try:
async with pool.acquire() as conn:
await conn.execute(
query,
backup_id,
host,
config_type,
snapshot_name,
is_auto,
raw_cli,
json.dumps(parsed_tree)
)
return backup_id
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (insert_config_backup): {e}")
return None
except Exception as e:
logger.error(f"❌ Unknown Error (insert_config_backup): {e}")
return None
async def get_config_backup_list(host: str, config_type: str) -> Optional[List[Dict[str, Any]]]:
"""取得歷史快照列表 (輕量級,不含完整設定檔內容)"""
pool = await get_pool()
if not pool:
return None
query = """
SELECT id, host, config_type, timestamp, snapshot_name, is_auto
FROM config_backups
WHERE host = $1 AND config_type = $2
ORDER BY timestamp DESC;
"""
try:
async with pool.acquire() as conn:
records = await conn.fetch(query, host, config_type)
# 將 asyncpg 的 Record 轉為 dict並將 datetime 轉為字串方便 JSON 序列化
return [
{
"id": str(r["id"]),
"host": r["host"],
"config_type": r["config_type"],
"timestamp": r["timestamp"].isoformat(),
"snapshot_name": r["snapshot_name"],
"is_auto": r["is_auto"]
}
for r in records
]
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (get_config_backup_list): {e}")
return None
except Exception as e:
logger.error(f"❌ Unknown Error (get_config_backup_list): {e}")
return None
async def get_config_backup_detail(backup_id: str) -> Optional[Dict[str, Any]]:
"""取得特定快照的完整內容 (包含 parsed_tree供 Phase 3 還原使用)"""
pool = await get_pool()
if not pool:
return None
query = """
SELECT id, timestamp, snapshot_name, is_auto, parsed_tree
FROM config_backups
WHERE id = $1;
"""
try:
async with pool.acquire() as conn:
record = await conn.fetchrow(query, backup_id)
if record:
data_val = record['parsed_tree']
parsed_tree = json.loads(data_val) if isinstance(data_val, str) else data_val
return {
"id": str(record["id"]),
"timestamp": record["timestamp"].isoformat(),
"snapshot_name": record["snapshot_name"],
"is_auto": record["is_auto"],
"parsed_tree": parsed_tree
}
return None
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (get_config_backup_detail): {e}")
return None
except Exception as e:
logger.error(f"❌ Unknown Error (get_config_backup_detail): {e}")
return None
async def delete_config_backup(backup_id: str) -> bool:
"""刪除指定的快照"""
pool = await get_pool()
if not pool:
return False
query = "DELETE FROM config_backups WHERE id = $1;"
try:
async with pool.acquire() as conn:
status = await conn.execute(query, backup_id)
# status 會是 'DELETE 1' 或 'DELETE 0'
return int(status.split()[-1]) > 0
except asyncpg.PostgresError as e:
logger.error(f"❌ DB Error (delete_config_backup): {e}")
return False
except Exception as e:
logger.error(f"❌ Unknown Error (delete_config_backup): {e}")
return False

View File

@ -61,6 +61,28 @@ async def init_database():
);
""")
# 4. 建立設備配置備份表 config_backups (Phase 1 新增)
logger.info("🛠️ 正在建立 config_backups 資料表與索引...")
await conn.execute("""
CREATE TABLE IF NOT EXISTS config_backups (
id UUID PRIMARY KEY,
host VARCHAR(255) NOT NULL,
config_type VARCHAR(50) NOT NULL DEFAULT 'running',
timestamp TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
snapshot_name VARCHAR(255),
is_auto BOOLEAN NOT NULL DEFAULT FALSE,
raw_cli TEXT,
parsed_tree JSONB,
CONSTRAINT uq_snapshot_name UNIQUE (host, config_type, snapshot_name)
);
""")
# 建立複合索引以加速列表查詢與排序
await conn.execute("""
CREATE INDEX IF NOT EXISTS idx_config_backups_host_type_ts
ON config_backups (host, config_type, timestamp DESC);
""")
await conn.close()
logger.info("✅ 資料庫初始化完成!所有資料表已準備就緒。")

View File

@ -7,7 +7,7 @@ from contextlib import asynccontextmanager
import database
# 引入我們剛剛拆分出來的路由模組
from routers import query, config, terminal, lock, leaf_options
from routers import query, config, terminal, lock, leaf_options, backup
@asynccontextmanager
async def lifespan(app: FastAPI):
@ -27,6 +27,7 @@ app.include_router(query.router, prefix="/api/v1")
app.include_router(config.router, prefix="/api/v1")
app.include_router(leaf_options.router, prefix="/api/v1")
app.include_router(lock.router, prefix="/api/v1")
app.include_router(backup.router, prefix="/api/v1")
# WebSocket 通常獨立於 API 版本之外,所以不加前綴
app.include_router(terminal.router)

81
routers/backup.py Normal file
View File

@ -0,0 +1,81 @@
import logging
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional
# 引入 DB 函數
from database import (
insert_config_backup,
get_config_backup_list,
get_config_backup_detail,
delete_config_backup
)
# ✅ 修正:引入我們剛剛在 cmts_scraper.py 新增的函數
from cmts_scraper import fetch_raw_config, parse_config_to_tree
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/v1/backups",
tags=["Backups"]
)
class SnapshotRequest(BaseModel):
host: str
username: str
password: str
snapshot_name: str
config_type: str = "running"
@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_config_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,
is_auto=False
)
if not backup_id:
return {"status": "error", "message": "資料庫寫入失敗,請檢查系統日誌"}
return {
"status": "success",
"message": f"快照 '{req.snapshot_name}' 建立成功!",
"backup_id": backup_id
}
except Exception as e:
logger.error(f"❌ 建立快照失敗: {e}")
return {"status": "error", "message": f"設備連線或解析失敗: {str(e)}"}