284 lines
8.9 KiB
JavaScript
284 lines
8.9 KiB
JavaScript
|
|
"use strict";
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处置执行引擎(remediationEngine)
|
|||
|
|
*
|
|||
|
|
* 设计原则(D24):
|
|||
|
|
* - 不直接 import harness 的 capabilityTools
|
|||
|
|
* - 通过 aiBridge.requestToolCall(alert_id, command_id, params) 让 Catty Agent 在它自己的 session 上下文里执行
|
|||
|
|
* - 凭据、审计、权限全部走 Catty Agent 现有链路
|
|||
|
|
*
|
|||
|
|
* 执行权限分级(D17):
|
|||
|
|
* - auto → AI 自动执行,值班员可随时暂停
|
|||
|
|
* - confirm → 弹确认框,值班员点允许后执行
|
|||
|
|
* - manual_only → 不调 AI,只展示 checklist,值班员手动完成后勾标记
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const { STEP_EXECUTION, REMEDIATION_STATUS } = require("./types.cjs");
|
|||
|
|
|
|||
|
|
// ---------- 内存运行态(处置进行中)----------
|
|||
|
|
const _running = new Map(); // alert_id → { status, steps: [], paused, promise }
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 启动处置
|
|||
|
|
* @param {object} alert - alertRecord(必须已匹配 matched_plan_snapshot)
|
|||
|
|
* @param {object} options - { onStepComplete, onComplete, onError }
|
|||
|
|
* @returns {Promise<object>} 处置结果
|
|||
|
|
*/
|
|||
|
|
async function startRemediation(alert, options = {}) {
|
|||
|
|
if (!alert?.matched_plan_snapshot) {
|
|||
|
|
throw new Error("告警未匹配预案,无法启动处置");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const snapshot = alert.matched_plan_snapshot;
|
|||
|
|
const steps = snapshot.remediation?.steps || [];
|
|||
|
|
if (steps.length === 0) {
|
|||
|
|
throw new Error("预案没有处置步骤");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 初始化运行态
|
|||
|
|
const runState = {
|
|||
|
|
alert_id: alert.id,
|
|||
|
|
status: REMEDIATION_STATUS.RUNNING,
|
|||
|
|
paused: false,
|
|||
|
|
started_at: Date.now(),
|
|||
|
|
auto_index: 0, // 正在执行的 auto 步骤索引
|
|||
|
|
steps: steps.map((s) => ({
|
|||
|
|
...s,
|
|||
|
|
status: "pending",
|
|||
|
|
started_at: null,
|
|||
|
|
finished_at: null,
|
|||
|
|
stdout_preview: null,
|
|||
|
|
error: null,
|
|||
|
|
confirmed_by: null,
|
|||
|
|
confirmed_at: null,
|
|||
|
|
})),
|
|||
|
|
};
|
|||
|
|
_running.set(alert.id, runState);
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// 顺序执行所有步骤(D10:串行,不做并发)
|
|||
|
|
for (let i = 0; i < runState.steps.length; i++) {
|
|||
|
|
const step = runState.steps[i];
|
|||
|
|
|
|||
|
|
// 暂停检查
|
|||
|
|
if (runState.paused) {
|
|||
|
|
runState.status = REMEDIATION_STATUS.PAUSED;
|
|||
|
|
runState.paused_at = Date.now();
|
|||
|
|
options.onPause?.(runState);
|
|||
|
|
// 等待恢复
|
|||
|
|
await _waitForResume(runState);
|
|||
|
|
runState.status = REMEDIATION_STATUS.RUNNING;
|
|||
|
|
runState.paused_at = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const stepResult = await _executeStep(alert, snapshot, step, runState, options);
|
|||
|
|
runState.steps[i] = stepResult;
|
|||
|
|
|
|||
|
|
options.onStepComplete?.(stepResult, i, runState);
|
|||
|
|
|
|||
|
|
if (stepResult.status === "failed") {
|
|||
|
|
// on_failure 处理
|
|||
|
|
const onFailure = step.on_failure || "stop";
|
|||
|
|
if (onFailure === "escalate") {
|
|||
|
|
runState.status = REMEDIATION_STATUS.PARTIAL;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
if (onFailure.startsWith("retry:")) {
|
|||
|
|
const retryCount = parseInt(onFailure.split(":")[1], 10) || 1;
|
|||
|
|
// retry 简化:标记 failed 但继续下一步
|
|||
|
|
continue;
|
|||
|
|
}
|
|||
|
|
// 默认 stop
|
|||
|
|
runState.status = REMEDIATION_STATUS.FAILED;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (runState.status === REMEDIATION_STATUS.RUNNING) {
|
|||
|
|
runState.status = REMEDIATION_STATUS.COMPLETED;
|
|||
|
|
runState.completed_at = Date.now();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
options.onComplete?.(runState);
|
|||
|
|
return { ...runState };
|
|||
|
|
} catch (err) {
|
|||
|
|
runState.status = REMEDIATION_STATUS.FAILED;
|
|||
|
|
runState.error = err.message;
|
|||
|
|
options.onError?.(err, runState);
|
|||
|
|
throw err;
|
|||
|
|
} finally {
|
|||
|
|
_running.delete(alert.id);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function _executeStep(alert, snapshot, step, runState, options) {
|
|||
|
|
step.status = "running";
|
|||
|
|
step.started_at = Date.now();
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
// manual_only:只标记完成,不调 AI
|
|||
|
|
if (step.execution === STEP_EXECUTION.MANUAL_ONLY) {
|
|||
|
|
step.status = "pending_manual";
|
|||
|
|
step.finished_at = null;
|
|||
|
|
return step;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// confirm:等待值班员确认
|
|||
|
|
if (step.execution === STEP_EXECUTION.CONFIRM) {
|
|||
|
|
const confirmed = await _requestConfirmation(alert.id, step, options);
|
|||
|
|
if (!confirmed) {
|
|||
|
|
step.status = "skipped";
|
|||
|
|
step.error = "值班员拒绝执行";
|
|||
|
|
step.finished_at = Date.now();
|
|||
|
|
return step;
|
|||
|
|
}
|
|||
|
|
step.confirmed_by = options.operator || "system";
|
|||
|
|
step.confirmed_at = Date.now();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// auto 或 confirm 已确认 → 通过 aiBridge 发工具调用
|
|||
|
|
const result = await _callAiTool(alert, snapshot, step);
|
|||
|
|
step.status = result.ok ? "success" : "failed";
|
|||
|
|
step.stdout_preview = (result.stdout || "").slice(0, 500);
|
|||
|
|
step.exit_code = result.exitCode;
|
|||
|
|
step.error = result.ok ? null : (result.stderr || result.error || null);
|
|||
|
|
step.finished_at = Date.now();
|
|||
|
|
return step;
|
|||
|
|
} catch (err) {
|
|||
|
|
step.status = "failed";
|
|||
|
|
step.error = err.message;
|
|||
|
|
step.finished_at = Date.now();
|
|||
|
|
return step;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 通过 aiBridge 发工具调用(D24:薄封装,让 Catty Agent 执行)
|
|||
|
|
*/
|
|||
|
|
async function _callAiTool(alert, snapshot, step) {
|
|||
|
|
try {
|
|||
|
|
// aiBridge.requestToolCall 薄封装(在 aiBridge 扩展中实现)
|
|||
|
|
const bridge = require("../aiBridge.cjs");
|
|||
|
|
if (typeof bridge.requestToolCall === "function") {
|
|||
|
|
return await bridge.requestToolCall({
|
|||
|
|
alert_id: alert.id,
|
|||
|
|
device_id: alert.device_id,
|
|||
|
|
command_id: step.command_id,
|
|||
|
|
params: step.command_params || {},
|
|||
|
|
timeout_sec: step.timeout_sec || 30,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.warn("[remediationEngine] aiBridge.requestToolCall 不可用,降级返回模拟结果:", e.message);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 降级:直接 SSH 执行(如果 aiBridge 不可用)
|
|||
|
|
return await _fallbackSshExecute(alert, step);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function _fallbackSshExecute(alert, step) {
|
|||
|
|
try {
|
|||
|
|
const { execSshCommand } = require("../inspection/inspectionExec.cjs");
|
|||
|
|
// 从 alert 构造 SSH config(简化路径,实际需要从 vault 解密凭据)
|
|||
|
|
const deviceInfo = alert.device_profile || {};
|
|||
|
|
if (!deviceInfo.hostname || !deviceInfo.username) {
|
|||
|
|
return { ok: false, stdout: "", stderr: "无有效 SSH 凭据(aiBridge 不可用)", exitCode: -1 };
|
|||
|
|
}
|
|||
|
|
const sshConfig = {
|
|||
|
|
host: deviceInfo.hostname,
|
|||
|
|
port: deviceInfo.port || 22,
|
|||
|
|
username: deviceInfo.username,
|
|||
|
|
password: deviceInfo.password,
|
|||
|
|
privateKey: deviceInfo.privateKey,
|
|||
|
|
passphrase: deviceInfo.passphrase,
|
|||
|
|
};
|
|||
|
|
// 注意:command_id → command 映射需要 commandCatalog
|
|||
|
|
const { getCommand } = require("./commandCatalogStore.cjs");
|
|||
|
|
const cmdInfo = getCommand(() => null, step.command_id);
|
|||
|
|
const command = cmdInfo?.command || `echo "fallback: ${step.command_id}"`;
|
|||
|
|
const stdout = await execSshCommand(sshConfig, command, { connectTimeoutMs: 15000, execTimeoutMs: 30000 });
|
|||
|
|
return { ok: true, stdout, stderr: "", exitCode: 0 };
|
|||
|
|
} catch (err) {
|
|||
|
|
return { ok: false, stdout: "", stderr: err.message, exitCode: -1 };
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _requestConfirmation(alertId, step, options) {
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
options.onConfirmRequest?.(alertId, step, (result) => resolve(!!result));
|
|||
|
|
// 如果调用方没有提供 onConfirmRequest,默认跳过(测试模式)
|
|||
|
|
if (!options.onConfirmRequest) resolve(true);
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function _waitForResume(runState) {
|
|||
|
|
return new Promise((resolve) => {
|
|||
|
|
const check = () => {
|
|||
|
|
if (!runState.paused) resolve();
|
|||
|
|
else setTimeout(check, 200);
|
|||
|
|
};
|
|||
|
|
check();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- 暂停 / 恢复 ----------
|
|||
|
|
|
|||
|
|
function pause(alertId) {
|
|||
|
|
const state = _running.get(alertId);
|
|||
|
|
if (state) {
|
|||
|
|
state.paused = true;
|
|||
|
|
state.status = REMEDIATION_STATUS.PAUSED;
|
|||
|
|
state.paused_at = Date.now();
|
|||
|
|
}
|
|||
|
|
return state ? { ok: true } : { ok: false, error: "处置不在运行中" };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resume(alertId) {
|
|||
|
|
const state = _running.get(alertId);
|
|||
|
|
if (state) {
|
|||
|
|
state.paused = false;
|
|||
|
|
state.status = REMEDIATION_STATUS.RUNNING;
|
|||
|
|
}
|
|||
|
|
return state ? { ok: true } : { ok: false, error: "处置不在运行中" };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function cancel(alertId) {
|
|||
|
|
const state = _running.get(alertId);
|
|||
|
|
if (state) {
|
|||
|
|
state.paused = true;
|
|||
|
|
state.status = REMEDIATION_STATUS.CANCELLED;
|
|||
|
|
_running.delete(alertId);
|
|||
|
|
}
|
|||
|
|
return state ? { ok: true } : { ok: false, error: "处置不在运行中" };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getStatus(alertId) {
|
|||
|
|
const state = _running.get(alertId);
|
|||
|
|
return state ? { ...state } : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- 自动关闭检查(D25)----------
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 处置完成后检查 auto_close_after_remediation_recovery
|
|||
|
|
* 只有 remediation.status === "completed" 且指标恢复正常时才自动关告警
|
|||
|
|
*/
|
|||
|
|
function checkAutoClose(alert, remediation) {
|
|||
|
|
if (!remediation?.auto_close_after_recovery) return false;
|
|||
|
|
if (remediation.status !== REMEDIATION_STATUS.COMPLETED) return false;
|
|||
|
|
|
|||
|
|
// 简化:实际需要巡检复查指标是否恢复
|
|||
|
|
// 这里只标记为"待复查",由 AlertStateMonitor 定时检查
|
|||
|
|
return false; // 交由 AlertStateMonitor 处理
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = {
|
|||
|
|
startRemediation,
|
|||
|
|
pause,
|
|||
|
|
resume,
|
|||
|
|
cancel,
|
|||
|
|
getStatus,
|
|||
|
|
checkAutoClose,
|
|||
|
|
};
|