233 lines
11 KiB
Python
233 lines
11 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
collect.py — Palladium Z1 远程数据采集推送脚本
|
|
|
|
在 Palladium Z1 主机上运行,执行 test_server -short 并将结果推送到监控服务器。
|
|
|
|
用法:
|
|
python3 collect.py # 默认配置
|
|
python3 collect.py --url http://monitor:5100/api/collect
|
|
python3 collect.py --host scmp03 --env /edatools/vxe/VXE23.03.001/bin/env.csh
|
|
python3 collect.py --dry-run # 只采集不推送
|
|
python3 collect.py --log /var/log/palladium-collect.log # 追加写日志
|
|
|
|
可配合 crontab 定时执行 (每 30 分钟):
|
|
*/30 * * * * /opt/palladium-monitor/collect.py --host scmp03 --url http://MONITOR:5100/api/collect --log /var/log/palladium-collect.log >> /var/log/palladium-collect.cron.log 2>&1
|
|
"""
|
|
|
|
import argparse
|
|
import os
|
|
import socket
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from datetime import datetime
|
|
|
|
try:
|
|
import requests
|
|
except ImportError:
|
|
print("ERROR: requests 未安装,请执行 pip install requests", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Defaults
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
DEFAULT_URL = "http://127.0.0.1:5000/api/collect"
|
|
DEFAULT_CMD = "test_server -short"
|
|
DEFAULT_TIMEOUT = 300
|
|
DEFAULT_RETRIES = 3
|
|
DEFAULT_RETRY_DELAY = 5
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Logging
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
class Logger:
|
|
"""同时输出到 stderr 和可选的日志文件。"""
|
|
|
|
def __init__(self, log_file: str | None = None):
|
|
self.log_file = log_file
|
|
|
|
def _emit(self, level: str, msg: str, ts: str) -> None:
|
|
line = f"{ts} [{level}] {msg}"
|
|
print(line, file=sys.stderr)
|
|
if self.log_file:
|
|
try:
|
|
os.makedirs(os.path.dirname(self.log_file), exist_ok=True)
|
|
with open(self.log_file, "a", encoding="utf-8") as f:
|
|
f.write(line + "\n")
|
|
except OSError as e:
|
|
print(f"{ts} [WARN] 无法写入日志文件 {self.log_file}: {e}", file=sys.stderr)
|
|
|
|
def info(self, msg: str) -> None:
|
|
self._emit("INFO", msg, datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
|
|
|
def warn(self, msg: str) -> None:
|
|
self._emit("WARN", msg, datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
|
|
|
def error(self, msg: str) -> None:
|
|
self._emit("ERROR", msg, datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Command Execution
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
def build_shell_cmd(cmd: str, env_script: str | None) -> str:
|
|
"""
|
|
如果指定了 env_script,把 source env 之后再执行 cmd 拼成一个 shell 串。
|
|
不指定则直接返回 cmd。
|
|
"""
|
|
if not env_script:
|
|
return cmd
|
|
# env 文件可能是 csh 或 sh 后缀, 用 bash -c 强制走 sh 语义, 兼容大多数 vendor
|
|
# 假设 vendor 提供的 env.* 是 POSIX 兼容 (setenv → export)
|
|
return f"source {shlex_quote(env_script)} >/dev/null 2>&1; {cmd}"
|
|
|
|
|
|
def shlex_quote(s: str) -> str:
|
|
"""最小 shell 单引号转义 (用于 env_script 路径里有空格的情况)。"""
|
|
return "'" + s.replace("'", "'\\''") + "'"
|
|
|
|
|
|
def run_test_server(cmd: str, timeout: int, env_script: str | None) -> tuple[str, int, str]:
|
|
"""
|
|
执行 test_server -short, 返回 (stdout, returncode, stderr)。
|
|
timeout 触发时 subprocess.run 不会抛 (>=3.12 subprocessing 时可能抛), 这里显式处理。
|
|
"""
|
|
full_cmd = build_shell_cmd(cmd, env_script)
|
|
try:
|
|
result = subprocess.run(
|
|
full_cmd,
|
|
shell=True,
|
|
executable="/bin/bash",
|
|
capture_output=True,
|
|
timeout=timeout,
|
|
)
|
|
except subprocess.TimeoutExpired as e:
|
|
return (e.stdout.decode("utf-8", errors="replace") if e.stdout else "", -1,
|
|
f"命令超时 (>={timeout}s)")
|
|
except FileNotFoundError as e:
|
|
return ("", 127, f"shell 不可用: {e}")
|
|
|
|
stdout = result.stdout.decode("utf-8", errors="replace") if result.stdout else ""
|
|
stderr = result.stderr.decode("utf-8", errors="replace") if result.stderr else ""
|
|
return (stdout, result.returncode, stderr)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Push to monitor
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
def push_to_monitor(url: str, raw_output: str, source: str,
|
|
retries: int, retry_delay: int,
|
|
log: Logger) -> tuple[bool, dict | None, str]:
|
|
"""带重试的 HTTP 推送, 返回 (ok, json_body_or_None, error_msg)。"""
|
|
payload = {"raw_output": raw_output, "source": source}
|
|
last_err = ""
|
|
for attempt in range(1, retries + 1):
|
|
try:
|
|
resp = requests.post(url, json=payload, timeout=30)
|
|
except requests.exceptions.ConnectionError as e:
|
|
last_err = f"无法连接 {url}: {e}"
|
|
except requests.exceptions.Timeout as e:
|
|
last_err = f"推送超时: {e}"
|
|
except requests.exceptions.RequestException as e:
|
|
last_err = f"请求异常: {e}"
|
|
else:
|
|
if resp.status_code == 200:
|
|
try:
|
|
return (True, resp.json(), "")
|
|
except ValueError:
|
|
return (True, None, "")
|
|
last_err = f"HTTP {resp.status_code}: {resp.text[:200]}"
|
|
|
|
if attempt < retries:
|
|
log.warn(f"推送失败 (第 {attempt}/{retries} 次): {last_err} — {retry_delay}s 后重试")
|
|
time.sleep(retry_delay)
|
|
return (False, None, last_err)
|
|
|
|
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
# Main
|
|
# ═══════════════════════════════════════════════════════════════════════════
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Palladium Z1 数据采集推送 (cron 友好, 带重试和日志)",
|
|
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
|
)
|
|
parser.add_argument("--url", default=DEFAULT_URL,
|
|
help=f"监控服务器 API 地址 (默认: {DEFAULT_URL})")
|
|
parser.add_argument("--cmd", default=DEFAULT_CMD,
|
|
help=f"采集命令 (默认: {DEFAULT_CMD})")
|
|
parser.add_argument("--host", default=socket.gethostname(),
|
|
help="本机标签, 会写入 source 字段 (默认: hostname)")
|
|
parser.add_argument("--env", default=None,
|
|
help="VXE env.csh/env.sh 路径, 自动 source 后再执行命令")
|
|
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT,
|
|
help=f"命令超时秒数 (默认: {DEFAULT_TIMEOUT})")
|
|
parser.add_argument("--retries", type=int, default=DEFAULT_RETRIES,
|
|
help=f"推送失败重试次数 (默认: {DEFAULT_RETRIES})")
|
|
parser.add_argument("--retry-delay", type=int, default=DEFAULT_RETRY_DELAY,
|
|
help=f"重试间隔秒数 (默认: {DEFAULT_RETRY_DELAY})")
|
|
parser.add_argument("--log", default=None,
|
|
help="追加写日志的文件路径 (例如 /var/log/palladium-collect.log)")
|
|
parser.add_argument("--dry-run", action="store_true",
|
|
help="只采集不推送, 打印解析结果")
|
|
args = parser.parse_args()
|
|
|
|
log = Logger(args.log)
|
|
source = f"collect.py@{args.host}"
|
|
log.info(f"开始采集 — host={args.host} cmd={args.cmd!r} env={args.env!r}")
|
|
|
|
# ─── 执行采集命令 ──────────────────────────────────────────
|
|
stdout, rc, stderr = run_test_server(args.cmd, args.timeout, args.env)
|
|
if rc != 0:
|
|
log.warn(f"命令退出码 {rc} (stderr: {stderr.strip()[:300]})")
|
|
if not stdout.strip():
|
|
log.error("命令无输出, 终止")
|
|
return 1
|
|
|
|
log.info(f"采集成功 — {len(stdout)} 字节, rc={rc}")
|
|
|
|
# ─── dry-run 模式 ──────────────────────────────────────────
|
|
if args.dry_run:
|
|
print("─" * 60)
|
|
print(stdout)
|
|
print("─" * 60)
|
|
log.info("dry-run 模式, 不推送")
|
|
return 0
|
|
|
|
# ─── 推送到监控服务器 ──────────────────────────────────────
|
|
log.info(f"推送到 {args.url} (source={source})")
|
|
ok, body, err = push_to_monitor(
|
|
args.url, stdout, source,
|
|
args.retries, args.retry_delay, log,
|
|
)
|
|
|
|
if not ok:
|
|
log.error(f"推送失败 (已重试 {args.retries} 次): {err}")
|
|
return 2
|
|
|
|
# 成功 — 打印 stats 摘要
|
|
snapshot_id = body.get("snapshot_id") if body else "?"
|
|
stats = body.get("stats") if body else None
|
|
if stats:
|
|
log.info(
|
|
f"推送成功 — snapshot #{snapshot_id} | "
|
|
f"板 {stats['total_boards']} (在线 {stats['online_boards']}/离线 {stats['offline_boards']}) | "
|
|
f"利用率 {stats['utilization']}% (使用 {stats['used_boards']}/{stats['total_boards']}) | "
|
|
f"作业 {stats['active_jobs']}"
|
|
)
|
|
else:
|
|
log.info(f"推送成功 — snapshot #{snapshot_id} (无 stats 字段)")
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except KeyboardInterrupt:
|
|
print("\n[!] 用户中断", file=sys.stderr)
|
|
sys.exit(130)
|