352 lines
9.6 KiB
JavaScript
352 lines
9.6 KiB
JavaScript
|
|
/**
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
* 🔧 铸渊运维守卫 · 自修复引擎
|
|||
|
|
* ═══════════════════════════════════════════════════════════
|
|||
|
|
*
|
|||
|
|
* 编号: ZY-OPS-REP-001
|
|||
|
|
* 签发: 铸渊 · ICE-GL-ZY001
|
|||
|
|
* 版权: 国作登字-2026-A-00037559
|
|||
|
|
*
|
|||
|
|
* 安全原则:
|
|||
|
|
* - 白名单制度:只允许预定义的安全操作
|
|||
|
|
* - 修复上限:同一问题最多修3次
|
|||
|
|
* - 修复前后记录日志(可追溯)
|
|||
|
|
* - 不删数据库、不改代码、不碰密钥文件
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
'use strict';
|
|||
|
|
|
|||
|
|
const { execSync } = require('child_process');
|
|||
|
|
|
|||
|
|
// ── 安全修复白名单 ──────────────────────────
|
|||
|
|
|
|||
|
|
const REPAIR_ACTIONS = {
|
|||
|
|
// PM2 进程重启
|
|||
|
|
'pm2-restart': {
|
|||
|
|
name: 'PM2进程重启',
|
|||
|
|
description: '重启指定PM2进程',
|
|||
|
|
risk: 'low',
|
|||
|
|
execute: (params) => {
|
|||
|
|
const name = sanitizeProcessName(params.processName);
|
|||
|
|
if (!name) throw new Error('无效的进程名');
|
|||
|
|
return execCommand(`pm2 restart ${name}`);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// PM2 日志清理
|
|||
|
|
'pm2-flush': {
|
|||
|
|
name: 'PM2日志清理',
|
|||
|
|
description: '清空所有PM2日志文件',
|
|||
|
|
risk: 'low',
|
|||
|
|
execute: () => {
|
|||
|
|
return execCommand('pm2 flush');
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// Nginx 重载
|
|||
|
|
'nginx-reload': {
|
|||
|
|
name: 'Nginx配置重载',
|
|||
|
|
description: '重新加载Nginx配置(不中断服务)',
|
|||
|
|
risk: 'low',
|
|||
|
|
execute: () => {
|
|||
|
|
// 先测试配置
|
|||
|
|
const testResult = execCommand('nginx -t 2>&1');
|
|||
|
|
if (testResult.includes('failed') || testResult.includes('error')) {
|
|||
|
|
throw new Error(`Nginx 配置测试失败: ${testResult}`);
|
|||
|
|
}
|
|||
|
|
return execCommand('systemctl reload nginx');
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// npm install
|
|||
|
|
'npm-install': {
|
|||
|
|
name: '依赖重装',
|
|||
|
|
description: '在指定目录重新安装npm依赖',
|
|||
|
|
risk: 'medium',
|
|||
|
|
execute: (params) => {
|
|||
|
|
const dir = sanitizePath(params.directory);
|
|||
|
|
if (!dir) throw new Error('无效的目录路径');
|
|||
|
|
return execCommand(`cd ${dir} && npm install --production 2>&1`);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 清理临时文件
|
|||
|
|
'clean-tmp': {
|
|||
|
|
name: '临时文件清理',
|
|||
|
|
description: '清理 /tmp 下的旧文件',
|
|||
|
|
risk: 'low',
|
|||
|
|
execute: () => {
|
|||
|
|
return execCommand('find /tmp -type f -mtime +7 -delete 2>/dev/null; echo "清理完成"');
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
// 清理旧日志
|
|||
|
|
'clean-old-logs': {
|
|||
|
|
name: '旧日志清理',
|
|||
|
|
description: '清理30天前的日志文件',
|
|||
|
|
risk: 'low',
|
|||
|
|
execute: () => {
|
|||
|
|
const logDirs = [
|
|||
|
|
'/opt/zhuyuan/data/logs',
|
|||
|
|
'/var/log/glada'
|
|||
|
|
];
|
|||
|
|
const results = [];
|
|||
|
|
for (const dir of logDirs) {
|
|||
|
|
try {
|
|||
|
|
const r = execCommand(`find ${dir} -type f -name '*.log' -mtime +30 -delete 2>/dev/null; echo "${dir}: 清理完成"`);
|
|||
|
|
results.push(r);
|
|||
|
|
} catch { /* directory might not exist */ }
|
|||
|
|
}
|
|||
|
|
return results.join('\n');
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ── 安全校验 ────────────────────────────
|
|||
|
|
|
|||
|
|
const ALLOWED_PROCESS_NAMES = new Set([
|
|||
|
|
'zhuyuan-server', 'zhuyuan-preview', 'novel-api',
|
|||
|
|
'age-os-mcp', 'age-os-agents', 'glada-agent', 'ops-agent'
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const ALLOWED_PATHS = [
|
|||
|
|
'/opt/zhuyuan/app',
|
|||
|
|
'/opt/zhuyuan/novel-db',
|
|||
|
|
'/opt/age-os',
|
|||
|
|
'/opt/zhuyuan/ops-agent'
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
function sanitizeProcessName(name) {
|
|||
|
|
if (!name || typeof name !== 'string') return null;
|
|||
|
|
const clean = name.replace(/[^a-zA-Z0-9_-]/g, '');
|
|||
|
|
return ALLOWED_PROCESS_NAMES.has(clean) ? clean : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function sanitizePath(dir) {
|
|||
|
|
if (!dir || typeof dir !== 'string') return null;
|
|||
|
|
// Prevent path traversal
|
|||
|
|
const normalized = dir.replace(/\.\./g, '').replace(/\/+/g, '/');
|
|||
|
|
for (const allowed of ALLOWED_PATHS) {
|
|||
|
|
if (normalized.startsWith(allowed)) return normalized;
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function execCommand(cmd) {
|
|||
|
|
try {
|
|||
|
|
return execSync(cmd, { encoding: 'utf-8', timeout: 60000 }).trim();
|
|||
|
|
} catch (err) {
|
|||
|
|
throw new Error(`命令执行失败: ${err.message}`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 修复记录追踪 ────────────────────────────
|
|||
|
|
|
|||
|
|
// 内存中的修复记录(进程重启后重置,配合 memory.js 持久化)
|
|||
|
|
const repairHistory = [];
|
|||
|
|
const MAX_HISTORY = 200;
|
|||
|
|
|
|||
|
|
function recordRepair(action, params, result, success) {
|
|||
|
|
const entry = {
|
|||
|
|
id: `REP-${Date.now()}`,
|
|||
|
|
action,
|
|||
|
|
params,
|
|||
|
|
result,
|
|||
|
|
success,
|
|||
|
|
timestamp: new Date().toISOString()
|
|||
|
|
};
|
|||
|
|
repairHistory.push(entry);
|
|||
|
|
if (repairHistory.length > MAX_HISTORY) {
|
|||
|
|
repairHistory.splice(0, repairHistory.length - MAX_HISTORY);
|
|||
|
|
}
|
|||
|
|
return entry;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getRepairCount(issueKey, withinHours = 24) {
|
|||
|
|
const cutoff = Date.now() - withinHours * 3600 * 1000;
|
|||
|
|
return repairHistory.filter(r =>
|
|||
|
|
r.action === issueKey && new Date(r.timestamp).getTime() > cutoff
|
|||
|
|
).length;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 自动修复决策 ────────────────────────────
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 根据健康检查发现的问题,自动修复
|
|||
|
|
* @param {Array} issues - 来自 health-checker 的问题列表
|
|||
|
|
* @param {number} maxRetries - 同一问题最大修复次数
|
|||
|
|
* @returns {Array} 修复结果列表
|
|||
|
|
*/
|
|||
|
|
function autoRepair(issues, maxRetries = 3) {
|
|||
|
|
const results = [];
|
|||
|
|
|
|||
|
|
for (const issue of issues) {
|
|||
|
|
const issueKey = `${issue.service}-${issue.status}`;
|
|||
|
|
const retryCount = getRepairCount(issueKey);
|
|||
|
|
|
|||
|
|
if (retryCount >= maxRetries) {
|
|||
|
|
results.push({
|
|||
|
|
issue,
|
|||
|
|
action: 'escalate',
|
|||
|
|
message: `${issue.service} 已修复${retryCount}次仍未解决 → 升级为工单`,
|
|||
|
|
repaired: false,
|
|||
|
|
escalate: true
|
|||
|
|
});
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const repairResult = attemptRepair(issue);
|
|||
|
|
const entry = recordRepair(issueKey, issue, repairResult.message, repairResult.success);
|
|||
|
|
results.push({
|
|||
|
|
issue,
|
|||
|
|
...repairResult,
|
|||
|
|
repairId: entry.id,
|
|||
|
|
retryCount: retryCount + 1,
|
|||
|
|
escalate: !repairResult.success && retryCount + 1 >= maxRetries
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return results;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function attemptRepair(issue) {
|
|||
|
|
const serviceName = issue.service || '';
|
|||
|
|
|
|||
|
|
// PM2 进程离线 → 重启
|
|||
|
|
if (serviceName.startsWith('PM2:') || issue.status === 'offline') {
|
|||
|
|
const processName = serviceName.replace('PM2:', '') ||
|
|||
|
|
guessProcessName(issue.port);
|
|||
|
|
if (processName && ALLOWED_PROCESS_NAMES.has(processName)) {
|
|||
|
|
try {
|
|||
|
|
const output = REPAIR_ACTIONS['pm2-restart'].execute({ processName });
|
|||
|
|
return {
|
|||
|
|
action: 'pm2-restart',
|
|||
|
|
success: true,
|
|||
|
|
message: `已重启 ${processName}: ${output}`,
|
|||
|
|
repaired: true
|
|||
|
|
};
|
|||
|
|
} catch (err) {
|
|||
|
|
return {
|
|||
|
|
action: 'pm2-restart',
|
|||
|
|
success: false,
|
|||
|
|
message: `重启 ${processName} 失败: ${err.message}`,
|
|||
|
|
repaired: false
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 磁盘空间不足 → 清理日志
|
|||
|
|
if (serviceName.includes('磁盘')) {
|
|||
|
|
try {
|
|||
|
|
REPAIR_ACTIONS['pm2-flush'].execute();
|
|||
|
|
REPAIR_ACTIONS['clean-old-logs'].execute();
|
|||
|
|
REPAIR_ACTIONS['clean-tmp'].execute();
|
|||
|
|
return {
|
|||
|
|
action: 'disk-cleanup',
|
|||
|
|
success: true,
|
|||
|
|
message: '已清理PM2日志、旧日志和临时文件',
|
|||
|
|
repaired: true
|
|||
|
|
};
|
|||
|
|
} catch (err) {
|
|||
|
|
return {
|
|||
|
|
action: 'disk-cleanup',
|
|||
|
|
success: false,
|
|||
|
|
message: `磁盘清理失败: ${err.message}`,
|
|||
|
|
repaired: false
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Nginx 异常 → reload
|
|||
|
|
if (serviceName === 'Nginx') {
|
|||
|
|
try {
|
|||
|
|
const output = REPAIR_ACTIONS['nginx-reload'].execute();
|
|||
|
|
return {
|
|||
|
|
action: 'nginx-reload',
|
|||
|
|
success: true,
|
|||
|
|
message: `Nginx 已重载: ${output}`,
|
|||
|
|
repaired: true
|
|||
|
|
};
|
|||
|
|
} catch (err) {
|
|||
|
|
return {
|
|||
|
|
action: 'nginx-reload',
|
|||
|
|
success: false,
|
|||
|
|
message: `Nginx 重载失败: ${err.message}`,
|
|||
|
|
repaired: false
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 依赖缺失 → npm install
|
|||
|
|
if (issue.error?.includes('MODULE_NOT_FOUND') || issue.error?.includes('Cannot find module')) {
|
|||
|
|
const dir = guessProjectDir(issue.port);
|
|||
|
|
if (dir) {
|
|||
|
|
try {
|
|||
|
|
const output = REPAIR_ACTIONS['npm-install'].execute({ directory: dir });
|
|||
|
|
return {
|
|||
|
|
action: 'npm-install',
|
|||
|
|
success: true,
|
|||
|
|
message: `已重装依赖(${dir}): ${output.slice(0, 200)}`,
|
|||
|
|
repaired: true
|
|||
|
|
};
|
|||
|
|
} catch (err) {
|
|||
|
|
return {
|
|||
|
|
action: 'npm-install',
|
|||
|
|
success: false,
|
|||
|
|
message: `依赖安装失败: ${err.message}`,
|
|||
|
|
repaired: false
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 无法自动修复
|
|||
|
|
return {
|
|||
|
|
action: 'none',
|
|||
|
|
success: false,
|
|||
|
|
message: `无法自动修复: ${issue.error || issue.status}`,
|
|||
|
|
repaired: false,
|
|||
|
|
escalate: true
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ── 辅助函数 ────────────────────────────
|
|||
|
|
|
|||
|
|
const PORT_TO_PROCESS = {
|
|||
|
|
3800: 'zhuyuan-server',
|
|||
|
|
3801: 'zhuyuan-preview',
|
|||
|
|
3100: 'age-os-mcp',
|
|||
|
|
3900: 'glada-agent',
|
|||
|
|
3950: 'ops-agent',
|
|||
|
|
4000: 'novel-api'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const PORT_TO_DIR = {
|
|||
|
|
3800: '/opt/zhuyuan/app',
|
|||
|
|
3801: '/opt/zhuyuan/app',
|
|||
|
|
3100: '/opt/age-os',
|
|||
|
|
3900: '/opt/zhuyuan/app',
|
|||
|
|
4000: '/opt/zhuyuan/novel-db'
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function guessProcessName(port) {
|
|||
|
|
return PORT_TO_PROCESS[port] || null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function guessProjectDir(port) {
|
|||
|
|
return PORT_TO_DIR[port] || null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
REPAIR_ACTIONS,
|
|||
|
|
ALLOWED_PROCESS_NAMES,
|
|||
|
|
autoRepair,
|
|||
|
|
attemptRepair,
|
|||
|
|
recordRepair,
|
|||
|
|
getRepairCount,
|
|||
|
|
repairHistory,
|
|||
|
|
sanitizeProcessName,
|
|||
|
|
sanitizePath
|
|||
|
|
};
|