Files
blog/scripts/backup-run.mjs
T
zqlit 5f27ad321d chore(备份): 异地备份定案为 OpenList 离线 bundle,撤销全部 git 辅仓
用户定案:「辅助仓就用 openlist,其他不再考虑」。
据此把前一轮为 Gitee 铺的路全部收回,git 远端只剩 CNB 一个。

一、git 配置收口
- `pushall` 别名 `origin + gitee` → `!git push origin main`(只推唯一远端)
- 移除 `gitea` remote(自建 23.254.236.47:3001)—— 远端仓库本身没删,
  需要时可 `git remote add` 恢复;改动前配置存
  `.workbuddy-backup/git-remotes.20261006-211516.txt`
- 确认无 gitee 相关 credential 残留

二、发布链路去 Gitee 化(6 处)
- `deploy/editor-api/bootstrap.sh`
    · 删掉 GITEE_URL / GITEE_SSH_KEY 两个变量与「没给私钥就降级」的分支
    · PUSH_REMOTES 默认 → origin
    · 原「配 gitee 辅仓远端」一节改为「清理退役远端」循环(gh/gitee/gitea),
      让从旧部署续用的工作区自动恢复干净
- `docker-compose.editor.yml`、`editor-api/server.mjs` → 默认值 origin
- `editor-api/README.md` → 变量表同步
- `editor-api/Dockerfile` → 注释里的「CNB / GitHub」改「CNB」
- `blog-admin/src/routes/rss/tools.ts` → deploy-notify 的注释里
  「与 Gitea Actions 的构建通知配套」改为中性描述
  (该轮询链路 2026-10-04 起已被 CNB 国内节点直传取代)

三、`scripts/setup-cnb-remotes.sh` 重写为单远端模式
- 去掉 GITEE_URL / GITEE_TOKEN 参数、校验、凭据写入与自检提示
- 新增「清理退役远端」步骤
- 凭据处理改为「已存在空的 credential.helper 就不再添加」,
  不再用 --replace-all —— 本仓另有一个从 `$HOME/.workbuddy/secrets/cnb-token`
  读令牌的自定义 helper,那是有效的,不能被脚本抹掉

四、备份升级为「唯一辅仓」的配置
- 保留份数 3 → 7(一周窗口;每份 605.5 MB ≈ 4.2 GB,F50 有 256 GB)
    · `scripts/backup-task.cmd` 默认参数 --keep 7
    · `scripts/backup-bundle.mjs` 的 KEEP 默认值同步为 7
      (原先写的是 2,一直被命令行参数掩盖着)
- 远端目录 `/本地/备份` → `/本地/备份/blog-bundle`:
  根目录是用户自己在用的(放着 github-zqlit-*、local-repos-* 等手工备份),
  实测发现直接放根下的 bundle 已被清掉 —— 改子目录隔离,避免混放与误删

五、新增 `scripts/backup-run.mjs`:备份的推荐入口 + 失败告警
- 读 `.workbuddy-backup/openlist-backup.env`(只补空缺,环境变量优先)
- 跑 backup-bundle.mjs 并实时透传输出,同时留一份日志尾部
- 退出码非 0 → 经 `scripts/send_mail.js` 发告警邮件(附日志尾部与常见原因);
  成功默认不发,`--notify-success` 才发
- 退出用 `process.exitCode` 而非 `process.exit()`,避免截断未排干的 stdout
- 发信失败不改判备份退出码 —— 通知不该掩盖真正的故障
- 理由:这是当前**唯一**的异地备份,而「每天自动跑」的任务最典型的失败模式
  恰恰是静默的(F50 被带出门、换了网段、OpenList 没起来、口令改过……),
  没有告警就要等到真要用备份那天才发现
- `scripts/backup-task.cmd` 改调它

六、文档
- `架构总览.md`
    · §1.2 地址地图:备份行改指 F50/OpenList;通知行补「兼做备份失败告警」
    · §2 旅程图:双推改单推,并说明备份换了介质
    · §5.1 / §5.2 推送与远端:只剩 origin;补「已移除远端」表与恢复命令;
      GitHub 退役记录保留并补上「CI 定义也已删除」
    · §5.3 由「辅仓选型」改为「异地备份的定案」—— 明确不走 git 远端;
      平台对比数据保留备查,并注明 `bin/linux/hugo` 出库不必再做了
    · §5.6 补「唯一备份」定位、专属子目录、失败告警、keep 7、SMTP 配置键,
      实测数据更新为本次复测值
    · §6 待办:#2 定案、#3 不必做、#4 已移除、#8 已更新、#10 定位升级,
      新增 #11(F50 目录使用约定)
