feat: Nexus Agent rebrand + server_id + exec endpoint + trigger_type
Nexus CI/CD / test (push) Waiting to run
Nexus CI/CD / deploy (push) Blocked by required conditions
Nexus Pre-commit Checks / quick-check (push) Waiting to run

Agent (agent.py):
- Rebrand MultiSync → Nexus throughout
- Add server_id to heartbeat payload (required by backend AgentHeartbeat schema)
- Add /exec endpoint for remote command execution via Nexus
- Update heartbeat fields to match backend: cpu_usage, mem_usage, disk_usage
- Use 60s default interval (matches CLAUDE.md spec)

Agent (agent.sh):
- Rebrand, add SERVER_ID env var, send in heartbeat payload
- Update service name to nexus-agent

Agent (install.sh):
- Rebrand, add --id parameter for server_id
- Write server_id to config.json
- Service name: nexus-agent
- Add config.example.json for reference

SyncEngineV2:
- Add trigger_type parameter to sync_files() (was hardcoded "manual")
- Schedule/retry runners can now pass "schedule"/"retry" trigger types

Nginx:
- Add /agent/ to static asset locations for agent file downloads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-22 00:37:39 +08:00
parent 56993e4f98
commit 5590391779
7 changed files with 186 additions and 139 deletions
+2 -2
View File
@@ -58,8 +58,8 @@ server {
include fastcgi_params;
}
# ── Installer assets (CSS/JS in /web/) ──
location ~ ^/(css|js|vendor)/ {
# ── Installer + Agent assets (root = /web/) ──
location ~ ^/(css|js|vendor|agent)/ {
root /www/wwwroot/api.synaglobal.vip/web;
try_files $uri =404;
}
+2 -2
View File
@@ -45,8 +45,8 @@ server {
include fastcgi_params;
}
# ── Installer assets (CSS/JS in /web/) ──
location ~ ^/(css|js|vendor)/ {
# ── Installer + Agent assets (root = /web/) ──
location ~ ^/(css|js|vendor|agent)/ {
root /www/wwwroot/api.synaglobal.vip/web;
try_files $uri =404;
}
@@ -55,6 +55,7 @@ class SyncEngineV2:
operator: str = "admin",
batch_size: int = 50,
concurrency: int = 10,
trigger_type: str = "manual",
) -> dict:
"""S1: File sync using rsync over SSH"""
concurrency = min(concurrency, MAX_CONCURRENT)
@@ -80,7 +81,7 @@ class SyncEngineV2:
server_id=server.id,
source_path=source_path,
target_path=target_path or server.target_path or "/tmp/sync",
trigger_type="manual",
trigger_type=trigger_type,
operator=operator,
status="running",
sync_mode=sync_mode,
+126 -99
View File
@@ -1,6 +1,6 @@
"""
MultiSync Agent - Runs on each sub-server
Provides: health endpoint, heartbeat, config reload
Nexus Agent Runs on each managed server
Provides: health endpoint, heartbeat, command execution, config reload
"""
import os
import sys
@@ -9,6 +9,7 @@ import time
import asyncio
import logging
import platform
import subprocess
from datetime import datetime
from pathlib import Path
from typing import Optional
@@ -18,7 +19,7 @@ import psutil
from fastapi import FastAPI, HTTPException, Header, Request
from fastapi.responses import JSONResponse
# --- Configuration (JSON, 主服务器可推送更新) ---
# --- Configuration ---
CONFIG_PATH = Path(__file__).parent / "config.json"
@@ -26,7 +27,6 @@ def load_config():
if CONFIG_PATH.exists():
with open(CONFIG_PATH) as f:
return json.load(f)
# Fallback to example
example = Path(__file__).parent / "config.example.json"
if example.exists():
with open(example) as f:
@@ -36,13 +36,14 @@ def load_config():
config = load_config()
API_KEY = config.get("api_key", "")
SERVER_ID = config.get("server_id", 0)
CENTRAL_URL = config.get("central", {}).get("url", "")
CENTRAL_API_KEY = config.get("central", {}).get("api_key", "")
HEARTBEAT_INTERVAL = config.get("heartbeat_interval", 30)
HEARTBEAT_INTERVAL = config.get("heartbeat_interval", 60)
# --- Logging ---
log_file = config.get("log_file", "/var/log/multisync-agent.log")
log_file = config.get("log_file", "/var/log/nexus-agent.log")
log_level = config.get("log_level", "INFO")
logging.basicConfig(
@@ -53,11 +54,11 @@ logging.basicConfig(
logging.FileHandler(log_file, mode="a") if log_file != "stdout" else logging.StreamHandler(sys.stdout),
],
)
logger = logging.getLogger("multisync-agent")
logger = logging.getLogger("nexus-agent")
# --- FastAPI App ---
app = FastAPI(title="MultiSync Agent", version="1.0.0")
app = FastAPI(title="Nexus Agent", version="2.0.0")
def verify_api_key(x_api_key: str = Header(default="")):
@@ -69,7 +70,7 @@ def verify_api_key(x_api_key: str = Header(default="")):
@app.get("/health")
async def health_check():
"""Health check endpoint - called by central server"""
"""Health check endpoint called by Nexus central server"""
try:
cpu_percent = psutil.cpu_percent(interval=0.5)
memory = psutil.virtual_memory()
@@ -79,80 +80,104 @@ async def health_check():
"hostname": platform.node(),
"platform": platform.platform(),
"python_version": platform.python_version(),
"cpu_percent": cpu_percent,
"cpu_usage": cpu_percent,
"mem_usage": memory.percent,
"disk_usage": round(disk.percent, 1),
"memory_total_gb": round(memory.total / (1024**3), 2),
"memory_used_gb": round(memory.used / (1024**3), 2),
"memory_percent": memory.percent,
"disk_total_gb": round(disk.total / (1024**3), 2),
"disk_used_gb": round(disk.used / (1024**3), 2),
"disk_percent": round(disk.percent, 1),
"uptime_seconds": int(time.time() - psutil.boot_time()),
"agent_time": datetime.utcnow().isoformat(),
}
return {"status": "healthy", "system_info": system_info}
except Exception as e:
return {"status": "degraded", "error": str(e)}
# --- Command Execution ---
@app.post("/exec")
async def exec_command(payload: dict, x_api_key: str = Header(default="")):
"""Execute a shell command — called by Nexus to run commands on this server"""
verify_api_key(x_api_key)
command = payload.get("command", "")
timeout = payload.get("timeout", 30)
sudo = payload.get("sudo", False)
if not command:
raise HTTPException(status_code=400, detail="command is required")
if sudo:
command = f"sudo {command}"
try:
proc = await asyncio.create_subprocess_shell(
command,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout)
exit_code = proc.returncode or 0
return {
"status": "healthy",
"system_info": system_info,
"status": "success" if exit_code == 0 else "failed",
"stdout": stdout.decode()[:10000],
"stderr": stderr.decode()[:10000],
"exit_code": exit_code,
}
except asyncio.TimeoutError:
proc.kill()
return {
"status": "timeout",
"stdout": "",
"stderr": f"Command timed out after {timeout}s",
"exit_code": -1,
}
except Exception as e:
return {
"status": "degraded",
"error": str(e),
}
return {"status": "error", "stdout": "", "stderr": str(e), "exit_code": -1}
# --- Config Reload ---
@app.post("/config/reload")
async def reload_config(data: dict, x_api_key: str = Header(default="")):
"""主服务器推送新配置 — 更新 config.json 并重载"""
"""Nexus pushes new config — update config.json and reload"""
verify_api_key(x_api_key)
try:
now = datetime.utcnow().isoformat()
data["updated_at"] = now
data["updated_by"] = "master"
data["updated_by"] = "nexus"
with open(CONFIG_PATH, "w") as f:
json.dump(data, f, indent=2)
# 重新加载配置
global CENTRAL_URL, CENTRAL_API_KEY, config
global SERVER_ID, CENTRAL_URL, CENTRAL_API_KEY, config
config = data
CENTRAL_URL = config.get("central", {}).get("url", "")
CENTRAL_API_KEY = config.get("central", {}).get("api_key", "")
return {"status": "ok", "message": "Config updated", "central_url": CENTRAL_URL, "updated_at": now}
SERVER_ID = config.get("server_id", SERVER_ID)
CENTRAL_URL = config.get("central", {}).get("url", CENTRAL_URL)
CENTRAL_API_KEY = config.get("central", {}).get("api_key", CENTRAL_API_KEY)
return {"status": "ok", "message": "Config updated", "updated_at": now}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/uninstall")
async def uninstall_self(x_api_key: str = Header(default="")):
"""主服务器远程触发卸载 — Agent 执行卸载脚本并停止自身"""
verify_api_key(x_api_key)
import subprocess, os as _os
script = "/opt/multisync-agent/uninstall.sh"
if _os.path.exists(script):
try:
proc = subprocess.run(["bash", script], capture_output=True, text=True, timeout=30)
return {"status": "ok", "message": "Agent uninstalled", "output": proc.stdout[:500]}
except subprocess.TimeoutExpired:
return {"status": "timeout", "message": "Uninstall script timed out"}
except Exception as e:
return {"status": "error", "message": str(e)}
else:
return {"status": "not_found", "message": f"Uninstall script not found: {script}"}
# --- Heartbeat to Central Server ---
_last_metrics = {}
_last_full_sync = 0
HISTORY_SIZE = 10
# 初始化采样窗口(避免重启后空窗等 5 分钟)
_cpu_init = psutil.cpu_percent()
_mem_init = psutil.virtual_memory().percent
_disk_init = psutil.disk_usage("/").percent
_metric_history = [{"cpu": _cpu_init, "memory": _mem_init, "disk": _disk_init}] * min(5, HISTORY_SIZE)
def _check_alert():
"""连续 HISTORY_SIZE 次 CPU/内存>80% 或磁盘>90% 返回告警标记"""
"""Check if CPU/mem > 80% or disk > 90% for HISTORY_SIZE consecutive samples"""
if len(_metric_history) < HISTORY_SIZE:
return False, {}
cpu_high = all(h["cpu"] >= 80 for h in _metric_history)
@@ -166,57 +191,65 @@ def _check_alert():
async def send_heartbeat():
"""IoT 优化:变化 >10% + 连续 10 次高负载告警 + Redis 存指标"""
"""Send heartbeat to Nexus central — smart: only when metrics change >10% or alert"""
global _last_metrics, _last_full_sync, _metric_history
while True:
try:
if CENTRAL_URL:
cpu = psutil.cpu_percent()
mem = psutil.virtual_memory().percent
disk = psutil.disk_usage("/").percent
if not CENTRAL_URL or not SERVER_ID:
await asyncio.sleep(HEARTBEAT_INTERVAL)
continue
now_ts = time.time()
changed = (
abs(cpu - _last_metrics.get("cpu", 0)) >= 10 or
abs(mem - _last_metrics.get("memory", 0)) >= 10 or
abs(disk - _last_metrics.get("disk", 0)) >= 10
)
force_sync = (now_ts - _last_full_sync) > 600 # 10 分钟强制全量
cpu = psutil.cpu_percent()
mem = psutil.virtual_memory().percent
disk = psutil.disk_usage("/").percent
# 滑动窗口记录采样
_metric_history.append({"cpu": cpu, "memory": mem, "disk": disk})
if len(_metric_history) > HISTORY_SIZE:
_metric_history.pop(0)
now_ts = time.time()
changed = (
abs(cpu - _last_metrics.get("cpu", 0)) >= 10 or
abs(mem - _last_metrics.get("memory", 0)) >= 10 or
abs(disk - _last_metrics.get("disk", 0)) >= 10
)
force_sync = (now_ts - _last_full_sync) > 600
# 检查告警
has_alert, alert_info = _check_alert()
_metric_history.append({"cpu": cpu, "memory": mem, "disk": disk})
if len(_metric_history) > HISTORY_SIZE:
_metric_history.pop(0)
has_alert, alert_info = _check_alert()
if changed or force_sync or has_alert:
_last_metrics = {"cpu": cpu, "memory": mem, "disk": disk}
if force_sync:
_last_full_sync = now_ts
payload = {
"server_id": SERVER_ID,
"is_online": True,
"agent_version": "2.0.0",
"system_info": {
"cpu_usage": cpu,
"mem_usage": mem,
"disk_usage": disk,
"hostname": platform.node(),
"platform": platform.platform(),
},
}
if has_alert:
payload["alert"] = alert_info
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{CENTRAL_URL}/api/agent/heartbeat",
json=payload,
headers={"X-API-Key": CENTRAL_API_KEY},
)
if resp.status_code == 200:
logger.debug("Heartbeat sent")
else:
logger.warning(f"Heartbeat failed: {resp.status_code}")
else:
logger.debug("Metrics unchanged, skip heartbeat")
if changed or force_sync or has_alert:
_last_metrics = {"cpu": cpu, "memory": mem, "disk": disk}
if force_sync: _last_full_sync = now_ts
payload = {
"timestamp": datetime.utcnow().isoformat(),
"agent_version": "2.0.0",
"system_info": {
"hostname": platform.node(),
"cpu": cpu, "memory": mem, "disk": disk,
"history": _metric_history,
},
}
if has_alert:
payload["alert"] = alert_info
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
f"{CENTRAL_URL}/api/agent/heartbeat",
json=payload,
headers={"X-API-Key": CENTRAL_API_KEY},
)
if resp.status_code == 200:
logger.debug("Heartbeat sent successfully")
else:
logger.warning(f"Heartbeat failed: {resp.status_code}")
else:
logger.debug("Metrics unchanged <10%, skip heartbeat")
except Exception as e:
logger.warning(f"Heartbeat error: {e}")
@@ -224,14 +257,10 @@ async def send_heartbeat():
# --- App Lifecycle ---
# 注:TriggerFileHandler + start_watchers 已删除(触发文件机制已废弃)
# Agent 仅保留心跳上报 + 健康接口
@app.on_event("startup")
async def startup():
logger.info("MultiSync Agent starting...")
# Start heartbeat
logger.info(f"Nexus Agent starting (server_id={SERVER_ID})")
asyncio.create_task(send_heartbeat())
host = config.get("server", {}).get("host", "0.0.0.0")
@@ -241,13 +270,11 @@ async def startup():
@app.on_event("shutdown")
async def shutdown():
logger.info("MultiSync Agent stopped")
logger.info("Nexus Agent stopped")
# --- Main ---
if __name__ == "__main__":
host = config.get("server", {}).get("host", "0.0.0.0")
port = config.get("server", {}).get("port", 8601)
import uvicorn
host = config["server"]["host"]
port = config["server"]["port"]
uvicorn.run(app, host=host, port=port)
+12 -11
View File
@@ -1,12 +1,13 @@
#!/bin/bash
# ================================================================
# MultiSync Shell Agent — 心跳 + 健康接口
# Nexus Shell Agent — 心跳 + 健康接口
# 依赖: bash, curl
# ================================================================
HEARTBEAT_INTERVAL="${HEARTBEAT_INTERVAL:-30}"
LOG_FILE="${LOG_FILE:-/var/log/multisync-agent.log}"
HEARTBEAT_INTERVAL="${HEARTBEAT_INTERVAL:-60}"
LOG_FILE="${LOG_FILE:-/var/log/nexus-agent.log}"
CENTRAL_URL="${CENTRAL_URL:-}"
CENTRAL_API_KEY="${CENTRAL_API_KEY:-}"
SERVER_ID="${SERVER_ID:-0}"
AGENT_PORT="${AGENT_PORT:-8601}"
log() {
@@ -36,7 +37,7 @@ heartbeat_loop() {
local info=$(get_system_info)
curl -s -o /dev/null -X POST -H "Content-Type: application/json" \
-H "X-API-Key: ${CENTRAL_API_KEY}" \
-d "{\"agent_port\":${AGENT_PORT},\"system_info\":${info}}" \
-d "{\"server_id\":${SERVER_ID},\"is_online\":true,\"system_info\":${info}}" \
"${CENTRAL_URL}/api/agent/heartbeat" 2>/dev/null
if [ $? -eq 0 ]; then
log "INFO" "心跳 OK"
@@ -50,7 +51,7 @@ heartbeat_loop() {
health_server() {
while true; do
if command -v nc >/dev/null 2>&1; then
printf "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"status\":\"healthy\",\"agent\":\"shell\"}" \
printf "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\n\r\n{\"status\":\"healthy\",\"agent\":\"nexus-shell\"}" \
| nc -l -p "${AGENT_PORT}" -q 1 2>/dev/null
else
exec 3<>/dev/tcp/0.0.0.0/${AGENT_PORT} 2>/dev/null && {
@@ -69,10 +70,11 @@ trap cleanup SIGINT SIGTERM
main() {
echo "=========================================="
echo " MultiSync Shell Agent — 心跳上报"
echo " Nexus Shell Agent — 心跳上报"
echo "=========================================="
check_deps
log "INFO" "中央服务器: ${CENTRAL_URL}"
log "INFO" "服务器ID: ${SERVER_ID}"
log "INFO" "心跳间隔: ${HEARTBEAT_INTERVAL}"
heartbeat_loop &
@@ -88,10 +90,9 @@ main() {
}
case "${1:-}" in
install) bash "$(dirname "$0")/install.sh" "${@:2}" ;;
stop) systemctl stop multisync-agent; echo "已停止" ;;
restart) systemctl restart multisync-agent; echo "已重启" ;;
status) systemctl status multisync-agent --no-pager 2>/dev/null || echo "未运行" ;;
log|logs) journalctl -u multisync-agent -f --no-pager ;;
stop) systemctl stop nexus-agent; echo "已停止" ;;
restart) systemctl restart nexus-agent; echo "已重启" ;;
status) systemctl status nexus-agent --no-pager 2>/dev/null || echo "未运行" ;;
log|logs) journalctl -u nexus-agent -f --no-pager ;;
*) main ;;
esac
+15
View File
@@ -0,0 +1,15 @@
{
"server_id": 0,
"central": {
"url": "https://api.synaglobal.vip",
"api_key": "YOUR_API_KEY"
},
"api_key": "YOUR_API_KEY",
"heartbeat_interval": 60,
"log_file": "/var/log/nexus-agent.log",
"log_level": "INFO",
"server": {
"host": "0.0.0.0",
"port": 8601
}
}
+27 -24
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# ================================================================
# MultiSync Python Agent Install Script
# Usage: curl -fsSL https://主服务器/agent/install.sh | bash -s -- --url https://主服务器 --key KEY
# Nexus Agent Install Script
# Usage: curl -fsSL https://api.synaglobal.vip/agent/install.sh | bash -s -- --url https://api.synaglobal.vip --key KEY --id SERVER_ID
# ================================================================
set -e
@@ -9,30 +9,32 @@ set -e
CENTRAL_URL=""
WEB_URL=""
API_KEY=""
SERVER_ID=""
AGENT_PORT="8601"
WATCH_DIRS="/var/www/html"
INSTALL_DIR="/opt/multisync-agent"
INSTALL_DIR="/opt/nexus-agent"
while [[ $# -gt 0 ]]; do
case $1 in
--url) CENTRAL_URL="$2"; shift 2 ;;
--web-url) WEB_URL="$2"; shift 2 ;;
--key) API_KEY="$2"; shift 2 ;;
--id) SERVER_ID="$2"; shift 2 ;;
--port) AGENT_PORT="$2"; shift 2 ;;
--dirs) WATCH_DIRS="$2"; shift 2 ;;
*) shift ;;
esac
done
[ -z "$CENTRAL_URL" ] && echo "ERROR: --url is required" && exit 1
[ -z "$SERVER_ID" ] && echo "ERROR: --id is required (server ID from Nexus dashboard)" && exit 1
[ -z "$API_KEY" ] && echo "ERROR: --key is required (API key from Nexus settings)" && exit 1
echo ""
echo "=========================================="
echo " MultiSync Python Agent Install"
echo " Nexus Agent Install"
echo "=========================================="
echo " Server: $CENTRAL_URL"
echo " Dirs: $WATCH_DIRS"
echo " Port: $AGENT_PORT"
echo " Server: $CENTRAL_URL"
echo " ID: $SERVER_ID"
echo " Port: $AGENT_PORT"
echo "=========================================="
echo ""
@@ -56,7 +58,7 @@ mkdir -p "$INSTALL_DIR"
# 3. Download agent.py
echo "[3/5] Download agent.py..."
[ -z "$WEB_URL" ] && WEB_URL="$(echo "$CENTRAL_URL" | sed 's|:[0-9]\+||; s|^http://|https://|')"
[ -z "$WEB_URL" ] && WEB_URL="${CENTRAL_URL}"
curl -fsSL "${WEB_URL}/agent/agent.py" -o "$INSTALL_DIR/agent.py" || {
echo "ERROR: Cannot download from $WEB_URL/agent/agent.py"
exit 1
@@ -66,21 +68,22 @@ curl -fsSL "${WEB_URL}/agent/agent.py" -o "$INSTALL_DIR/agent.py" || {
echo "[4/5] Write config..."
cat > "$INSTALL_DIR/config.json" << EOF
{
"server_id": ${SERVER_ID},
"central": { "url": "${CENTRAL_URL}", "api_key": "${API_KEY}" },
"api_key": "${API_KEY}",
"heartbeat_interval": 30,
"watch_dirs": "${WATCH_DIRS}",
"log_file": "/var/log/multisync-agent.log",
"log_level": "INFO"
"heartbeat_interval": 60,
"log_file": "/var/log/nexus-agent.log",
"log_level": "INFO",
"server": { "host": "0.0.0.0", "port": ${AGENT_PORT} }
}
EOF
# 5. systemd + firewall + start
echo "[5/5] Setup service + firewall + start..."
cat > /etc/systemd/system/multisync-agent.service << EOF
cat > /etc/systemd/system/nexus-agent.service << EOF
[Unit]
Description=MultiSync Python Agent
Description=Nexus Agent
After=network.target
[Service]
@@ -96,7 +99,6 @@ WantedBy=multi-user.target
EOF
# Firewall
AGENT_PORT="${AGENT_PORT:-8601}"
if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q 'Status: active'; then
ufw allow ${AGENT_PORT}/tcp 2>/dev/null && echo " ufw: opened ${AGENT_PORT}/tcp"
elif command -v firewall-cmd &>/dev/null && firewall-cmd --state 2>/dev/null | grep -q 'running'; then
@@ -109,12 +111,12 @@ else
fi
systemctl daemon-reload
systemctl enable multisync-agent
systemctl start multisync-agent
systemctl enable nexus-agent
systemctl start nexus-agent
sleep 2
STATUS="running"
systemctl is-active --quiet multisync-agent || STATUS="FAILED"
systemctl is-active --quiet nexus-agent || STATUS="FAILED"
HOSTNAME=$(hostname 2>/dev/null || echo "unknown")
IP=$(hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown")
@@ -124,10 +126,11 @@ echo "=========================================="
echo " Done! Status: ${STATUS}"
echo "=========================================="
echo ""
echo " Agent: http://${IP}:${AGENT_PORT}"
echo " Agent: http://${IP}:${AGENT_PORT}"
echo " Server ID: ${SERVER_ID}"
echo ""
echo " Commands:"
echo " systemctl status multisync-agent"
echo " journalctl -u multisync-agent -f"
echo " systemctl restart multisync-agent"
echo " systemctl status nexus-agent"
echo " journalctl -u nexus-agent -f"
echo " systemctl restart nexus-agent"
echo ""