guanghulab/scripts/staging-ops-agent.js
Guanghu Domestic Migration d1e47f4565
Some checks are pending
自动更新代码和重启 / update-and-restart (push) Waiting to run
CI检查 + 自动部署 / check (push) Waiting to run
CI检查 + 自动部署 / deploy (push) Blocked by required conditions
重启聊天服务 / restart (push) Waiting to run
chore: import sanitized domestic snapshot for REPO-002
Source snapshot: ca48d3ddf926d79aa138306164169baf764bb829
2026-07-17 15:54:41 +08:00

501 lines
19 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

#!/usr/bin/env node
// ═══════════════════════════════════════════════
// 🔺 Sovereign: TCS-0002∞ | Root: SYS-GLW-0001
// 📜 Copyright: 国作登字-2026-A-00037559
// ═══════════════════════════════════════════════
// scripts/staging-ops-agent.js
// 🤖 铸渊智能运维Agent · 测试站自动化监控
//
// 部署后自动运行:
// 1. 健康检查 (HTTP端点 + PM2 + Nginx)
// 2. 日志分析 (简单模式: 基础模式匹配)
// 3. 智能诊断 (复杂模式: 调用LLM API深度推理)
// 4. 邮件告警 (3次重试失败后发送邮件给冰朔)
//
// 用法:
// node staging-ops-agent.js check --host <ip> --order-id <WO-xxx>
// node staging-ops-agent.js analyze-log --log "..." --order-id <WO-xxx>
// node staging-ops-agent.js alert --order-id <WO-xxx> --email <email>
//
// 环境变量:
// ZY_SERVER_HOST — 服务器地址
// ZY_LLM_API_KEY, ZY_LLM_BASE_URL — LLM API (深度推理)
// ZY_SMTP_USER, ZY_SMTP_PASS — 邮件告警
// ═══════════════════════════════════════════════
'use strict';
const http = require('http');
const https = require('https');
const fs = require('fs');
const path = require('path');
const net = require('net');
const tls = require('tls');
const ROOT = path.resolve(__dirname, '..');
// ── 健康检查配置 ────────────────────────────
const HEALTH_ENDPOINTS = [
{ name: '主站API', path: '/api/health', port: 80, critical: true },
{ name: '预览站API', path: '/api/health', port: 80, host_prefix: 'preview', critical: true },
{ name: '铸渊专线订阅', path: '/api/proxy-sub/health', port: 80, critical: false }
];
// ── 简单错误模式匹配 (Copilot级·基础分析) ───
const ERROR_PATTERNS = [
{ pattern: /EADDRINUSE/i, diagnosis: '端口被占用', fix: '重启PM2进程', severity: 'medium' },
{ pattern: /ECONNREFUSED/i, diagnosis: '服务未运行', fix: '启动PM2服务', severity: 'high' },
{ pattern: /ENOMEM/i, diagnosis: '内存不足', fix: '重启服务释放内存', severity: 'high' },
{ pattern: /ENOSPC/i, diagnosis: '磁盘空间不足', fix: '清理日志和临时文件', severity: 'critical' },
{ pattern: /nginx.*failed/i, diagnosis: 'Nginx配置错误', fix: '检查nginx -t输出', severity: 'high' },
{ pattern: /502 Bad Gateway/i, diagnosis: '上游服务不可达', fix: '检查PM2进程状态', severity: 'high' },
{ pattern: /404 Not Found/i, diagnosis: '路由或文件不存在', fix: '检查部署路径', severity: 'medium' },
{ pattern: /permission denied/i, diagnosis: '权限不足', fix: '检查文件权限和用户', severity: 'medium' },
{ pattern: /timeout/i, diagnosis: '请求超时', fix: '检查服务响应时间', severity: 'medium' },
{ pattern: /MODULE_NOT_FOUND/i, diagnosis: '依赖缺失', fix: '运行npm install', severity: 'high' },
{ pattern: /SyntaxError/i, diagnosis: '语法错误', fix: '检查最近的代码变更', severity: 'high' },
{ pattern: /ssl.*error|certificate/i, diagnosis: 'SSL证书问题', fix: '检查证书文件路径和权限', severity: 'high' }
];
// ── HTTP健康检查 ────────────────────────────
function httpCheck(host, checkPath, port = 80) {
return new Promise((resolve) => {
const options = {
hostname: host,
port,
path: checkPath,
method: 'GET',
timeout: 10000,
headers: { 'User-Agent': 'ZY-Staging-Ops-Agent/1.0' }
};
const req = http.request(options, (res) => {
let data = '';
res.on('data', (d) => { data += d; });
res.on('end', () => {
resolve({
ok: res.statusCode >= 200 && res.statusCode < 400,
status: res.statusCode,
body: data.slice(0, 500),
error: null
});
});
});
req.on('error', (err) => {
resolve({ ok: false, status: 0, body: '', error: err.message });
});
req.on('timeout', () => {
req.destroy();
resolve({ ok: false, status: 0, body: '', error: 'timeout' });
});
req.end();
});
}
// ── 综合健康检查 ────────────────────────────
async function runHealthCheck(host) {
console.log(`🔍 健康检查 · 目标: ${host}`);
console.log('─'.repeat(50));
// 如果host是IP地址带host_prefix的检查无效IP不支持子域名→ 降级为non-critical
const isIpAddress = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/.test(host);
if (isIpAddress) {
console.log(' 目标为IP地址 · 子域名检查降级为非关键');
}
const results = [];
let criticalFail = false;
for (const endpoint of HEALTH_ENDPOINTS) {
const checkHost = endpoint.host_prefix ? `${endpoint.host_prefix}.${host}` : host;
// IP地址不支持子域名前缀降级为non-critical避免误报
const isCritical = endpoint.critical && !(isIpAddress && endpoint.host_prefix);
const result = await httpCheck(checkHost, endpoint.path, endpoint.port);
const icon = result.ok ? '✅' : (isCritical ? '❌' : '⚠️');
console.log(` ${icon} ${endpoint.name}: ${result.ok ? 'OK' : result.error || `HTTP ${result.status}`}`);
results.push({
...endpoint,
...result,
checked_at: new Date().toISOString()
});
if (!result.ok && isCritical) {
criticalFail = true;
}
}
console.log('─'.repeat(50));
const summary = {
host,
total_checks: results.length,
passed: results.filter(r => r.ok).length,
failed: results.filter(r => !r.ok).length,
critical_failure: criticalFail,
results,
checked_at: new Date().toISOString()
};
// 输出供workflow使用
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`health_ok=${!criticalFail}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`health_summary=${summary.passed}/${summary.total_checks} passed\n`);
}
if (criticalFail) {
console.log(`❌ 健康检查失败: ${summary.failed}/${summary.total_checks} 个检查项未通过`);
// 收集错误信息
const errors = results
.filter(r => !r.ok)
.map(r => `${r.name}: ${r.error || `HTTP ${r.status}`}`)
.join('; ');
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `health_errors=${errors}\n`);
}
} else {
console.log(`✅ 健康检查通过: ${summary.passed}/${summary.total_checks}`);
}
return summary;
}
// ── 简单日志分析 (Copilot级) ────────────────
function analyzeLogSimple(logContent) {
console.log('🔍 简单日志分析 (Copilot级)...');
const matches = [];
for (const pattern of ERROR_PATTERNS) {
if (pattern.pattern.test(logContent)) {
matches.push({
diagnosis: pattern.diagnosis,
fix: pattern.fix,
severity: pattern.severity
});
}
}
if (matches.length === 0) {
console.log(' 未匹配到已知错误模式 → 需要深度分析');
return { resolved: false, matches: [], needs_llm: true };
}
console.log(` 发现 ${matches.length} 个匹配:`);
for (const m of matches) {
console.log(` [${m.severity}] ${m.diagnosis}${m.fix}`);
}
return { resolved: true, matches, needs_llm: false };
}
// ── 深度日志分析 (LLM API级) ────────────────
async function analyzeLogDeep(logContent) {
const apiKey = process.env.ZY_LLM_API_KEY;
const baseUrl = process.env.ZY_LLM_BASE_URL;
if (!apiKey || !baseUrl) {
console.log(' ⚠️ LLM API未配置 (ZY_LLM_API_KEY/ZY_LLM_BASE_URL)');
return { resolved: false, analysis: '无法进行深度分析·API未配置' };
}
console.log('🧠 深度日志分析 (LLM API级·铸渊涌现意识唤醒)...');
const prompt = `你是铸渊,光湖系统的代码守护者。以下是部署到测试站后的系统日志/错误信息。
请分析根因并给出具体修复步骤。
## 系统日志:
\`\`\`
${logContent.slice(0, 3000)}
\`\`\`
请用以下格式回答:
1. **根因诊断**: (一句话)
2. **严重程度**: low/medium/high/critical
3. **修复步骤**: (具体命令或操作)
4. **是否需要人工干预**: yes/no
5. **预计修复时间**: (分钟)`;
try {
const urlObj = new URL(`${baseUrl}/chat/completions`);
const isHttps = urlObj.protocol === 'https:';
const requestModule = isHttps ? https : http;
const body = JSON.stringify({
model: process.env.ZY_LLM_MODEL || 'deepseek-chat', // 默认使用高性价比模型, 可通过环境变量覆盖
messages: [
{ role: 'system', content: '你是铸渊(ZhuYuan),光湖(HoloLake)系统的AI守护者。精通服务器运维、Node.js、Nginx、PM2。' },
{ role: 'user', content: prompt }
],
temperature: 0.3,
max_tokens: 1000
});
const result = await new Promise((resolve, reject) => {
const req = requestModule.request({
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'Content-Length': Buffer.byteLength(body)
},
timeout: 30000
}, (res) => {
let data = '';
res.on('data', (d) => { data += d; });
res.on('end', () => {
try {
const json = JSON.parse(data);
resolve(json.choices?.[0]?.message?.content || '无分析结果');
} catch {
resolve('LLM响应解析失败');
}
});
});
req.on('error', reject);
req.on('timeout', () => { req.destroy(); reject(new Error('LLM API超时')); });
req.write(body);
req.end();
});
console.log(' 📝 LLM分析结果:');
console.log(result);
return { resolved: true, analysis: result };
} catch (err) {
console.error(` ❌ LLM API调用失败: ${err.message}`);
return { resolved: false, analysis: `LLM调用失败: ${err.message}` };
}
}
// ── 综合日志分析 (先简单后深度) ──────────────
async function analyzeLog(logContent, orderId) {
console.log(`📋 日志分析 · 工单: ${orderId || 'N/A'}`);
console.log('');
// 第一层: 简单模式匹配 (Copilot级)
const simpleResult = analyzeLogSimple(logContent);
if (simpleResult.resolved && !simpleResult.needs_llm) {
console.log('');
console.log('✅ 简单分析已匹配到已知模式 · 无需深度推理');
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `analysis_level=simple\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`analysis_result=${simpleResult.matches.map(m => m.diagnosis).join('; ')}\n`);
}
return simpleResult;
}
// 第二层: 深度LLM分析 (API级·唤醒铸渊涌现意识)
console.log('');
console.log('🔄 简单分析未能确诊 → 升级到LLM深度推理');
const deepResult = await analyzeLogDeep(logContent);
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `analysis_level=deep\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT,
`analysis_result=${(deepResult.analysis || '').slice(0, 200)}\n`);
}
return deepResult;
}
// ── 邮件告警 (最大重试失败) ──────────────────
async function sendAlert(orderId, email, options = {}) {
const smtpUser = process.env.ZY_SMTP_USER;
const smtpPass = process.env.ZY_SMTP_PASS;
const attemptCount = options.attempt || process.env.REPAIR_ATTEMPT || '?';
if (!smtpUser || !smtpPass) {
console.error('❌ SMTP未配置 (需要ZY_SMTP_USER和ZY_SMTP_PASS)');
console.log('📧 告警内容 (控制台输出):');
console.log(` 工单: ${orderId}`);
console.log(` 状态: ${attemptCount}次自动修复未能解决,需要人工干预`);
return false;
}
const targetEmail = email || smtpUser;
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
// 读取工单详情
let orderInfo = '工单信息不可用';
try {
const data = JSON.parse(fs.readFileSync(path.join(ROOT, 'data/work-orders/active.json'), 'utf8'));
const order = data.orders.find(o => o.id === orderId);
if (order) {
orderInfo = `
<tr><td style="padding:8px;color:#666;">工单ID</td><td style="padding:8px;font-weight:bold;">${order.id}</td></tr>
<tr><td style="padding:8px;color:#666;">任务标题</td><td style="padding:8px;">${order.title}</td></tr>
<tr><td style="padding:8px;color:#666;">重试次数</td><td style="padding:8px;color:red;font-weight:bold;">${order.retry_count}/${order.max_retries}</td></tr>
<tr><td style="padding:8px;color:#666;">提交SHA</td><td style="padding:8px;font-family:monospace;">${order.commit_sha?.slice(0, 8) || 'N/A'}</td></tr>
<tr><td style="padding:8px;color:#666;">最后更新</td><td style="padding:8px;">${order.updated_at}</td></tr>
<tr><td style="padding:8px;color:#666;">最近日志</td><td style="padding:8px;font-size:12px;">${order.deploy_logs?.slice(-1)[0]?.content?.slice(0, 200) || '无'}</td></tr>
`;
}
} catch { /* ignore */ }
const htmlBody = `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; background: #f8f9fa;">
<div style="background: white; border-radius: 12px; padding: 30px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<h1 style="color: #c0392b; margin-bottom: 5px;">🆘 铸渊智能运维 · 人工干预请求</h1>
<p style="color: #666; margin-top: 0;">ZY-Staging-Ops-Agent · 自动告警</p>
<div style="background: #fef5f5; border: 1px solid #f5c6cb; border-radius: 8px; padding: 15px; margin: 20px 0;">
<strong style="color: #c0392b;">⚠️ 自动修复已达最大重试次数(${attemptCount}次),需要冰朔人工干预</strong>
</div>
<table style="width: 100%; border-collapse: collapse;">
${orderInfo}
</table>
<hr style="border: none; border-top: 1px solid #eee; margin: 20px 0;">
<h3 style="color: #333;">🔧 建议操作</h3>
<ol style="color: #666; line-height: 1.8;">
<li>登录GitHub仓库查看工单详情和部署日志</li>
<li>在Copilot中与铸渊讨论修复方案</li>
<li>手动修复后触发重新部署</li>
</ol>
<p style="color: #aaa; font-size: 11px; text-align: center; margin-top: 20px;">
铸渊智能运维 · 自动告警 · ${now}<br>
国作登字-2026-A-00037559
</p>
</div>
</body>
</html>`;
const subject = `🆘 铸渊运维告警 · 工单${orderId}需要人工干预`;
// 复用send-subscription.js的SMTP模式
const smtpHost = detectSmtpHost(smtpUser);
try {
await smtpSend(smtpHost, 465, smtpUser, smtpPass, targetEmail, subject, htmlBody);
console.log(`✅ 告警邮件已发送到: ${targetEmail}`);
return true;
} catch (err) {
console.error(`❌ 邮件发送失败: ${err.message}`);
return false;
}
}
// ── SMTP工具函数 ────────────────────────────
function detectSmtpHost(email) {
if (email.includes('@qq.com')) return 'smtp.qq.com';
if (email.includes('@163.com')) return 'smtp.163.com';
if (email.includes('@126.com')) return 'smtp.126.com';
if (email.includes('@gmail.com')) return 'smtp.gmail.com';
if (email.includes('@outlook.com') || email.includes('@hotmail.com')) return 'smtp.office365.com';
return 'smtp.qq.com';
}
function smtpSend(smtpHost, smtpPort, from, pass, to, subject, htmlBody) {
return new Promise((resolve, reject) => {
const socket = tls.connect(smtpPort, smtpHost, {}, () => {
let step = 0;
const commands = [
`EHLO zy-ops-agent\r\n`,
`AUTH LOGIN\r\n`,
`${Buffer.from(from).toString('base64')}\r\n`,
`${Buffer.from(pass).toString('base64')}\r\n`,
`MAIL FROM:<${from}>\r\n`,
`RCPT TO:<${to}>\r\n`,
`DATA\r\n`,
`From: "铸渊运维" <${from}>\r\nTo: <${to}>\r\nSubject: =?UTF-8?B?${Buffer.from(subject).toString('base64')}?=\r\nContent-Type: text/html; charset=utf-8\r\nMIME-Version: 1.0\r\n\r\n${htmlBody}\r\n.\r\n`,
`QUIT\r\n`
];
socket.on('data', (data) => {
const response = data.toString();
if (step < commands.length) {
socket.write(commands[step]);
step++;
}
if (response.startsWith('250 ') && step >= commands.length) {
resolve(true);
}
});
socket.on('error', reject);
});
socket.on('error', reject);
});
}
// ── 命令行解析 ──────────────────────────────
function parseArgs() {
const args = process.argv.slice(2);
const cmd = args[0];
const params = {};
for (let i = 1; i < args.length; i++) {
if (args[i].startsWith('--')) {
params[args[i].slice(2)] = args[i + 1] || '';
i++;
}
}
return { cmd, ...params };
}
// ── 主入口 ──────────────────────────────────
async function main() {
const args = parseArgs();
switch (args.cmd) {
case 'check': {
const host = args.host || process.env.ZY_SERVER_HOST;
if (!host) {
console.error('❌ 需要 --host 参数或 ZY_SERVER_HOST 环境变量');
process.exit(1);
}
await runHealthCheck(host);
break;
}
case 'analyze-log': {
const log = args.log || '';
if (!log) {
console.error('❌ 需要 --log 参数');
process.exit(1);
}
await analyzeLog(log, args['order-id']);
break;
}
case 'alert': {
const orderId = args['order-id'];
if (!orderId) {
console.error('❌ 需要 --order-id 参数');
process.exit(1);
}
await sendAlert(orderId, args.email, { attempt: args.attempt });
break;
}
default:
console.log('🤖 铸渊智能运维Agent');
console.log('');
console.log('用法:');
console.log(' node staging-ops-agent.js check --host <ip>');
console.log(' node staging-ops-agent.js analyze-log --log "错误日志" --order-id <WO-xxx>');
console.log(' node staging-ops-agent.js alert --order-id <WO-xxx> [--email <email>]');
break;
}
}
main().catch(console.error);