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

441 lines
19 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.

# --- routers/config.py ---
import re
import json
import os
import database
import asyncio
import asyncssh
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import List
from netmiko import ConnectHandler
# 🌟 [Priority 1 修復] 引入 cmts_config_locks
from shared import CMTS_DEVICE, cmts_config_locks, parse_cli_to_tree, deep_split_tree, USE_DB
from collections import defaultdict
from fastapi.responses import StreamingResponse
router = APIRouter()
# ==========================================
# 🌟 雙軌過濾器檔案讀寫輔助函數 (加入高可用性 Fallback)
# ==========================================
def get_filter_file_path(config_type: str) -> str:
# 確保檔名安全,只允許 'running' 或 'full'
safe_type = "full" if config_type == "full" else "running"
return f"filters_{safe_type}.json"
async def load_tree_filters(config_type: str) -> list:
if USE_DB:
try:
db_filters = await database.get_tree_filters(config_type)
if db_filters is not None:
return db_filters
else:
print("⚠️ [Fallback] 資料庫無法取得過濾器,自動切換至 JSON 快取讀取...")
except Exception as e:
print(f"⚠️ [Fallback] 資料庫讀取過濾器發生例外: {e},自動切換至 JSON 快取讀取...")
file_path = get_filter_file_path(config_type)
if os.path.exists(file_path):
try:
with open(file_path, 'r', encoding='utf-8') as f:
data = json.load(f)
return data.get("hidden_keys", [])
except Exception as e:
print(f"讀取過濾器檔案失敗: {e}")
return []
return []
async def save_tree_filters(config_type: str, hidden_keys: list):
db_success = False
if USE_DB:
try:
if await database.upsert_tree_filters(config_type, hidden_keys):
db_success = True
else:
print("⚠️ [Fallback] 資料庫儲存過濾器失敗,自動切換至 JSON 快取寫入...")
except Exception as e:
print(f"⚠️ [Fallback] 資料庫寫入過濾器發生例外: {e},自動切換至 JSON 快取寫入...")
if not db_success:
file_path = get_filter_file_path(config_type)
try:
with open(file_path, 'w', encoding='utf-8') as f:
json.dump({"hidden_keys": hidden_keys}, f, ensure_ascii=False, indent=4)
except Exception as e:
print(f"儲存過濾器檔案失敗: {e}")
# ==========================================
class ConfigRequest(BaseModel):
script: str
host: str
username: str
password: str
# 定義前端傳來的 Diff 資料結構
class DiffItem(BaseModel):
path: str # 例如: "cable.ds-rf-port.1:9/0.down-channel.0.frequency"
old_val: str
new_val: str
class GenerateCliRequest(BaseModel):
diffs: List[DiffItem]
interfaces_with_admin_state: List[str] = []
@router.post("/cmts-config")
async def execute_cmts_config(req: ConfigRequest):
# 過濾掉空行與註解 (! 開頭的行)
commands = [cmd.strip() for cmd in req.script.splitlines() if cmd.strip() and not cmd.strip().startswith('!')]
async def config_streamer():
# 🌟 [Priority 1 修復] 使用依 IP 隔離的鎖
host_lock = cmts_config_locks[req.host]
async with host_lock:
try:
yield json.dumps({"status": "progress", "message": f"🔌 準備連線至設備 {req.host}..."}) + "\n"
# 使用 asyncssh 建立非同步連線
async with asyncssh.connect(
req.host,
username=req.username,
password=req.password,
known_hosts=None
) as conn:
async with conn.create_process() as process:
# 🌟 建立安全的非同步讀取函數 (讀到安靜為止,避免卡死)
async def read_until_quiet(timeout=0.5):
out_data = ""
while True:
try:
# 利用 wait_for 設定超時,時間內沒資料就當作設備吐完了
chunk = await asyncio.wait_for(process.stdout.read(4096), timeout=timeout)
if not chunk:
break
out_data += str(chunk)
except asyncio.TimeoutError:
break
return out_data
# 1. 進入設定模式
process.stdin.write("config\n")
await process.stdin.drain()
await read_until_quiet(0.5) # 清空歡迎訊息
# 2. 逐行送出指令並回報進度
for cmd in commands:
yield json.dumps({"status": "progress", "message": f"▶️ 執行: {cmd}"}) + "\n"
process.stdin.write(cmd + "\n")
await process.stdin.drain()
# ⏱️ 智慧等待:如果是 commit 指令,給予 5 秒讓設備存檔;其他指令等 0.5 秒
wait_time = 5.0 if cmd.strip() == "commit" else 0.5
output = await read_until_quiet(wait_time)
# 🛡️ 防呆撤銷機制:偵測到錯誤立刻 abort
lower_output = output.lower()
if "% invalid" in lower_output or "% incomplete" in lower_output or "error" in lower_output or "aborted" in lower_output:
yield json.dumps({
"status": "error",
"message": f"❌ 設備拒絕指令: {cmd}",
"output": output.strip()
}) + "\n"
process.stdin.write("abort\n")
await process.stdin.drain()
return # 發生錯誤,提早結束串流
# 3. 退出設定模式
process.stdin.write("exit\n")
await process.stdin.drain()
final_output = await read_until_quiet(1.0)
yield json.dumps({
"status": "success",
"message": "✅ 所有配置已成功寫入並 Commit",
"output": final_output.strip()
}) + "\n"
except Exception as e:
yield json.dumps({"status": "error", "message": f"SSH 執行發生例外錯誤: {str(e)}"}) + "\n"
# 回傳 NDJSON 串流格式
return StreamingResponse(config_streamer(), media_type="application/x-ndjson")
@router.post("/cmts-generate-cli")
async def generate_cli(req: GenerateCliRequest):
"""
將前端的 JSON Diff 轉譯為 Harmonic 單行 CLI 腳本
並自動處理 admin-state 的安全生命週期 (先 down 後 up)
"""
interface_groups = defaultdict(list)
interface_admin_states = {}
for diff in req.diffs:
parts = diff.path.split('::')
param_name = parts[-1]
interface_path = " ".join(parts[:-1])
# 全域清除路徑中間可能夾帶的虛擬資料夾索引
interface_path = re.sub(r"\s*\[\d+\]", "", interface_path)
if param_name == "admin-state":
interface_admin_states[interface_path] = diff.new_val
else:
interface_groups[interface_path].append((param_name, diff.old_val, diff.new_val))
cli_lines = []
cli_lines.append("! --- Auto-Generated Configuration Script ---")
for interface, changes in interface_groups.items():
cli_lines.append(f"! Configuring: {interface}")
supports_admin_state = interface in req.interfaces_with_admin_state
if supports_admin_state:
cli_lines.append(f"{interface} admin-state down")
for param, old_val, new_val in changes:
if re.match(r"^\[\d+\]$", param):
if new_val == "":
if old_val:
cli_lines.append(f"no {interface} {old_val}")
else:
if old_val and old_val != new_val:
cli_lines.append(f"no {interface} {old_val}")
cli_lines.append(f"{interface} {new_val}")
else:
if new_val == "":
cli_lines.append(f"no {interface} {param}")
else:
cli_lines.append(f"{interface} {param} {new_val}")
if supports_admin_state:
final_state = interface_admin_states.get(interface, "up")
cli_lines.append(f"{interface} admin-state {final_state}")
cli_lines.append("commit")
cli_lines.append("!")
for interface, state in interface_admin_states.items():
if interface not in interface_groups:
cli_lines.append(f"! Configuring state only: {interface}")
cli_lines.append(f"{interface} admin-state {state}")
cli_lines.append("commit")
cli_lines.append("!")
final_script = "\n".join(cli_lines)
return {"status": "success", "data": final_script}
# ==========================================
# DS RF Port 動態樹狀查詢 API
# ==========================================
@router.get("/cmts-ds-rf-port-list")
async def get_ds_rf_port_list(host: str, username: str, password: str = ""):
"""獲取設備上所有的 DS RF Port 清單"""
net_connect = None
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
command = 'show running-config | include "cable ds-rf-port"'
raw_output = str(net_connect.send_command(command, read_timeout=60))
pattern = r"cable ds-rf-port\s+([0-9:/]+)"
ports = re.findall(pattern, raw_output)
unique_ports = sorted(list(set(ports)))
return {
"status": "success",
"data": unique_ports,
"raw_output": raw_output
}
except Exception as e:
return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-ds-rf-port-config")
async def get_ds_rf_port_config(target: str, host: str, username: str, password: str = ""):
"""獲取特定 DS RF Port 的配置,並轉換為樹狀 JSON"""
net_connect = None
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
command = f"show running-config interface cable ds-rf-port {target} | nomore"
raw_output = str(net_connect.send_command(command, read_timeout=60))
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
base_tree = await asyncio.to_thread(parse_cli_to_tree, raw_output)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
return {
"status": "success",
"data": perfect_tree,
"raw_cli": raw_output.strip()
}
except Exception as e:
return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-full-config")
async def get_full_config(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
"""獲取整台 CMTS 的完整配置,並轉換為大型樹狀 JSON"""
net_connect = None
try:
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
net_connect = ConnectHandler(**device)
if config_type == "full":
net_connect.send_command_timing("config")
command = "show full-configuration | nomore"
raw_output = str(net_connect.send_command(command, read_timeout=180))
net_connect.send_command_timing("exit")
else:
command = "show running-config | nomore"
raw_output = str(net_connect.send_command(command, read_timeout=180))
clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
clean_output = clean_output.lstrip()
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
# ==========================================
# 🌟 深度過濾攔截器:改用雙軌過濾器讀取邏輯
# ==========================================
if not skip_filter:
hidden_keys = await load_tree_filters(config_type)
for path in hidden_keys:
keys = path.split('::')
current = perfect_tree
# 走到倒數第二層
for k in keys[:-1]:
if isinstance(current, dict) and k in current:
current = current[k]
else:
current = None
break
# 刪除最後一層的目標節點
if isinstance(current, dict) and keys[-1] in current:
current.pop(keys[-1], None)
# ==========================================
return {"status": "success", "data": perfect_tree}
except Exception as e:
return {"status": "error", "message": str(e)}
finally:
if net_connect:
net_connect.disconnect()
@router.get("/cmts-full-config/stream")
async def get_full_config_stream(host: str, username: str, password: str = "", skip_filter: bool = False, config_type: str = "running"):
"""獲取整台 CMTS 的完整配置 (串流進度回報版)"""
async def config_streamer():
net_connect = None
try:
# 1. 廣播:正在連線
yield json.dumps({"status": "progress", "message": f"🔌 正在建立 SSH 連線至 {host}..."}) + "\n"
await asyncio.sleep(0.1) # 讓前端有時間渲染
device = CMTS_DEVICE.copy()
device.update({'host': host, 'username': username, 'password': password})
# 使用 asyncio.to_thread 將阻塞的 netmiko 連線放到背景執行,避免卡住其他非同步任務
net_connect = await asyncio.to_thread(ConnectHandler, **device)
# 2. 廣播:正在下載
yield json.dumps({"status": "progress", "message": f"📥 正在下載 {config_type} 配置檔 (可能需要 30~60 秒)..."}) + "\n"
if config_type == "full":
await asyncio.to_thread(net_connect.send_command_timing, "config")
command = "show full-configuration | nomore"
raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
await asyncio.to_thread(net_connect.send_command_timing, "exit")
else:
command = "show running-config | nomore"
raw_output = await asyncio.to_thread(net_connect.send_command, command, read_timeout=180)
# 3. 廣播:正在解析
yield json.dumps({"status": "progress", "message": "🧩 正在解析配置並建構樹狀圖結構..."}) + "\n"
await asyncio.sleep(0.1)
clean_output = re.sub(r"^(?:Building configuration\.\.\.|Current configuration.*?)\n+", "", raw_output, flags=re.IGNORECASE | re.MULTILINE)
clean_output = clean_output.lstrip()
# 🌟 [Priority 1 修復] 將 CPU-Bound 任務移至 ThreadPool
base_tree = await asyncio.to_thread(parse_cli_to_tree, clean_output)
perfect_tree = await asyncio.to_thread(deep_split_tree, base_tree)
# ==========================================
# 🌟 深度過濾攔截器 (維持原樣)
# ==========================================
if not skip_filter:
yield json.dumps({"status": "progress", "message": "🔍 正在套用系統過濾器規則..."}) + "\n"
await asyncio.sleep(0.1)
hidden_keys = await load_tree_filters(config_type)
for path in hidden_keys:
keys = path.split('::')
current = perfect_tree
for k in keys[:-1]:
if isinstance(current, dict) and k in current:
current = current[k]
else:
current = None
break
if isinstance(current, dict) and keys[-1] in current:
current.pop(keys[-1], None)
# ==========================================
# 4. 廣播:完成並傳送最終資料
yield json.dumps({"status": "success", "data": perfect_tree}) + "\n"
except Exception as e:
yield json.dumps({"status": "error", "message": f"載入失敗: {str(e)}"}) + "\n"
finally:
# 🌟 [Priority 1 修復] 確保背景連線被正確釋放
if net_connect:
await asyncio.to_thread(net_connect.disconnect)
# 回傳 NDJSON 串流格式
return StreamingResponse(config_streamer(), media_type="application/x-ndjson")
# ==========================================
# 🌟 系統設定 API (System Settings)
# ==========================================
class SettingsRequest(BaseModel):
hidden_keys: list[str]
config_type: str = "running" # 🌟 加入 config_type 參數
@router.get("/settings/tree-filters")
async def get_tree_filters(config_type: str = "running"):
"""獲取目前的樹狀圖隱藏名單"""
# 🌟 根據 config_type 讀取對應的 JSON
hidden_keys = await load_tree_filters(config_type)
return {"status": "success", "data": hidden_keys}
@router.post("/settings/tree-filters")
async def update_tree_filters(req: SettingsRequest):
"""更新樹狀圖隱藏名單並存檔"""
# 🌟 根據 config_type 寫入對應的 JSON
await save_tree_filters(req.config_type, req.hidden_keys)
return {"status": "success", "message": f"系統設定 ({req.config_type}) 已更新"}