- `CNB构建落地方案.md` §4.0:双远端改单远端,脚本示例去掉 Gitee 参数
- `README.md`:推送说明改单推;脚本表补 backup-run.mjs

实测(2026-10-06,本轮复测):
  bundle 7.4s / AES-256-GCM 加密 1.3s / 上传 19.2s(31.6 MB/s)
  / 读回 sha256 一致 → 端到端 60.6s,退出码 0
  告警邮件链路已实测(发出一封「备份成功」验证信)

★ 一处过程记录,供以后避免重复踩坑:
  中途我把「本机沙箱里 `env -u ... cmd > file` 会让输出整个消失」
  误判成 process.exit 截断 stdout,并据此改了日志实现;
  随后用 `env -u FOO echo hi > file`(同样零输出)证伪 ——
  那是沙箱文件重定向的伪影,与脚本无关。相关改动已回滚,
  只留下本身无害的 process.exitCode 写法。
2026-10-06 21:27:28 +08:00

168 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 备份包装器 —— 给「唯一辅仓」加一道失败告警
* =====================================================================
* 为什么需要它:
* OpenList(中兴 F50)现在是**唯一的异地备份**。而这类「每天自动跑」的任务
* 最典型的失败模式恰恰是**静默**的 —— F50 被带出门、换了网段、OpenList
* 容器重启后没起来、WebDAV 口令改过……这些都不会有人主动发现,
* 直到某天真的要用备份时才发现最近三个月一份都没成功。
*
* CNB 主仓还在,所以这不会立刻致命;但「唯一辅仓悄悄失效」这件事本身
* 必须被告知。于是加这一层:跑备份 → 失败就发邮件。
*
* 它做什么:
* 1. 读 .workbuddy-backup/openlist-backup.env(KEY=VALUE,# 开头为注释)
* —— 只填充**尚未设置**的环境变量,环境变量优先(与 backup-bundle.mjs 一致)
* 2. 跑 scripts/backup-bundle.mjs,参数原样转发,输出实时透传
* 3. 退出码非 0 → 用 scripts/send_mail.js 发告警邮件,正文附日志尾部
* 成功时默认不发(避免每天一封的邮件疲劳);加 --notify-success 才发
* 4. **原样返回 backup-bundle.mjs 的退出码**(发信失败不改变它 ——
* 通知不该掩盖真正的故障,也不该把成功改判成失败)
*
* 用法(一般由 scripts/backup-task.cmd 调用,也可手跑):
* node scripts/backup-run.mjs # 等价于 backup-bundle.mjs 默认参数
* node scripts/backup-run.mjs --verify --keep 7
* node scripts/backup-run.mjs --dry --notify-success
* node scripts/backup-run.mjs --list
*
* 邮件相关环境变量(未配则静默跳过发信,不让通知拖垮备份):
* SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_PASS / SMTP_TO
* 未设 SMTP_USER 时会回落到 MAIL_USERNAME(本站既有命名),SMTP_PASS 同理。
*/
import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO = path.resolve(HERE, '..');
const BACKUP_SCRIPT = path.join(HERE, 'backup-bundle.mjs');
const MAIL_SCRIPT = path.join(HERE, 'send_mail.js');
const ENV_FILE = path.join(REPO, '.workbuddy-backup', 'openlist-backup.env');
function log(...a) {
console.log('[' + new Date().toTimeString().slice(0, 8) + ']', ...a);
}
// ---- 1. 载入 env 文件(只补空缺,环境变量优先) ----
function loadEnvFile(file) {
let raw;
try {
raw = fs.readFileSync(file, 'utf8');
} catch {
return { loaded: false, count: 0 };
}
let count = 0;
for (const line of raw.split(/\r?\n/)) {
const s = line.trim();
if (!s || s.startsWith('#')) continue;
const eq = s.indexOf('=');
if (eq <= 0) continue;
const k = s.slice(0, eq).trim();
// 值可能被引号包住 —— 只做去引号这一种规整
let v = s.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
if (process.env[k] === undefined) {
process.env[k] = v;
count++;
}
}
return { loaded: true, count };
}
const envInfo = loadEnvFile(ENV_FILE);
log('配置文件:', envInfo.loaded ? `${ENV_FILE}(补入 ${envInfo.count} 项)` : `${ENV_FILE} 不存在,只用环境变量`);
// ---- 2. 参数拆分:--notify-success 归自己,其余原样转发给 backup-bundle.mjs ----
const argv = process.argv.slice(2);
const notifySuccess = argv.includes('--notify-success');
const passThrough = argv.filter((a) => a !== '--notify-success');
function mailConfigured() {
const user = process.env.SMTP_USER || process.env.MAIL_USERNAME || '';
const pass = process.env.SMTP_PASS || process.env.MAIL_PASSWORD || '';
return Boolean(user && pass);
}
// ---- 3. 跑备份,实时透传输出,同时留一份尾部用于邮件正文 ----
log('开始:', process.execPath.split(/[\\/]/).pop(), BACKUP_SCRIPT, passThrough.join(' ') || '(默认参数)');
const t0 = Date.now();
const child = spawn(process.execPath, [BACKUP_SCRIPT, ...passThrough], {
cwd: REPO,
env: process.env,
stdio: ['ignore', 'pipe', 'pipe'],
});
const TAIL_MAX = 6000;
let tail = '';
const keep = (d) => {
tail += d.toString('utf8');
if (tail.length > TAIL_MAX) tail = tail.slice(-TAIL_MAX);
};
child.stdout.on('data', (d) => { process.stdout.write(d); keep(d); });
child.stderr.on('data', (d) => { process.stderr.write(d); keep(d); });
const code = await new Promise((resolve) => {
child.on('close', (c) => resolve(c === null ? 1 : c));
child.on('error', (e) => {
console.error('启动备份失败:', e.message);
keep(`启动备份失败: ${e.message}\n`);
resolve(1);
});
});
const secs = ((Date.now() - t0) / 1000).toFixed(1);
log(`结束:exit=${code} 用时 ${secs}s`);
// ---- 4. 发信 ----
// 退出用 process.exitCode 而不是 process.exit():到这一步 stdout 还可能有
// 未排干的写入,process.exit() 会在 flush 前终止进程(Node 官方文档明确警告过)。
// 这里已经是脚本尾部,设完让 Node 自行结束即可。
if (code === 0 && !notifySuccess) {
log('备份成功(未配置 --notify-success,不发信)');
process.exitCode = 0;
} else if (!mailConfigured()) {
log('未配置 SMTP 凭据,跳过发信。需要时在 openlist-backup.env 里补 SMTP_USER / SMTP_PASS');
process.exitCode = code;
} else {
const ok = code === 0;
const stamp = new Date().toLocaleString('zh-CN', { hour12: false });
const subject = ok
? '【优世界】整仓离线备份成功'
: `【优世界】★ 整仓离线备份失败(exit=${code})`;
const body = [
`时间:${stamp}`,
`结果:${ok ? '成功' : '失败'}(exit=${code})`,
`用时:${secs}s`,
`命令:backup-bundle.mjs ${passThrough.join(' ') || '(默认参数)'}`,
'',
ok ? '备份已上传到 OpenList 并通过校验。' : '★ 备份没有完成 —— 异地备份这一层目前是失效的。',
'',
'常见原因:',
' · 中兴 F50 不在线 / 换了网段 → 检查 OPENLIST_URL 是否仍可达',
' · OpenList 容器重启后没起来',
' · WebDAV 账号或口令改过',
' · 仓库体积增长导致超时',
'',
'--------- 输出尾部 ---------',
tail.trim() || '(无输出)',
].join('\n');
log('发送通知邮件…');
const mail = spawn(process.execPath, [MAIL_SCRIPT], {
cwd: REPO,
env: { ...process.env, MAIL_SUBJECT: subject, MAIL_BODY: body },
stdio: 'inherit',
});
const mailCode = await new Promise((r) => mail.on('close', (c) => r(c === null ? 1 : c)));
log(mailCode === 0 ? '通知邮件已处理' : `通知邮件失败(exit=${mailCode},不影响备份退出码)`);
process.exitCode = code;
}