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
|
||
};
|