feat: 實作 Phase 1 & 2 備份資料庫 Schema 與 API 路由
This commit is contained in:
parent
d53f90efff
commit
d11f70b898
|
|
@ -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"}
|
||||
131
database.py
131
database.py
|
|
@ -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
|
||||
|
|
|
|||
22
init_db.py
22
init_db.py
|
|
@ -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("✅ 資料庫初始化完成!所有資料表已準備就緒。")
|
||||
|
||||
|
|
|
|||
3
main.py
3
main.py
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}"}
|
||||
|
||||
Loading…
Reference in New Issue