建立每日自动异机备份基线(07-27-automatic-offhost-backup)
这个提交包含在:
@@ -16,11 +16,11 @@ const SEND_CONFIRM_ENV = "WARCH3_OPS_ALERT_SEND";
|
||||
function printHelp() {
|
||||
process.stdout.write(`Usage: node src/scripts/send-wecom-ops-alert.js [options]
|
||||
|
||||
QA-only WARCH3 企微巡检脱敏告警。
|
||||
QA/运维脱敏告警,支持 WARCH3 巡检和 AUTO_BACKUP 自动备份状态。
|
||||
默认 dry-run:只生成预览和报告,不发送企微消息。
|
||||
|
||||
Options:
|
||||
--summary-file=<path> WARCH3/WROAD2 summary.json。
|
||||
--summary-file=<path|-> WARCH3/WROAD2 summary.json;- 表示从 stdin 读取。
|
||||
--recipient-employee-no=<no> 收件员工号,可重复;默认 HYD000006。
|
||||
--send 发送企微应用消息。
|
||||
--force PASS 且无 finding 时也发送测试消息。
|
||||
@@ -131,7 +131,12 @@ function assertAlertSendAllowed(options, env = process.env) {
|
||||
}
|
||||
}
|
||||
|
||||
function readSummary(summaryFile) {
|
||||
function safeIssueId(value) {
|
||||
const issueId = toSafeString(value, 40);
|
||||
return /^[A-Z0-9_-]{1,40}$/.test(issueId) ? issueId : "WARCH3";
|
||||
}
|
||||
|
||||
function readSummary(summaryFile, stdinText) {
|
||||
if (!summaryFile) {
|
||||
return {
|
||||
status: "PARTIAL_ALERT_NEEDS_CONFIG",
|
||||
@@ -141,12 +146,17 @@ function readSummary(summaryFile) {
|
||||
notes: "summary_file_missing",
|
||||
};
|
||||
}
|
||||
const resolved = path.resolve(summaryFile);
|
||||
const raw = fs.readFileSync(resolved, "utf8");
|
||||
const fromStdin = summaryFile === "-";
|
||||
const resolved = fromStdin ? null : path.resolve(summaryFile);
|
||||
const raw = fromStdin
|
||||
? (stdinText ?? fs.readFileSync(0, "utf8"))
|
||||
: fs.readFileSync(resolved, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
...parsed,
|
||||
reportDir: parsed.reportDir || path.dirname(resolved),
|
||||
issueId: safeIssueId(parsed.issueId),
|
||||
// stdin is an untrusted cross-host boundary. Never echo a supplied path.
|
||||
reportDir: fromStdin ? "stdin-summary" : (parsed.reportDir || path.dirname(resolved)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,7 +166,20 @@ function shouldSendForSummary(summary, options) {
|
||||
}
|
||||
|
||||
function nextStepForStatus(summary) {
|
||||
const issueId = safeIssueId(summary?.issueId);
|
||||
const status = toSafeString(summary?.status || summary?.conclusion, 80);
|
||||
const findingText = Array.isArray(summary?.findings)
|
||||
? summary.findings.map((finding) => toSafeString(finding?.detail, 160)).join(" ")
|
||||
: "";
|
||||
if (issueId === "AUTO_BACKUP" && /quarterly_shadow_restore_due/i.test(findingText)) {
|
||||
return "按恢复卡在 ARM 隔离目标执行人工影子恢复,并核对就绪状态与关键计数。";
|
||||
}
|
||||
if (issueId === "AUTO_BACKUP" && (
|
||||
/FAIL|STALE|MISSING|INVALID|MISMATCH|BLOCKED|ERROR/i.test(status)
|
||||
|| Number(summary?.findingCount || 0) > 0
|
||||
)) {
|
||||
return "检查自动备份任务、异机传输链路和最近一个已校验成功的恢复点。";
|
||||
}
|
||||
if (status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG") {
|
||||
return "检查 Secret、权限、可信 IP、可见范围或外部配置后复跑。";
|
||||
}
|
||||
@@ -167,13 +190,13 @@ function nextStepForStatus(summary) {
|
||||
}
|
||||
|
||||
function buildAlertMessage(summary) {
|
||||
const issueId = toSafeString(summary?.issueId || "WARCH3", 40) || "WARCH3";
|
||||
const issueId = safeIssueId(summary?.issueId);
|
||||
const status = toSafeString(summary?.status || summary?.conclusion || "UNKNOWN", 80) || "UNKNOWN";
|
||||
const findingCount = Number(summary?.findingCount || 0);
|
||||
const reportPath = toSafeString(summary?.reportDir || summary?.sourceReports?.wroad2 || "N/A", 500);
|
||||
const nextStep = nextStepForStatus(summary);
|
||||
return {
|
||||
title: `OA 巡检 ${status}`,
|
||||
title: issueId === "AUTO_BACKUP" ? `OA 自动备份 ${status}` : `OA 巡检 ${status}`,
|
||||
body: [
|
||||
`阶段:${issueId}`,
|
||||
`状态:${status}`,
|
||||
@@ -252,6 +275,7 @@ function writeAlertState(stateFile, state) {
|
||||
|
||||
function evaluateAlertDecision(summary, options = {}, priorState = null) {
|
||||
const currentState = buildAlertState(summary);
|
||||
const issueId = safeIssueId(summary?.issueId);
|
||||
if (options.force) {
|
||||
return {
|
||||
shouldSend: true,
|
||||
@@ -267,8 +291,31 @@ function evaluateAlertDecision(summary, options = {}, priorState = null) {
|
||||
const previousRank = Number(priorState?.statusRank || statusRank(priorState?.status));
|
||||
const previousKeys = new Set(Array.isArray(priorState?.findingKeys) ? priorState.findingKeys : []);
|
||||
const newFindingKeys = currentState.findingKeys.filter((key) => !previousKeys.has(key));
|
||||
const isAutomaticBackup = issueId === "AUTO_BACKUP";
|
||||
const isQuarterlyReminder = currentState.findingKeys.some((key) => /quarterly_shadow_restore_due/i.test(key));
|
||||
|
||||
if (isAutomaticBackup && isQuarterlyReminder && (!priorState || newFindingKeys.length > 0)) {
|
||||
return {
|
||||
shouldSend: true,
|
||||
reason: "quarterly_restore_reminder",
|
||||
newFindingKeys,
|
||||
previousStatus: priorState?.status || null,
|
||||
currentStatus: currentState.status,
|
||||
currentState,
|
||||
};
|
||||
}
|
||||
|
||||
if (currentRank >= 4) {
|
||||
if (isAutomaticBackup && priorState && currentRank <= previousRank && newFindingKeys.length === 0) {
|
||||
return {
|
||||
shouldSend: false,
|
||||
reason: "repeated_automatic_backup_failure",
|
||||
newFindingKeys,
|
||||
previousStatus: priorState.status || null,
|
||||
currentStatus: currentState.status,
|
||||
currentState,
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldSend: true,
|
||||
reason: "blocked_or_failed",
|
||||
@@ -412,6 +459,7 @@ async function executeOpsAlert(options, deps = {}) {
|
||||
const generatedAt = deps.generatedAt || new Date().toISOString();
|
||||
const config = deps.config || getConfig();
|
||||
const summary = deps.summary || readSummary(options.summaryFile);
|
||||
const issueId = safeIssueId(summary?.issueId);
|
||||
const message = buildAlertMessage(summary);
|
||||
const priorState = Object.prototype.hasOwnProperty.call(deps, "priorState")
|
||||
? deps.priorState
|
||||
@@ -497,7 +545,7 @@ async function executeOpsAlert(options, deps = {}) {
|
||||
}
|
||||
|
||||
return {
|
||||
issueId: "WARCH3",
|
||||
issueId,
|
||||
mode: "wecom-ops-alert",
|
||||
generatedAt,
|
||||
status,
|
||||
@@ -513,7 +561,7 @@ async function executeOpsAlert(options, deps = {}) {
|
||||
},
|
||||
stateWrite,
|
||||
summary: {
|
||||
issueId: summary?.issueId || null,
|
||||
issueId,
|
||||
status: summary?.status || summary?.conclusion || "UNKNOWN",
|
||||
findingCount: Number(summary?.findingCount || 0),
|
||||
reportDir: summary?.reportDir || null,
|
||||
@@ -572,6 +620,7 @@ module.exports = {
|
||||
evaluateAlertDecision,
|
||||
executeOpsAlert,
|
||||
parseArgs,
|
||||
readSummary,
|
||||
readAlertState,
|
||||
sanitizeDeliveryResponse,
|
||||
shouldSendForSummary,
|
||||
|
||||
@@ -5,6 +5,7 @@ const {
|
||||
evaluateAlertDecision,
|
||||
executeOpsAlert,
|
||||
parseArgs,
|
||||
readSummary,
|
||||
shouldSendForSummary,
|
||||
} = require("../../../src/scripts/send-wecom-ops-alert");
|
||||
|
||||
@@ -75,6 +76,72 @@ describe("send-wecom-ops-alert", () => {
|
||||
expect(message.body).not.toContain("手机号");
|
||||
});
|
||||
|
||||
it("reads stdin summary without preserving an injected absolute report path", () => {
|
||||
const summary = readSummary("-", JSON.stringify({
|
||||
issueId: "AUTO_BACKUP",
|
||||
status: "FAIL_AUTOMATIC_BACKUP",
|
||||
findingCount: 1,
|
||||
reportDir: `/private/${SENSITIVE_TOKEN}/state`,
|
||||
}));
|
||||
|
||||
expect(summary.issueId).toBe("AUTO_BACKUP");
|
||||
expect(summary.reportDir).toBe("stdin-summary");
|
||||
expect(JSON.stringify(summary)).not.toContain(SENSITIVE_TOKEN);
|
||||
expect(JSON.stringify(summary)).not.toContain("/private/");
|
||||
});
|
||||
|
||||
it("keeps AUTO_BACKUP issue id and uses backup-specific failure guidance", async () => {
|
||||
const summary = baseSummary({
|
||||
issueId: "AUTO_BACKUP",
|
||||
status: "FAIL_AUTOMATIC_BACKUP",
|
||||
reportDir: "host-backup-state",
|
||||
});
|
||||
const message = buildAlertMessage(summary);
|
||||
const report = await executeOpsAlert(baseOptions(), {
|
||||
summary,
|
||||
recipientRows: recipientRows(),
|
||||
sendMessageImpl: vi.fn(),
|
||||
env: {},
|
||||
config: {},
|
||||
});
|
||||
|
||||
expect(message.title).toContain("自动备份");
|
||||
expect(message.body).toContain("异机传输链路");
|
||||
expect(report.issueId).toBe("AUTO_BACKUP");
|
||||
expect(report.summary.issueId).toBe("AUTO_BACKUP");
|
||||
});
|
||||
|
||||
it("uses manual ARM shadow-restore guidance for quarterly reminder", () => {
|
||||
const summary = baseSummary({
|
||||
issueId: "AUTO_BACKUP",
|
||||
findings: [{ detail: "quarterly_shadow_restore_due_2026-Q3" }],
|
||||
reportDir: "host-backup-state",
|
||||
});
|
||||
const message = buildAlertMessage(summary);
|
||||
const firstDecision = evaluateAlertDecision(summary, baseOptions(), null);
|
||||
const repeatedDecision = evaluateAlertDecision(summary, baseOptions(), buildAlertState(summary));
|
||||
|
||||
expect(message.body).toContain("ARM 隔离目标执行人工影子恢复");
|
||||
expect(firstDecision.shouldSend).toBe(true);
|
||||
expect(firstDecision.reason).toBe("quarterly_restore_reminder");
|
||||
expect(repeatedDecision.shouldSend).toBe(false);
|
||||
});
|
||||
|
||||
it("sends the first automatic-backup failure and suppresses an identical repeat", () => {
|
||||
const summary = baseSummary({
|
||||
issueId: "AUTO_BACKUP",
|
||||
status: "FAIL_AUTOMATIC_BACKUP",
|
||||
findings: [{ source: "automatic_backup", status: "FAIL_AUTOMATIC_BACKUP", detail: "phase_offhost_transfer_failed" }],
|
||||
});
|
||||
const firstDecision = evaluateAlertDecision(summary, baseOptions(), null);
|
||||
const repeatedDecision = evaluateAlertDecision(summary, baseOptions(), buildAlertState(summary));
|
||||
|
||||
expect(firstDecision.shouldSend).toBe(true);
|
||||
expect(firstDecision.reason).toBe("blocked_or_failed");
|
||||
expect(repeatedDecision.shouldSend).toBe(false);
|
||||
expect(repeatedDecision.reason).toBe("repeated_automatic_backup_failure");
|
||||
});
|
||||
|
||||
it("keeps PASS without findings as no-send unless forced", () => {
|
||||
const summary = baseSummary({ status: "PASS_ARCHIVE_OPS_READY", findingCount: 0 });
|
||||
expect(shouldSendForSummary(summary, baseOptions())).toBe(false);
|
||||
|
||||
在新工单中引用
屏蔽一个用户