feat: Initial v0.9 release with API Key authentication
## v0.9.20260325_144654 ### Features - API Key Authentication System - Job Worker System - V2 Backup Versioning ### Bug Fixes - get_processor_results_by_job column mapping Co-authored-by: OpenCode
This commit is contained in:
35
monitor/common/load_credentials.sh
Normal file
35
monitor/common/load_credentials.sh
Normal file
@@ -0,0 +1,35 @@
|
||||
#!/bin/bash
|
||||
# Momentry Monitor 密碼管理腳本
|
||||
# 路徑: /Users/accusys/momentry_core_0.1/monitor/common/load_credentials.sh
|
||||
#
|
||||
# 使用方式:
|
||||
# source /path/to/load_credentials.sh
|
||||
|
||||
# 載入環境變數(如果存在)
|
||||
if [ -f "$HOME/.momentry/credentials" ]; then
|
||||
set -a
|
||||
source "$HOME/.momentry/credentials"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# 預設值
|
||||
export PG_USER="${PG_USER:-accusys}"
|
||||
export PG_PASSWORD="${PG_PASSWORD:-accusys}"
|
||||
export PG_HOST="${PG_HOST:-localhost}"
|
||||
export PG_PORT="${PG_PORT:-5432}"
|
||||
|
||||
export REDIS_PASSWORD="${REDIS_PASSWORD:-accusys}"
|
||||
|
||||
export MONGO_USER="${MONGO_USER:-accusys}"
|
||||
export MONGO_PASSWORD="${MONGO_PASSWORD:-Test3200Test3200}"
|
||||
export MONGO_PORT="${MONGO_PORT:-27017}"
|
||||
|
||||
export QDRANT_API_KEY="${QDRANT_API_KEY:-Test3200Test3200Test3200}"
|
||||
|
||||
export SFTPGO_PASSWORD="${SFTPGO_PASSWORD:-sftpgo_pass_2026}"
|
||||
export SFTPGO_USER="${SFTPGO_USER:-sftpgo}"
|
||||
|
||||
export N8N_PASSWORD="${N8N_PASSWORD:-accusys}"
|
||||
|
||||
export MARIADB_USER="${MARIADB_USER:-accusys}"
|
||||
export MARIADB_PASSWORD="${MARIADB_PASSWORD:-Test3200Test3200Test3200}"
|
||||
@@ -405,14 +405,6 @@ backup:
|
||||
retention:
|
||||
weekly: 4
|
||||
|
||||
- name: "mongodb"
|
||||
enabled: true
|
||||
backup_type: "config"
|
||||
method: "file"
|
||||
schedule: "weekly"
|
||||
retention:
|
||||
weekly: 4
|
||||
|
||||
- name: "php"
|
||||
enabled: true
|
||||
backup_type: "config"
|
||||
|
||||
@@ -5,6 +5,12 @@
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
MONITOR_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
# 載入密碼配置
|
||||
if [ -f "$MONITOR_DIR/common/load_credentials.sh" ]; then
|
||||
source "$MONITOR_DIR/common/load_credentials.sh"
|
||||
fi
|
||||
|
||||
LOG_DIR="/Users/accusys/momentry/log/monitor"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
@@ -27,7 +33,7 @@ record_service() {
|
||||
local response_time=$3
|
||||
local error_msg=$4
|
||||
|
||||
psql -U accusys -h localhost -d momentry << EOF 2>/dev/null
|
||||
PGPASSWORD="$PG_PASSWORD" psql -U "$PG_USER" -h localhost -d momentry << EOF 2>/dev/null
|
||||
INSERT INTO monitor_services (service_name, service_type, status, response_time_ms, error_message, checked_at)
|
||||
VALUES ('$service', 'service', '$status', $response_time, '$error_msg', NOW());
|
||||
EOF
|
||||
@@ -36,7 +42,7 @@ EOF
|
||||
# 檢查 PostgreSQL
|
||||
check_postgresql() {
|
||||
local start=$(date +%s%N)
|
||||
if pg_isready -h localhost -p 5432 -U accusys > /dev/null 2>&1; then
|
||||
if PGPASSWORD="$PG_PASSWORD" pg_isready -h localhost -p 5432 -U "$PG_USER" > /dev/null 2>&1; then
|
||||
local end=$(date +%s%N)
|
||||
local ms=$(( (end - start) / 1000000 ))
|
||||
echo -e "${GREEN}✓${NC} PostgreSQL (5432) - ${ms}ms"
|
||||
@@ -52,7 +58,7 @@ check_postgresql() {
|
||||
# 檢查 Redis
|
||||
check_redis() {
|
||||
local start=$(date +%s%N)
|
||||
if redis-cli -a accusys ping 2>/dev/null | grep -q "PONG"; then
|
||||
if redis-cli -a "$REDIS_PASSWORD" ping 2>/dev/null | grep -q "PONG"; then
|
||||
local end=$(date +%s%N)
|
||||
local ms=$(( (end - start) / 1000000 ))
|
||||
echo -e "${GREEN}✓${NC} Redis (6379) - ${ms}ms"
|
||||
@@ -68,7 +74,7 @@ check_redis() {
|
||||
# 檢查 MariaDB
|
||||
check_mariadb() {
|
||||
local start=$(date +%s%N)
|
||||
if mysql -u accusys -e "SELECT 1" > /dev/null 2>&1; then
|
||||
if mysql -u "$MARIADB_USER" -p"$MARIADB_PASSWORD" -e "SELECT 1" > /dev/null 2>&1; then
|
||||
local end=$(date +%s%N)
|
||||
local ms=$(( (end - start) / 1000000 ))
|
||||
echo -e "${GREEN}✓${NC} MariaDB (3306) - ${ms}ms"
|
||||
@@ -142,9 +148,16 @@ check_sftpgo() {
|
||||
local end=$(date +%s%N)
|
||||
local ms=$(( (end - start) / 1000000 ))
|
||||
|
||||
# 檢查 SFTP 端口
|
||||
local sftp_port=$(lsof -i :2022 2>/dev/null | grep -c LISTEN || echo "0")
|
||||
local webdav_port=$(lsof -i :8090 2>/dev/null | grep -c LISTEN || echo "0")
|
||||
|
||||
# 檢查 PostgreSQL 連接
|
||||
local db_conn=$(PGPASSWORD="$PG_PASSWORD" psql -U "$PG_USER" -h localhost -d postgres -t -c "SELECT numbackends FROM pg_stat_database WHERE datname='sftpgo';" 2>/dev/null | xargs || echo "0")
|
||||
|
||||
if [ "$http_code" = "200" ] || [ "$http_code" = "301" ] || [ "$http_code" = "302" ]; then
|
||||
echo -e "${GREEN}✓${NC} SFTPGo (8080) - ${ms}ms"
|
||||
record_service "sftpgo" "up" "$ms" ""
|
||||
echo -e "${GREEN}✓${NC} SFTPGo (8080) - ${ms}ms | SFTP:$sftp_port | WebDAV:$webdav_port | DB:$db_conn"
|
||||
record_service "sftpgo" "up" "$ms" "SFTP:$sftp_port WebDAV:$webdav_port DB:$db_conn"
|
||||
return 0
|
||||
else
|
||||
echo -e "${RED}✗${NC} SFTPGo (8080) - HTTP $http_code"
|
||||
@@ -153,6 +166,73 @@ check_sftpgo() {
|
||||
fi
|
||||
}
|
||||
|
||||
# SFTPGo 詳細監控
|
||||
check_sftpgo_detailed() {
|
||||
echo ""
|
||||
echo "=== SFTPGo 詳細監控 ==="
|
||||
|
||||
# 1. 服務狀態
|
||||
echo "1. 服務狀態:"
|
||||
ps aux | grep sftpgo | grep -v grep | awk '{print " PID: "$2" CMD: "$11" "$12}'
|
||||
|
||||
# 2. 端口監聽
|
||||
echo "2. 端口監聽:"
|
||||
echo " - HTTP (8080): $(lsof -i :8080 2>/dev/null | grep -c LISTEN || echo '0')"
|
||||
echo " - SFTP (2022): $(lsof -i :2022 2>/dev/null | grep -c LISTEN || echo '0')"
|
||||
echo " - WebDAV (8090): $(lsof -i :8090 2>/dev/null | grep -c LISTEN || echo '0')"
|
||||
|
||||
# 3. PostgreSQL 連接
|
||||
echo "3. PostgreSQL 連接:"
|
||||
PGPASSWORD="$PG_PASSWORD" psql -U "$PG_USER" -h localhost -d postgres -c "SELECT numbackends, xact_commit, xact_rollback FROM pg_stat_database WHERE datname='sftpgo';" 2>/dev/null | grep -v "numbackends\|^$\|row)" || echo " 無數據"
|
||||
|
||||
# 4. 用戶統計
|
||||
echo "4. 用戶統計:"
|
||||
PGPASSWORD="$SFTPGO_PASSWORD" psql -U "$SFTPGO_USER" -h localhost -d sftpgo -c "SELECT 'users' as type, COUNT(*) as count FROM users UNION ALL SELECT 'admins', COUNT(*) FROM admins UNION ALL SELECT 'api_keys', COUNT(*) FROM api_keys;" 2>/dev/null | grep -v "^$\|type\|^(\|row)" || echo " 無數據"
|
||||
|
||||
# 5. 數據庫大小
|
||||
echo "5. 數據庫大小:"
|
||||
PGPASSWORD="$PG_PASSWORD" psql -U "$PG_USER" -h localhost -d postgres -t -c "SELECT pg_size_pretty(pg_database_size('sftpgo'));" 2>/dev/null | xargs || echo " 無法獲取"
|
||||
|
||||
# 6. 磁盤使用
|
||||
echo "6. 文件存儲使用:"
|
||||
du -sh /Users/accusys/momentry/var/sftpgo/data/ 2>/dev/null | awk '{print " "$2": "$1}'
|
||||
}
|
||||
|
||||
# SFTPGo 認證失敗監控
|
||||
check_sftpgo_auth_failures() {
|
||||
local log_file="/Users/accusys/momentry/log/sftpgo.log"
|
||||
local threshold=${1:-5} # 默認 5 次失敗
|
||||
|
||||
if [ ! -f "$log_file" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
# 檢查過去 1 小時的認證失敗
|
||||
local failures=$(grep -i "authentication error\|invalid credentials\|login failed\|auth error" "$log_file" 2>/dev/null | wc -l)
|
||||
|
||||
if [ "$failures" -gt "$threshold" ]; then
|
||||
echo "⚠️ SFTPGo 認證失敗過多: $failures 次"
|
||||
return 1
|
||||
else
|
||||
echo "✓ SFTPGo 認證失敗: $failures 次 (閾值: $threshold)"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
|
||||
# SFTPGo 傳輸統計
|
||||
check_sftpgo_transfers() {
|
||||
echo ""
|
||||
echo "=== SFTPGo 傳輸統計 ==="
|
||||
|
||||
# 檢查活動傳輸
|
||||
local active_transfers=$(PGPASSWORD="$SFTPGO_PASSWORD" psql -U "$SFTPGO_USER" -h localhost -d sftpgo -t -c "SELECT COUNT(*) FROM active_transfers;" 2>/dev/null | xargs || echo "0")
|
||||
echo "活動傳輸: $active_transfers"
|
||||
|
||||
# 檢查今日訪問IP
|
||||
echo "今日訪問來源:"
|
||||
tail -1000 /Users/accusys/momentry/log/sftpgo_access.log 2>/dev/null | grep -o '"remote_ip":"[^"]*"' | cut -d'"' -f4 | sort | uniq -c | sort -rn | head -5 | awk '{print " "$2": "$1" 次"}'
|
||||
}
|
||||
|
||||
# 檢查 Ollama
|
||||
check_ollama() {
|
||||
local start=$(date +%s%N)
|
||||
|
||||
625
monitor/workflow/backup_n8n_api.py
Executable file
625
monitor/workflow/backup_n8n_api.py
Executable file
@@ -0,0 +1,625 @@
|
||||
#!/usr/bin/env python3.11
|
||||
"""
|
||||
n8n Workflow 備份腳本 - 使用 n8n REST API
|
||||
路徑: /Users/accusys/momentry_core_0.1/monitor/workflow/backup_n8n_api.py
|
||||
|
||||
功能:
|
||||
- 使用 n8n REST API 導出所有 workflows
|
||||
- 按用戶/Tags 分組備份
|
||||
- 變更偵測
|
||||
- 差異備份(只備份變更的 workflow)
|
||||
- SHA256 校驗
|
||||
- 備份驗證
|
||||
|
||||
前置需求:
|
||||
pip3.11 install requests
|
||||
|
||||
使用方式:
|
||||
python3.11 backup_n8n_api.py # 備份所有 workflows
|
||||
python3.11 backup_n8n_api.py --diff # 只顯示變更
|
||||
python3.11 backup_n8n_api.py --incremental # 差異備份(只備份變更的)
|
||||
python3.11 backup_n8n_api.py --list # 列出可用備份
|
||||
python3.11 backup_n8n_api.py --verify # 驗證最新備份
|
||||
python3.11 backup_n8n_api.py --stats # 顯示備份統計
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import hashlib
|
||||
import requests
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# ============================================
|
||||
# 配置
|
||||
# ============================================
|
||||
BACKUP_ROOT = Path("/Users/accusys/momentry/backup/n8n_workflows/api")
|
||||
LOG_DIR = Path("/Users/accusys/momentry/log/monitor")
|
||||
TIMESTAMP = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
N8N_BASE_URL = os.environ.get("N8N_BASE_URL", "https://n8n.momentry.ddns.net")
|
||||
N8N_API_KEY = os.environ.get("N8N_API_KEY", "")
|
||||
|
||||
# 顏色
|
||||
RED = "\033[0;31m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
BLUE = "\033[0;34m"
|
||||
NC = "\033[0m"
|
||||
|
||||
# ============================================
|
||||
# 日誌
|
||||
# ============================================
|
||||
LOG_FILE = LOG_DIR / "workflow_backup_api.log"
|
||||
|
||||
|
||||
def log(msg: str, color: str = ""):
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
full_msg = f"[{timestamp}] {msg}"
|
||||
if color:
|
||||
print(f"{color}{full_msg}{NC}")
|
||||
else:
|
||||
print(full_msg)
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOG_FILE, "a") as f:
|
||||
f.write(full_msg + "\n")
|
||||
|
||||
|
||||
def log_info(msg: str):
|
||||
log(msg)
|
||||
|
||||
|
||||
def log_success(msg: str):
|
||||
log(f"✅ {msg}", GREEN)
|
||||
|
||||
|
||||
def log_error(msg: str):
|
||||
log(f"❌ {msg}", RED)
|
||||
|
||||
|
||||
def log_warn(msg: str):
|
||||
log(f"⚠️ {msg}", YELLOW)
|
||||
|
||||
|
||||
# ============================================
|
||||
# n8n API 客戶端
|
||||
# ============================================
|
||||
class N8nAPIClient:
|
||||
def __init__(self, base_url: str, api_key: str):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({"X-N8N-API-Key": api_key})
|
||||
self.session.verify = True
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs):
|
||||
url = f"{self.base_url}/api/v1{path}"
|
||||
resp = self.session.request(method, url, timeout=30, **kwargs)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def list_workflows(self, limit: int = 100, cursor: str = None) -> dict:
|
||||
"""列出 workflows"""
|
||||
params = {"limit": limit}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
return self._request("GET", "/workflows", params=params)
|
||||
|
||||
def get_workflow(self, workflow_id: str) -> dict:
|
||||
"""取得 workflow 詳情"""
|
||||
return self._request("GET", f"/workflows/{workflow_id}")
|
||||
|
||||
def list_tags(self) -> dict:
|
||||
"""列出所有 tags"""
|
||||
return self._request("GET", "/tags")
|
||||
|
||||
def get_workflow_tags(self, workflow_id: str) -> dict:
|
||||
"""取得 workflow 的 tags"""
|
||||
return self._request("GET", f"/workflows/{workflow_id}/tags")
|
||||
|
||||
def list_executions(
|
||||
self, workflow_id: str = None, limit: int = 50, status: str = None
|
||||
) -> dict:
|
||||
"""列出執行記錄"""
|
||||
params: dict = {"limit": limit}
|
||||
if workflow_id:
|
||||
params["workflowId"] = workflow_id
|
||||
if status:
|
||||
params["status"] = status
|
||||
return self._request("GET", "/executions", params=params)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
"""健康檢查"""
|
||||
try:
|
||||
self._request("GET", "/workflows", params={"limit": 1})
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
# ============================================
|
||||
# 備份功能
|
||||
# ============================================
|
||||
def compute_sha256(data) -> str:
|
||||
"""計算 JSON 的 SHA256"""
|
||||
json_str = json.dumps(data, sort_keys=True, ensure_ascii=False)
|
||||
return hashlib.sha256(json_str.encode()).hexdigest()
|
||||
|
||||
|
||||
def save_backup(workflows, tags, backup_dir: Path, metadata: dict = None):
|
||||
"""保存備份到磁盤"""
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 主要備份文件
|
||||
backup_file = backup_dir / "workflows.json"
|
||||
with open(backup_file, "w", encoding="utf-8") as f:
|
||||
json.dump(workflows, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# SHA256 校驗
|
||||
sha256_hash = compute_sha256(workflows)
|
||||
sha256_file = backup_dir / "workflows.json.sha256"
|
||||
with open(sha256_file, "w") as f:
|
||||
f.write(sha256_hash)
|
||||
|
||||
# Tags 文件
|
||||
if tags:
|
||||
tags_file = backup_dir / "tags.json"
|
||||
with open(tags_file, "w", encoding="utf-8") as f:
|
||||
json.dump(tags, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# 元數據文件
|
||||
meta = dict(metadata) if metadata else {}
|
||||
meta.update(
|
||||
{
|
||||
"timestamp": TIMESTAMP,
|
||||
"workflow_count": len(workflows),
|
||||
"sha256": sha256_hash,
|
||||
}
|
||||
)
|
||||
meta_file = backup_dir / "manifest.json"
|
||||
with open(meta_file, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, indent=2, ensure_ascii=False)
|
||||
|
||||
log_success(f"Backup saved: {backup_file} ({len(workflows)} workflows)")
|
||||
return backup_dir
|
||||
|
||||
|
||||
def load_latest_backup() -> tuple:
|
||||
"""載入最新備份"""
|
||||
if not BACKUP_ROOT.exists():
|
||||
return None, None, None
|
||||
|
||||
backup_dirs = sorted([d for d in BACKUP_ROOT.iterdir() if d.is_dir()], reverse=True)
|
||||
if not backup_dirs:
|
||||
return None, None, None
|
||||
|
||||
latest = backup_dirs[0]
|
||||
return load_backup(latest)
|
||||
|
||||
|
||||
def load_backup(backup_dir: Path) -> tuple:
|
||||
"""載入指定備份"""
|
||||
workflows = None
|
||||
tags = None
|
||||
manifest = None
|
||||
|
||||
workflows_file = backup_dir / "workflows.json"
|
||||
tags_file = backup_dir / "tags.json"
|
||||
manifest_file = backup_dir / "manifest.json"
|
||||
|
||||
if workflows_file.exists():
|
||||
with open(workflows_file) as f:
|
||||
workflows = json.load(f)
|
||||
|
||||
if tags_file.exists():
|
||||
with open(tags_file) as f:
|
||||
tags = json.load(f)
|
||||
|
||||
if manifest_file.exists():
|
||||
with open(manifest_file) as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
return workflows, tags, manifest
|
||||
|
||||
|
||||
def list_backups() -> list:
|
||||
"""列出所有可用備份"""
|
||||
if not BACKUP_ROOT.exists():
|
||||
return []
|
||||
|
||||
backups = []
|
||||
for d in sorted(BACKUP_ROOT.iterdir(), reverse=True):
|
||||
if d.is_dir():
|
||||
manifest_file = d / "manifest.json"
|
||||
if manifest_file.exists():
|
||||
with open(manifest_file) as f:
|
||||
meta = json.load(f)
|
||||
backups.append(
|
||||
{
|
||||
"path": d,
|
||||
"name": d.name,
|
||||
"timestamp": meta.get("timestamp"),
|
||||
"workflow_count": meta.get("workflow_count", 0),
|
||||
"sha256": meta.get("sha256"),
|
||||
}
|
||||
)
|
||||
return backups
|
||||
|
||||
|
||||
def detect_changes(current: list, previous: list) -> dict:
|
||||
"""偵測 workflow 變更"""
|
||||
current_map = {wf["id"]: wf for wf in current}
|
||||
previous_map = {wf["id"]: wf for wf in previous} if previous else {}
|
||||
|
||||
changes = {
|
||||
"added": [],
|
||||
"modified": [],
|
||||
"deleted": [],
|
||||
}
|
||||
|
||||
# 新增或修改
|
||||
for wf_id, wf in current_map.items():
|
||||
if wf_id not in previous_map:
|
||||
changes["added"].append(wf["name"])
|
||||
else:
|
||||
current_hash = compute_sha256(wf)
|
||||
previous_hash = compute_sha256(previous_map[wf_id])
|
||||
if current_hash != previous_hash:
|
||||
changes["modified"].append(wf["name"])
|
||||
|
||||
# 刪除
|
||||
for wf_id in previous_map:
|
||||
if wf_id not in current_map:
|
||||
changes["deleted"].append(previous_map[wf_id]["name"])
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
def print_changes(changes: dict):
|
||||
"""打印變更摘要"""
|
||||
print(f"\n{BLUE}{'=' * 50}{NC}")
|
||||
print(f"{BLUE}n8n Workflow 變更偵測{NC}")
|
||||
print(f"{BLUE}{'=' * 50}{NC}\n")
|
||||
|
||||
if changes["added"]:
|
||||
print(f"{GREEN}+ 新增 ({len(changes['added'])}):{NC}")
|
||||
for name in changes["added"]:
|
||||
print(f" - {name}")
|
||||
print()
|
||||
|
||||
if changes["modified"]:
|
||||
print(f"{YELLOW}~ 修改 ({len(changes['modified'])}):{NC}")
|
||||
for name in changes["modified"]:
|
||||
print(f" - {name}")
|
||||
print()
|
||||
|
||||
if changes["deleted"]:
|
||||
print(f"{RED}- 刪除 ({len(changes['deleted'])}):{NC}")
|
||||
for name in changes["deleted"]:
|
||||
print(f" - {name}")
|
||||
print()
|
||||
|
||||
if not any(changes.values()):
|
||||
print(f"{GREEN}✅ 無變更{NC}\n")
|
||||
|
||||
|
||||
def get_changed_workflows(current: list, previous: list) -> list:
|
||||
"""取得變更的 workflows"""
|
||||
if not previous:
|
||||
return current
|
||||
|
||||
current_map = {wf["id"]: wf for wf in current}
|
||||
previous_map = {wf["id"]: wf for wf in previous}
|
||||
|
||||
changed = []
|
||||
for wf in current:
|
||||
wf_id = wf["id"]
|
||||
if wf_id not in previous_map:
|
||||
changed.append(wf)
|
||||
else:
|
||||
current_hash = compute_sha256(wf)
|
||||
previous_hash = compute_sha256(previous_map[wf_id])
|
||||
if current_hash != previous_hash:
|
||||
changed.append(wf)
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
def verify_backup(backup_dir: Path) -> dict:
|
||||
"""驗證備份完整性"""
|
||||
result = {
|
||||
"valid": True,
|
||||
"errors": [],
|
||||
"workflow_count": 0,
|
||||
"sha256_match": False,
|
||||
}
|
||||
|
||||
workflows_file = backup_dir / "workflows.json"
|
||||
sha256_file = backup_dir / "workflows.json.sha256"
|
||||
manifest_file = backup_dir / "manifest.json"
|
||||
|
||||
if not workflows_file.exists():
|
||||
result["valid"] = False
|
||||
result["errors"].append("workflows.json 不存在")
|
||||
return result
|
||||
|
||||
if not sha256_file.exists():
|
||||
result["valid"] = False
|
||||
result["errors"].append("workflows.json.sha256 不存在")
|
||||
return result
|
||||
|
||||
if not manifest_file.exists():
|
||||
result["valid"] = False
|
||||
result["errors"].append("manifest.json 不存在")
|
||||
return result
|
||||
|
||||
with open(workflows_file) as f:
|
||||
workflows = json.load(f)
|
||||
|
||||
with open(sha256_file) as f:
|
||||
stored_hash = f.read().strip()
|
||||
|
||||
current_hash = compute_sha256(workflows)
|
||||
result["sha256_match"] = current_hash == stored_hash
|
||||
result["workflow_count"] = len(workflows)
|
||||
|
||||
if not result["sha256_match"]:
|
||||
result["valid"] = False
|
||||
result["errors"].append(
|
||||
f"SHA256 不匹配: 預期 {stored_hash}, 實際 {current_hash}"
|
||||
)
|
||||
|
||||
with open(manifest_file) as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
expected_count = manifest.get("workflow_count", 0)
|
||||
if expected_count != len(workflows):
|
||||
result["valid"] = False
|
||||
result["errors"].append(
|
||||
f"數量不匹配: manifest={expected_count}, 實際={len(workflows)}"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def print_backup_stats():
|
||||
"""顯示備份統計"""
|
||||
backups = list_backups()
|
||||
|
||||
if not backups:
|
||||
print(f"\n{BLUE}沒有可用備份{NC}\n")
|
||||
return
|
||||
|
||||
print(f"\n{BLUE}{'=' * 60}{NC}")
|
||||
print(f"{BLUE}n8n Workflow 備份統計{NC}")
|
||||
print(f"{BLUE}{'=' * 60}{NC}\n")
|
||||
|
||||
total_workflows = sum(b["workflow_count"] for b in backups)
|
||||
print(f" 備份數量: {len(backups)}")
|
||||
print(f" 總 workflows: {total_workflows}")
|
||||
print(f" 最新備份: {backups[0]['name'] if backups else 'N/A'}")
|
||||
print()
|
||||
|
||||
print(f"{'時間戳':<25} {'Workflows':<12} {'SHA256 前 16 字元'}")
|
||||
print("-" * 60)
|
||||
for b in backups[:10]:
|
||||
ts = b.get("timestamp", "N/A") or "N/A"
|
||||
count = b.get("workflow_count", 0)
|
||||
sha = (b.get("sha256", "") or "")[:16]
|
||||
print(f"{ts:<25} {count:<12} {sha}")
|
||||
|
||||
|
||||
# ============================================
|
||||
# 主程式
|
||||
# ============================================
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="n8n Workflow 備份 (REST API)")
|
||||
parser.add_argument(
|
||||
"--active-only", action="store_true", help="只備份啟用的 workflows"
|
||||
)
|
||||
parser.add_argument("--diff", action="store_true", help="只顯示變更,不備份")
|
||||
parser.add_argument(
|
||||
"--incremental", action="store_true", help="差異備份(只備份變更的)"
|
||||
)
|
||||
parser.add_argument("--list", action="store_true", help="列出可用備份")
|
||||
parser.add_argument("--verify", action="store_true", help="驗證最新備份")
|
||||
parser.add_argument("--stats", action="store_true", help="顯示備份統計")
|
||||
parser.add_argument(
|
||||
"--failed-only", action="store_true", help="只備份失敗的執行記錄"
|
||||
)
|
||||
parser.add_argument("--dry-run", action="store_true", help="測試模式,不實際備份")
|
||||
args = parser.parse_args()
|
||||
|
||||
log_info("=" * 50)
|
||||
log_info("n8n Workflow 備份 (REST API)")
|
||||
log_info("=" * 50)
|
||||
|
||||
# 顯示統計
|
||||
if args.stats:
|
||||
print_backup_stats()
|
||||
return
|
||||
|
||||
# 驗證備份
|
||||
if args.verify:
|
||||
backups = list_backups()
|
||||
if not backups:
|
||||
log_error("沒有可用備份")
|
||||
sys.exit(1)
|
||||
|
||||
latest = backups[0]["path"]
|
||||
log_info(f"驗證備份: {latest.name}")
|
||||
result = verify_backup(latest)
|
||||
|
||||
if result["valid"]:
|
||||
log_success(f"備份有效: {result['workflow_count']} workflows, SHA256 匹配")
|
||||
else:
|
||||
log_error(f"備份無效: {result['errors']}")
|
||||
sys.exit(1)
|
||||
return
|
||||
|
||||
# 列出可用備份
|
||||
if args.list:
|
||||
print(f"\n{BLUE}可用備份:{NC}\n")
|
||||
backups = list_backups()
|
||||
if backups:
|
||||
for b in backups[:10]:
|
||||
ts = b.get("timestamp", "") or ""
|
||||
count = b.get("workflow_count", 0)
|
||||
print(f" {b['name']} - {count} workflows ({ts})")
|
||||
else:
|
||||
print(" 無可用備份")
|
||||
print()
|
||||
return
|
||||
|
||||
# 創建 API 客戶端
|
||||
if not N8N_API_KEY:
|
||||
log_error("N8N_API_KEY 環境變數未設定")
|
||||
sys.exit(1)
|
||||
|
||||
client = N8nAPIClient(N8N_BASE_URL, N8N_API_KEY)
|
||||
|
||||
# 健康檢查
|
||||
try:
|
||||
if client.health_check():
|
||||
log_success("n8n API 連線正常")
|
||||
else:
|
||||
log_error("n8n API 連線失敗")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
log_error(f"連線檢查失敗: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 獲取 workflows
|
||||
log_info("獲取 workflows...")
|
||||
try:
|
||||
all_workflows = []
|
||||
cursor = None
|
||||
while True:
|
||||
result = client.list_workflows(limit=100, cursor=cursor)
|
||||
all_workflows.extend(result.get("data", []))
|
||||
cursor = result.get("nextCursor")
|
||||
if not cursor:
|
||||
break
|
||||
|
||||
# 過濾
|
||||
if args.active_only:
|
||||
all_workflows = [wf for wf in all_workflows if wf.get("active")]
|
||||
|
||||
log_info(f"找到 {len(all_workflows)} workflows")
|
||||
except Exception as e:
|
||||
log_error(f"獲取 workflows 失敗: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 獲取 tags
|
||||
log_info("獲取 tags...")
|
||||
try:
|
||||
tags_result = client.list_tags()
|
||||
tags = tags_result.get("data", [])
|
||||
log_info(f"找到 {len(tags)} tags")
|
||||
except Exception as e:
|
||||
log_warn(f"獲取 tags 失敗: {e}")
|
||||
tags = []
|
||||
|
||||
# 變更偵測
|
||||
log_info("檢查變更...")
|
||||
previous_wf, _, _ = load_latest_backup()
|
||||
changes = detect_changes(all_workflows, previous_wf)
|
||||
|
||||
if args.diff:
|
||||
print_changes(changes)
|
||||
return
|
||||
|
||||
# 顯示變更摘要
|
||||
total_changes = sum(len(v) for v in changes.values())
|
||||
if total_changes > 0:
|
||||
print_changes(changes)
|
||||
log_warn(f"發現 {total_changes} 個變更")
|
||||
else:
|
||||
log_info("✅ 無變更")
|
||||
|
||||
# 測試模式
|
||||
if args.dry_run:
|
||||
log_info("測試模式結束")
|
||||
return
|
||||
|
||||
# 差異備份
|
||||
if args.incremental:
|
||||
if not previous_wf:
|
||||
log_warn("沒有舊備份,執行完整備份")
|
||||
backup_workflows = all_workflows
|
||||
else:
|
||||
backup_workflows = get_changed_workflows(all_workflows, previous_wf)
|
||||
if not backup_workflows:
|
||||
log_info("✅ 沒有變更,跳過備份")
|
||||
return
|
||||
log_info(f"差異備份: {len(backup_workflows)} workflows")
|
||||
else:
|
||||
backup_workflows = all_workflows
|
||||
|
||||
# 執行備份
|
||||
backup_dir = BACKUP_ROOT / TIMESTAMP
|
||||
metadata = {
|
||||
"active_only": args.active_only,
|
||||
"incremental": args.incremental,
|
||||
"changes": changes if total_changes > 0 else None,
|
||||
"backup_count": len(backup_workflows),
|
||||
"total_count": len(all_workflows),
|
||||
}
|
||||
|
||||
save_backup(backup_workflows, tags, backup_dir, metadata)
|
||||
|
||||
# 執行記錄備份
|
||||
if args.failed_only:
|
||||
log_info("備份失敗的執行記錄...")
|
||||
try:
|
||||
executions_data = []
|
||||
for wf in backup_workflows[:10]:
|
||||
try:
|
||||
execs_result = client.list_executions(
|
||||
wf["id"], limit=20, status="failed"
|
||||
)
|
||||
if execs_result.get("data"):
|
||||
executions_data.append(
|
||||
{
|
||||
"workflow_id": wf["id"],
|
||||
"workflow_name": wf["name"],
|
||||
"failed_executions": len(execs_result.get("data", [])),
|
||||
"executions": execs_result.get("data", []),
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if executions_data:
|
||||
exec_dir = backup_dir / "executions"
|
||||
exec_dir.mkdir(exist_ok=True)
|
||||
with open(exec_dir / "failed_executions.json", "w") as f:
|
||||
json.dump(executions_data, f, indent=2)
|
||||
log_success(f"失敗執行記錄已保存: {len(executions_data)} workflows")
|
||||
else:
|
||||
log_info("沒有失敗的執行記錄")
|
||||
except Exception as e:
|
||||
log_warn(f"執行記錄備份失敗: {e}")
|
||||
|
||||
# 清理舊備份(保留 30 天)
|
||||
log_info("清理舊備份...")
|
||||
try:
|
||||
import shutil
|
||||
|
||||
cutoff = datetime.now().timestamp() - (30 * 24 * 60 * 60)
|
||||
for d in BACKUP_ROOT.iterdir():
|
||||
if d.is_dir() and d.stat().st_mtime < cutoff:
|
||||
shutil.rmtree(d)
|
||||
log_info(f"已刪除舊備份: {d.name}")
|
||||
except Exception as e:
|
||||
log_warn(f"清理失敗: {e}")
|
||||
|
||||
log_success("備份完成!")
|
||||
print(f"\n備份位置: {backup_dir}")
|
||||
print(f"備份數量: {len(backup_workflows)} / {len(all_workflows)} workflows")
|
||||
print(f"SHA256: {compute_sha256(backup_workflows)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
481
monitor/workflow/backup_n8n_mcp.py
Normal file
481
monitor/workflow/backup_n8n_mcp.py
Normal file
@@ -0,0 +1,481 @@
|
||||
#!/usr/bin/env python3.11
|
||||
"""
|
||||
n8n Workflow 備份腳本 - 使用 n8n MCP API
|
||||
路徑: /Users/accusys/momentry_core_0.1/monitor/workflow/backup_n8n_mcp.py
|
||||
|
||||
功能:
|
||||
- 使用 n8n MCP API 導出所有 workflows
|
||||
- 按用戶/Tags 分組備份
|
||||
- 變更偵測
|
||||
- SHA256 校驗
|
||||
|
||||
前置需求:
|
||||
pip3.11 install mcp
|
||||
|
||||
使用方式:
|
||||
python3.11 backup_n8n_mcp.py # 備份所有 workflows
|
||||
python3.11 backup_n8n_mcp.py --tags prod # 只備份有 prod tag 的 workflow
|
||||
python3.11 backup_n8n_mcp.py --diff # 只顯示變更
|
||||
python3.11 backup_n8n_mcp.py --list # 列出可用備份
|
||||
python3.11 backup_n8n_mcp.py --audit # 產生安全審計報告
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
import hashlib
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
# 嘗試載入 MCP SDK
|
||||
try:
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
MCP_AVAILABLE = True
|
||||
except ImportError:
|
||||
MCP_AVAILABLE = False
|
||||
print("Warning: MCP SDK not available. Install with: pip install mcp")
|
||||
|
||||
# ============================================
|
||||
# 配置
|
||||
# ============================================
|
||||
BACKUP_ROOT = Path("/Users/accusys/momentry/backup/n8n_workflows/mcp")
|
||||
LOG_DIR = Path("/Users/accusys/momentry/log/monitor")
|
||||
TIMESTAMP = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
# 顏色
|
||||
RED = "\033[0;31m"
|
||||
GREEN = "\033[0;32m"
|
||||
YELLOW = "\033[1;33m"
|
||||
BLUE = "\033[0;34m"
|
||||
NC = "\033[0m"
|
||||
|
||||
# ============================================
|
||||
# 日誌
|
||||
# ============================================
|
||||
LOG_FILE = LOG_DIR / "workflow_backup_mcp.log"
|
||||
|
||||
|
||||
def log(msg: str, color: str = ""):
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
full_msg = f"[{timestamp}] {msg}"
|
||||
if color:
|
||||
print(f"{color}{full_msg}{NC}")
|
||||
else:
|
||||
print(full_msg)
|
||||
LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(LOG_FILE, "a") as f:
|
||||
f.write(full_msg + "\n")
|
||||
|
||||
|
||||
def log_info(msg: str):
|
||||
log(msg)
|
||||
|
||||
|
||||
def log_success(msg: str):
|
||||
log(f"✅ {msg}", GREEN)
|
||||
|
||||
|
||||
def log_error(msg: str):
|
||||
log(f"❌ {msg}", RED)
|
||||
|
||||
|
||||
def log_warn(msg: str):
|
||||
log(f"⚠️ {msg}", YELLOW)
|
||||
|
||||
|
||||
# ============================================
|
||||
# MCP 客戶端
|
||||
# ============================================
|
||||
class N8nMCPClient:
|
||||
def __init__(self):
|
||||
self.session = None
|
||||
self.tools = {}
|
||||
|
||||
async def connect(self):
|
||||
if not MCP_AVAILABLE:
|
||||
raise RuntimeError("MCP SDK not available")
|
||||
|
||||
server_params = StdioServerParameters(
|
||||
command="/opt/homebrew/bin/mcp-n8n",
|
||||
env={
|
||||
"N8N_BASE_URL": "https://n8n.momentry.ddns.net",
|
||||
"N8N_API_KEY": os.environ.get("N8N_API_KEY", ""),
|
||||
},
|
||||
)
|
||||
|
||||
async with stdio_client(server_params) as (read, write):
|
||||
self.session = ClientSession(read, write)
|
||||
await self.session.initialize()
|
||||
|
||||
# 獲取可用工具列表
|
||||
tools_result = await self.session.list_tools()
|
||||
for tool in tools_result.tools:
|
||||
self.tools[tool.name] = tool
|
||||
|
||||
log_info(f"Connected to n8n MCP, available tools: {len(self.tools)}")
|
||||
|
||||
async def call_tool(self, tool_name: str, arguments: dict = None):
|
||||
if not self.session:
|
||||
raise RuntimeError("Not connected")
|
||||
|
||||
result = await self.session.call_tool(tool_name, arguments or {})
|
||||
return result.content
|
||||
|
||||
async def list_workflows(
|
||||
self, tags: list = None, active: bool = None, limit: int = 100
|
||||
):
|
||||
"""列出 workflows"""
|
||||
args = {"limit": limit}
|
||||
if tags:
|
||||
args["tags"] = tags
|
||||
if active is not None:
|
||||
args["active"] = active
|
||||
|
||||
content = await self.call_tool("n8n_list_workflows", args)
|
||||
data = json.loads(content[0].text)
|
||||
return data.get("data", [])
|
||||
|
||||
async def get_workflow(self, workflow_id: str):
|
||||
"""取得 workflow 詳情"""
|
||||
content = await self.call_tool("n8n_get_workflow", {"workflowId": workflow_id})
|
||||
data = json.loads(content[0].text)
|
||||
return data
|
||||
|
||||
async def list_tags(self):
|
||||
"""列出所有 tags"""
|
||||
content = await self.call_tool("n8n_list_tags")
|
||||
data = json.loads(content[0].text)
|
||||
return data.get("data", [])
|
||||
|
||||
async def get_workflow_tags(self, workflow_id: str):
|
||||
"""取得 workflow 的 tags"""
|
||||
content = await self.call_tool(
|
||||
"n8n_get_workflow_tags", {"workflowId": workflow_id}
|
||||
)
|
||||
data = json.loads(content[0].text)
|
||||
return data
|
||||
|
||||
async def list_executions(self, workflow_id: str = None, limit: int = 50):
|
||||
"""列出執行記錄"""
|
||||
args = {"limit": limit}
|
||||
if workflow_id:
|
||||
args["workflowId"] = workflow_id
|
||||
|
||||
content = await self.call_tool("n8n_list_executions", args)
|
||||
data = json.loads(content[0].text)
|
||||
return data.get("data", [])
|
||||
|
||||
async def generate_audit(self):
|
||||
"""產生安全審計報告"""
|
||||
content = await self.call_tool("n8n_generate_audit", {})
|
||||
data = json.loads(content[0].text)
|
||||
return data
|
||||
|
||||
async def health_check(self):
|
||||
"""健康檢查"""
|
||||
content = await self.call_tool("n8n_health_check", {})
|
||||
return content[0].text if content else "Unknown"
|
||||
|
||||
|
||||
# ============================================
|
||||
# 備份功能
|
||||
# ============================================
|
||||
def compute_sha256(data: dict) -> str:
|
||||
"""計算 JSON 的 SHA256"""
|
||||
json_str = json.dumps(data, sort_keys=True, ensure_ascii=False)
|
||||
return hashlib.sha256(json_str.encode()).hexdigest()
|
||||
|
||||
|
||||
def save_backup(workflows: list, tags: list, backup_dir: Path, metadata: dict = None):
|
||||
"""保存備份到磁盤"""
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# 主要備份文件
|
||||
backup_file = backup_dir / "workflows.json"
|
||||
with open(backup_file, "w", encoding="utf-8") as f:
|
||||
json.dump(workflows, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# SHA256 校驗
|
||||
sha256_hash = compute_sha256(workflows)
|
||||
sha256_file = backup_dir / "workflows.json.sha256"
|
||||
with open(sha256_file, "w") as f:
|
||||
f.write(sha256_hash)
|
||||
|
||||
# Tags 文件
|
||||
if tags:
|
||||
tags_file = backup_dir / "tags.json"
|
||||
with open(tags_file, "w", encoding="utf-8") as f:
|
||||
json.dump(tags, f, indent=2, ensure_ascii=False)
|
||||
|
||||
# 元數據文件
|
||||
meta = metadata or {}
|
||||
meta.update(
|
||||
{
|
||||
"timestamp": TIMESTAMP,
|
||||
"workflow_count": len(workflows),
|
||||
"sha256": sha256_hash,
|
||||
}
|
||||
)
|
||||
meta_file = backup_dir / "manifest.json"
|
||||
with open(meta_file, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, indent=2, ensure_ascii=False)
|
||||
|
||||
log_success(f"Backup saved: {backup_file} ({len(workflows)} workflows)")
|
||||
return backup_dir
|
||||
|
||||
|
||||
def load_latest_backup() -> tuple:
|
||||
"""載入最新備份"""
|
||||
if not BACKUP_ROOT.exists():
|
||||
return None, None, None
|
||||
|
||||
# 找到最新的備份目錄
|
||||
backup_dirs = sorted([d for d in BACKUP_ROOT.iterdir() if d.is_dir()], reverse=True)
|
||||
if not backup_dirs:
|
||||
return None, None, None
|
||||
|
||||
latest = backup_dirs[0]
|
||||
workflows_file = latest / "workflows.json"
|
||||
tags_file = latest / "tags.json"
|
||||
manifest_file = latest / "manifest.json"
|
||||
|
||||
workflows = None
|
||||
tags = None
|
||||
manifest = None
|
||||
|
||||
if workflows_file.exists():
|
||||
with open(workflows_file) as f:
|
||||
workflows = json.load(f)
|
||||
|
||||
if tags_file.exists():
|
||||
with open(tags_file) as f:
|
||||
tags = json.load(f)
|
||||
|
||||
if manifest_file.exists():
|
||||
with open(manifest_file) as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
return workflows, tags, manifest
|
||||
|
||||
|
||||
def detect_changes(current: list, previous: list) -> dict:
|
||||
"""偵測 workflow 變更"""
|
||||
current_map = {wf["id"]: wf for wf in current}
|
||||
previous_map = {wf["id"]: wf for wf in previous} if previous else {}
|
||||
|
||||
changes = {
|
||||
"added": [],
|
||||
"modified": [],
|
||||
"deleted": [],
|
||||
}
|
||||
|
||||
# 新增或修改
|
||||
for wf_id, wf in current_map.items():
|
||||
if wf_id not in previous_map:
|
||||
changes["added"].append(wf["name"])
|
||||
else:
|
||||
current_hash = compute_sha256(wf)
|
||||
previous_hash = compute_sha256(previous_map[wf_id])
|
||||
if current_hash != previous_hash:
|
||||
changes["modified"].append(wf["name"])
|
||||
|
||||
# 刪除
|
||||
for wf_id in previous_map:
|
||||
if wf_id not in current_map:
|
||||
changes["deleted"].append(previous_map[wf_id]["name"])
|
||||
|
||||
return changes
|
||||
|
||||
|
||||
def print_changes(changes: dict):
|
||||
"""打印變更摘要"""
|
||||
print(f"\n{BLUE}{'=' * 50}{NC}")
|
||||
print(f"{BLUE}n8n Workflow 變更偵測{NC}")
|
||||
print(f"{BLUE}{'=' * 50}{NC}\n")
|
||||
|
||||
if changes["added"]:
|
||||
print(f"{GREEN}+ 新增 ({len(changes['added'])}):{NC}")
|
||||
for name in changes["added"]:
|
||||
print(f" - {name}")
|
||||
print()
|
||||
|
||||
if changes["modified"]:
|
||||
print(f"{YELLOW}~ 修改 ({len(changes['modified'])}):{NC}")
|
||||
for name in changes["modified"]:
|
||||
print(f" - {name}")
|
||||
print()
|
||||
|
||||
if changes["deleted"]:
|
||||
print(f"{RED}- 刪除 ({len(changes['deleted'])}):{NC}")
|
||||
for name in changes["deleted"]:
|
||||
print(f" - {name}")
|
||||
print()
|
||||
|
||||
if not any(changes.values()):
|
||||
print(f"{GREEN}✅ 無變更{NC}\n")
|
||||
|
||||
|
||||
# ============================================
|
||||
# 主程式
|
||||
# ============================================
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="n8n Workflow 備份 (MCP API)")
|
||||
parser.add_argument("--tags", nargs="*", help="只備份有特定 tags 的 workflows")
|
||||
parser.add_argument(
|
||||
"--active-only", action="store_true", help="只備份啟用的 workflows"
|
||||
)
|
||||
parser.add_argument("--diff", action="store_true", help="只顯示變更,不備份")
|
||||
parser.add_argument("--list", action="store_true", help="列出可用備份")
|
||||
parser.add_argument("--audit", action="store_true", help="產生安全審計報告")
|
||||
parser.add_argument("--executions", action="store_true", help="同時備份執行記錄")
|
||||
parser.add_argument("--dry-run", action="store_true", help="測試模式,不實際備份")
|
||||
args = parser.parse_args()
|
||||
|
||||
log_info("=" * 50)
|
||||
log_info("n8n Workflow 備份 (MCP API)")
|
||||
log_info("=" * 50)
|
||||
|
||||
# 列出可用備份
|
||||
if args.list:
|
||||
print(f"\n{BLUE}可用備份:{NC}\n")
|
||||
if BACKUP_ROOT.exists():
|
||||
for d in sorted(BACKUP_ROOT.iterdir(), reverse=True)[:10]:
|
||||
manifest_file = d / "manifest.json"
|
||||
if manifest_file.exists():
|
||||
with open(manifest_file) as f:
|
||||
meta = json.load(f)
|
||||
print(f" {d.name} - {meta.get('workflow_count', 0)} workflows")
|
||||
else:
|
||||
print(" 無可用備份")
|
||||
print()
|
||||
return
|
||||
|
||||
# 連接 MCP
|
||||
client = N8nMCPClient()
|
||||
try:
|
||||
await client.connect()
|
||||
except Exception as e:
|
||||
log_error(f"連接失敗: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 健康檢查
|
||||
try:
|
||||
health = await client.health_check()
|
||||
log_info(f"健康狀態: {health}")
|
||||
except Exception as e:
|
||||
log_warn(f"健康檢查失敗: {e}")
|
||||
|
||||
# 安全審計
|
||||
if args.audit:
|
||||
log_info("產生安全審計報告...")
|
||||
try:
|
||||
audit = await client.generate_audit()
|
||||
audit_dir = BACKUP_ROOT / TIMESTAMP / "audit"
|
||||
audit_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(audit_dir / "audit.json", "w") as f:
|
||||
json.dump(audit, f, indent=2)
|
||||
log_success("審計報告已保存")
|
||||
except Exception as e:
|
||||
log_error(f"審計失敗: {e}")
|
||||
return
|
||||
|
||||
# 獲取 workflows
|
||||
log_info("獲取 workflows...")
|
||||
try:
|
||||
workflows = await client.list_workflows(
|
||||
tags=args.tags, active=True if args.active_only else None
|
||||
)
|
||||
log_info(f"找到 {len(workflows)} workflows")
|
||||
except Exception as e:
|
||||
log_error(f"獲取 workflows 失敗: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 獲取 tags
|
||||
log_info("獲取 tags...")
|
||||
try:
|
||||
tags = await client.list_tags()
|
||||
log_info(f"找到 {len(tags)} tags")
|
||||
except Exception as e:
|
||||
log_warn(f"獲取 tags 失敗: {e}")
|
||||
tags = []
|
||||
|
||||
# 變更偵測
|
||||
log_info("檢查變更...")
|
||||
previous_wf, _, _ = load_latest_backup()
|
||||
changes = detect_changes(workflows, previous_wf)
|
||||
|
||||
if args.diff:
|
||||
print_changes(changes)
|
||||
return
|
||||
|
||||
# 顯示變更摘要
|
||||
total_changes = sum(len(v) for v in changes.values())
|
||||
if total_changes > 0:
|
||||
print_changes(changes)
|
||||
log_warn(f"發現 {total_changes} 個變更")
|
||||
else:
|
||||
log_info("✅ 無變更")
|
||||
|
||||
# 測試模式
|
||||
if args.dry_run:
|
||||
log_info("測試模式結束")
|
||||
return
|
||||
|
||||
# 執行備份
|
||||
backup_dir = BACKUP_ROOT / TIMESTAMP
|
||||
metadata = {
|
||||
"filter_tags": args.tags,
|
||||
"active_only": args.active_only,
|
||||
"changes": changes if total_changes > 0 else None,
|
||||
}
|
||||
|
||||
save_backup(workflows, tags, backup_dir, metadata)
|
||||
|
||||
# 執行記錄備份
|
||||
if args.executions:
|
||||
log_info("備份執行記錄...")
|
||||
try:
|
||||
executions_data = []
|
||||
for wf in workflows[:10]: # 只備份前 10 個 workflow
|
||||
execs = await client.list_executions(wf["id"], limit=20)
|
||||
executions_data.append(
|
||||
{
|
||||
"workflow_id": wf["id"],
|
||||
"workflow_name": wf["name"],
|
||||
"executions": execs[:10], # 每個只取 10 筆
|
||||
}
|
||||
)
|
||||
|
||||
exec_dir = backup_dir / "executions"
|
||||
exec_dir.mkdir(exist_ok=True)
|
||||
with open(exec_dir / "executions.json", "w") as f:
|
||||
json.dump(executions_data, f, indent=2)
|
||||
log_success(f"執行記錄已保存: {len(executions_data)} workflows")
|
||||
except Exception as e:
|
||||
log_warn(f"執行記錄備份失敗: {e}")
|
||||
|
||||
# 清理舊備份(保留 30 天)
|
||||
log_info("清理舊備份...")
|
||||
try:
|
||||
cutoff = datetime.now().timestamp() - (30 * 24 * 60 * 60)
|
||||
for d in BACKUP_ROOT.iterdir():
|
||||
if d.is_dir() and d.stat().st_mtime < cutoff:
|
||||
import shutil
|
||||
|
||||
shutil.rmtree(d)
|
||||
log_info(f"已刪除舊備份: {d.name}")
|
||||
except Exception as e:
|
||||
log_warn(f"清理失敗: {e}")
|
||||
|
||||
log_success("備份完成!")
|
||||
print(f"\n備份位置: {backup_dir}")
|
||||
print(f"SHA256: {compute_sha256(workflows)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
asyncio.run(main())
|
||||
376
monitor/workflow/backup_n8n_workflows.sh
Executable file
376
monitor/workflow/backup_n8n_workflows.sh
Executable file
@@ -0,0 +1,376 @@
|
||||
#!/bin/bash
|
||||
# Momentry n8n Workflow 備份腳本
|
||||
# 路徑: /Users/accusys/momentry_core_0.1/monitor/workflow/backup_n8n_workflows.sh
|
||||
#
|
||||
# 功能:
|
||||
# - 導出所有 workflows 為 n8n 原生 JSON 格式
|
||||
# - 按用戶分組備份
|
||||
# - 版本化存儲
|
||||
#
|
||||
# 使用方式:
|
||||
# ./backup_n8n_workflows.sh # 備份所有 workflows
|
||||
# ./backup_n8n_workflows.sh daily # 每日增量備份
|
||||
# ./backup_n8n_workflows.sh full # 完整備份
|
||||
# ./backup_n8n_workflows.sh restore # 列出可恢復版本
|
||||
|
||||
set -e
|
||||
|
||||
# ============================================
|
||||
# 配置
|
||||
# ============================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
BACKUP_ROOT="/Users/accusys/momentry/backup/n8n_workflows"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
LOG_DIR="/Users/accusys/momentry/log/monitor"
|
||||
|
||||
# 載入密碼配置
|
||||
MONITOR_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
if [ -f "$MONITOR_DIR/common/load_credentials.sh" ]; then
|
||||
source "$MONITOR_DIR/common/load_credentials.sh"
|
||||
fi
|
||||
|
||||
# n8n 數據庫連接 (使用 n8n 用戶)
|
||||
N8N_PG_USER="${N8N_PG_USER:-n8n}"
|
||||
N8N_PG_PASSWORD="${N8N_PG_PASSWORD:-accusys}"
|
||||
N8N_PG_HOST="${N8N_PG_HOST:-localhost}"
|
||||
N8N_PG_DB="${N8N_PG_DB:-n8n}"
|
||||
|
||||
# 確保目錄存在
|
||||
mkdir -p "$LOG_DIR"
|
||||
mkdir -p "$BACKUP_ROOT"
|
||||
|
||||
# ============================================
|
||||
# 顏色
|
||||
# ============================================
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# ============================================
|
||||
# 日誌函數
|
||||
# ============================================
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_DIR/workflow_backup.log"
|
||||
}
|
||||
|
||||
log_success() {
|
||||
echo -e "${GREEN}[$(date '+%Y-%m-%d %H:%M:%S')] ✅ $1${NC}" | tee -a "$LOG_DIR/workflow_backup.log"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
echo -e "${RED}[$(date '+%Y-%m-%d %H:%M:%S')] ❌ $1${NC}" | tee -a "$LOG_DIR/workflow_backup.log"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
echo -e "${YELLOW}[$(date '+%Y-%m-%d %H:%M:%S')] ⚠️ $1${NC}" | tee -a "$LOG_DIR/workflow_backup.log"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 導出單個 Workflow (n8n 原生格式)
|
||||
# ============================================
|
||||
export_workflow() {
|
||||
local wf_id=$1
|
||||
local output_file=$2
|
||||
|
||||
# 查詢 workflow 數據 (包含所有 n8n 需要的欄位)
|
||||
PGPASSWORD="$N8N_PG_PASSWORD" psql -U "$N8N_PG_USER" -h "$N8N_PG_HOST" -d "$N8N_PG_DB" -t -A -c "
|
||||
SELECT json_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'active', active,
|
||||
'nodes', nodes,
|
||||
'connections', connections,
|
||||
'settings', COALESCE(settings, '{}'),
|
||||
'staticData', COALESCE(\"staticData\", 'null'),
|
||||
'pinData', COALESCE(\"pinData\", 'null'),
|
||||
'versionId', \"versionId\",
|
||||
'triggerCount', \"triggerCount\",
|
||||
'createdAt', \"createdAt\",
|
||||
'updatedAt', \"updatedAt\",
|
||||
'isArchived', \"isArchived\",
|
||||
'versionCounter', \"versionCounter\",
|
||||
'description', COALESCE(description, ''),
|
||||
'meta', COALESCE(meta, '{}')
|
||||
)
|
||||
FROM workflow_entity
|
||||
WHERE id = '$wf_id';
|
||||
" > "$output_file" 2>&1
|
||||
|
||||
# 移除錯誤輸出
|
||||
if grep -q "ERROR:" "$output_file" 2>/dev/null; then
|
||||
sed -i '/ERROR:/d' "$output_file"
|
||||
fi
|
||||
|
||||
if [ -s "$output_file" ]; then
|
||||
return 0
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 按用戶導出 Workflows
|
||||
# ============================================
|
||||
export_workflows_by_user() {
|
||||
local user_id=$1
|
||||
local user_email=$2
|
||||
local output_dir=$3
|
||||
|
||||
log "導出用戶 $user_email 的 workflows..."
|
||||
|
||||
mkdir -p "$output_dir"
|
||||
|
||||
local count=0
|
||||
|
||||
# 獲取該用戶的 workflows
|
||||
workflows=$(PGPASSWORD="$N8N_PG_PASSWORD" psql -U "$N8N_PG_USER" -h "$N8N_PG_HOST" -d "$N8N_PG_DB" -t -A -c "
|
||||
SELECT DISTINCT w.id, w.name
|
||||
FROM workflow_entity w
|
||||
LEFT JOIN shared_workflow sw ON w.id = sw.\"workflowId\"
|
||||
LEFT JOIN project p ON sw.\"projectId\" = p.id
|
||||
WHERE p.\"creatorId\" = '$user_id'
|
||||
OR w.id IN (
|
||||
SELECT \"workflowId\" FROM shared_workflow
|
||||
WHERE \"projectId\" IN (
|
||||
SELECT id FROM project WHERE \"creatorId\" = '$user_id'
|
||||
)
|
||||
)
|
||||
ORDER BY w.name;
|
||||
" 2>/dev/null)
|
||||
|
||||
while IFS='|' read -r wf_id wf_name; do
|
||||
if [ -n "$wf_id" ]; then
|
||||
# 清理文件名
|
||||
local safe_name=$(echo "$wf_name" | sed 's/[^a-zA-Z0-9_-]/_/g')
|
||||
local output_file="$output_dir/${safe_name}.json"
|
||||
|
||||
if export_workflow "$wf_id" "$output_file"; then
|
||||
log " ✅ $wf_name"
|
||||
((count++))
|
||||
else
|
||||
log_error " ❌ $wf_name (ID: $wf_id)"
|
||||
fi
|
||||
fi
|
||||
done <<< "$workflows"
|
||||
|
||||
echo "$count"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 導出所有 Workflows (n8n 批量格式)
|
||||
# ============================================
|
||||
export_all_workflows() {
|
||||
local output_file="$1"
|
||||
|
||||
log "導出所有 workflows..."
|
||||
|
||||
# 導出 workflows
|
||||
PGPASSWORD="$N8N_PG_PASSWORD" psql -U "$N8N_PG_USER" -h "$N8N_PG_HOST" -d "$N8N_PG_DB" -t -A -c "
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'id', id,
|
||||
'name', name,
|
||||
'active', active,
|
||||
'nodes', nodes,
|
||||
'connections', connections,
|
||||
'settings', COALESCE(settings, '{}'),
|
||||
'staticData', COALESCE(\"staticData\", 'null'),
|
||||
'pinData', COALESCE(\"pinData\", 'null'),
|
||||
'versionId', \"versionId\",
|
||||
'triggerCount', \"triggerCount\",
|
||||
'createdAt', \"createdAt\",
|
||||
'updatedAt', \"updatedAt\",
|
||||
'isArchived', \"isArchived\",
|
||||
'versionCounter', \"versionCounter\",
|
||||
'description', COALESCE(description, ''),
|
||||
'meta', COALESCE(meta, '{}')
|
||||
)
|
||||
)
|
||||
FROM workflow_entity;
|
||||
" > "$output_file" 2>&1
|
||||
|
||||
# 移除 psql 錯誤輸出
|
||||
if grep -q "ERROR:" "$output_file" 2>/dev/null; then
|
||||
sed -i '/ERROR:/d' "$output_file"
|
||||
fi
|
||||
|
||||
if [ $? -eq 0 ] && [ -s "$output_file" ]; then
|
||||
log_success "導出完成: $output_file"
|
||||
return 0
|
||||
else
|
||||
log_error "導出失敗"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 完整備份 (所有 workflows + 用戶)
|
||||
# ============================================
|
||||
backup_full() {
|
||||
log "=========================================="
|
||||
log "開始完整 n8n Workflow 備份"
|
||||
log "=========================================="
|
||||
|
||||
local backup_dir="$BACKUP_ROOT/full/$TIMESTAMP"
|
||||
mkdir -p "$backup_dir"
|
||||
|
||||
# 1. 導出所有 workflows 為單一檔案
|
||||
export_all_workflows "$backup_dir/all_workflows.json"
|
||||
|
||||
# 2. 導出執行歷史統計
|
||||
log ""
|
||||
log "導出執行統計..."
|
||||
PGPASSWORD="$N8N_PG_PASSWORD" psql -U "$N8N_PG_USER" -h "$N8N_PG_HOST" -d "$N8N_PG_DB" -t -A -c "
|
||||
SELECT json_agg(
|
||||
json_build_object(
|
||||
'workflow_id', w.id,
|
||||
'workflow_name', w.name,
|
||||
'total_executions', e.total_executions,
|
||||
'successful_executions', e.successful_executions,
|
||||
'failed_executions', e.failed_executions,
|
||||
'last_execution', e.last_execution
|
||||
)
|
||||
)
|
||||
FROM (
|
||||
SELECT
|
||||
\"workflowId\" as wid,
|
||||
COUNT(*) as total_executions,
|
||||
COUNT(CASE WHEN status = 'success' THEN 1 END) as successful_executions,
|
||||
COUNT(CASE WHEN status = 'error' THEN 1 END) as failed_executions,
|
||||
MAX(\"startedAt\") as last_execution
|
||||
FROM execution_entity
|
||||
GROUP BY \"workflowId\"
|
||||
) e
|
||||
JOIN workflow_entity w ON e.wid = w.id;
|
||||
" > "$backup_dir/execution_stats.json" 2>&1
|
||||
|
||||
# 移除錯誤輸出
|
||||
if grep -q "ERROR:" "$backup_dir/execution_stats.json" 2>/dev/null; then
|
||||
sed -i '/ERROR:/d' "$backup_dir/execution_stats.json"
|
||||
fi
|
||||
|
||||
# 3. 生成備份清單
|
||||
local workflow_count=$(cat "$backup_dir/all_workflows.json" 2>/dev/null | jq '. | length' 2>/dev/null || echo "0")
|
||||
cat > "$backup_dir/manifest.json" << EOF
|
||||
{
|
||||
"backup_time": "$(date -Iseconds)",
|
||||
"timestamp": "$TIMESTAMP",
|
||||
"workflow_count": $workflow_count,
|
||||
"version": "1.0"
|
||||
}
|
||||
EOF
|
||||
|
||||
# 5. 計算 SHA256
|
||||
sha256sum "$backup_dir/all_workflows.json" > "$backup_dir/all_workflows.json.sha256"
|
||||
|
||||
log ""
|
||||
log_success "備份完成!"
|
||||
log "備份目錄: $backup_dir"
|
||||
log "Workflow 總數: $workflow_count"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 增量備份 (每日)
|
||||
# ============================================
|
||||
backup_daily() {
|
||||
local backup_dir="$BACKUP_ROOT/daily/$(date +%Y%m%d)"
|
||||
|
||||
log "=========================================="
|
||||
log "開始每日 n8n Workflow 增量備份"
|
||||
log "=========================================="
|
||||
|
||||
mkdir -p "$backup_dir"
|
||||
|
||||
# 導出所有 workflows
|
||||
export_all_workflows "$backup_dir/workflows.json"
|
||||
|
||||
# SHA256 校驗
|
||||
sha256sum "$backup_dir/workflows.json" > "$backup_dir/workflows.json.sha256"
|
||||
|
||||
log_success "每日備份完成: $backup_dir/workflows.json"
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 列出可恢復版本
|
||||
# ============================================
|
||||
list_backups() {
|
||||
log ""
|
||||
log "=========================================="
|
||||
log "可用備份版本"
|
||||
log "=========================================="
|
||||
log ""
|
||||
|
||||
echo "📁 完整備份:"
|
||||
for dir in "$BACKUP_ROOT"/full/*/; do
|
||||
if [ -d "$dir" ]; then
|
||||
local name=$(basename "$dir")
|
||||
local count=$(cat "$dir/all_workflows.json" 2>/dev/null | jq '. | length' 2>/dev/null || echo "?")
|
||||
echo " $name ($count workflows)"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "📁 每日備份:"
|
||||
for dir in "$BACKUP_ROOT"/daily/*/; do
|
||||
if [ -d "$dir" ]; then
|
||||
local name=$(basename "$dir")
|
||||
local size=$(du -sh "$dir" | cut -f1)
|
||||
echo " $name ($size)"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 恢復 Workflow
|
||||
# ============================================
|
||||
restore_workflow() {
|
||||
local backup_file=$1
|
||||
local wf_name=$2
|
||||
|
||||
if [ ! -f "$backup_file" ]; then
|
||||
log_error "備份文件不存在: $backup_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log "從 $backup_file 恢復 workflow..."
|
||||
|
||||
# 這需要 n8n API 或直接 SQL 插入
|
||||
# 這裡只是示範結構
|
||||
log_warn "請使用 n8n UI 或 API 進行恢復"
|
||||
log "備份文件格式:"
|
||||
jq 'keys' "$backup_file" 2>/dev/null
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# 主程序
|
||||
# ============================================
|
||||
main() {
|
||||
case "${1:-full}" in
|
||||
full)
|
||||
backup_full
|
||||
;;
|
||||
daily)
|
||||
backup_daily
|
||||
;;
|
||||
list)
|
||||
list_backups
|
||||
;;
|
||||
restore)
|
||||
restore_workflow "$2" "$3"
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 [full|daily|list|restore]"
|
||||
echo ""
|
||||
echo "命令:"
|
||||
echo " full - 完整備份 (所有 workflows + 用戶分組)"
|
||||
echo " daily - 每日增量備份"
|
||||
echo " list - 列出可用備份"
|
||||
echo " restore - 恢復 workflow"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user