建立每日自动异机备份基线(07-27-automatic-offhost-backup)

这个提交包含在:
2026-07-27 10:50:01 +08:00
父节点 325c592aba
当前提交 b78f2a11ce
共修改 19 个文件,包含 2070 行新增和 10 行删除
+59 -10
查看文件
@@ -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);