diff --git a/cmts_scraper.py b/cmts_scraper.py index 012a572..f753da5 100644 --- a/cmts_scraper.py +++ b/cmts_scraper.py @@ -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"} \ No newline at end of file diff --git a/database.py b/database.py index 3cac799..04c5b2c 100644 --- a/database.py +++ b/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 diff --git a/init_db.py b/init_db.py index d732216..27fa2f3 100644 --- a/init_db.py +++ b/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("✅ 資料庫初始化完成!所有資料表已準備就緒。") diff --git a/main.py b/main.py index c6dd9fa..d331b95 100644 --- a/main.py +++ b/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) diff --git a/routers/backup.py b/routers/backup.py new file mode 100644 index 0000000..45e7cb5 --- /dev/null +++ b/routers/backup.py @@ -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)}"} +