Files
blog/scripts/backup-run.mjs
T
zqlit d3da972c3b feat(editor-api): 线上容器切「CNB 主 + Gitea 备」双推;备份补 fetch 步骤
用户定案:「editor-api 容器 采用 cnb主仓 gitea备份仓 openlist文件备份」。
三层各有各的失效模式,不能互相替代:
  ① 主仓 origin = CNB          —— 源码 + 触发构建,依赖 CNB 平台
  ② 代码辅仓 gitea = 自建 Gitea —— 不受第三方平台规则约束,可 clone / 按提交追溯
  ③ 离线备份 = F50/OpenList     —— 单一加密文件,平台全挂也能恢复

一、线上容器实测切双推(国内机 119.29.215.187)

先探测出两个关键事实:
  · 国内机**能**直连自建 Gitea(/api/v1/version → 200,0.38s)
  · 国内机**够不到**家里的 OpenList(192.168.0.1 私有地址,6s 超时)
    → 「文件备份」这一层只能由家庭机发起,国内机做不到(下面的 fetch 修复正因此

改动:
  · /srv/blog 停在 432cf5e3,落后 origin/main 10 个提交且是其后代
    → git merge --ff-only 追平到 448b898e,**未强推**
  · 挂 gitea 远端(凭据编在 URL 里,同本机做法)
  · .env:PUSH_REMOTES=origin → origin,gitea;追加 GITEA_URL/USER/PASS
  · docker-compose.yml 默认值同步为 ${PUSH_REMOTES:-origin,gitea}
  · docker compose up -d --build 重建

验证(全部实测通过):
  · 启动日志 [editor-api] 分支 main 推送远端 origin, gitea
  · curl /health → "remotes":["origin","gitea"]
  · 容器内推 origin ✅ / 推 gitea ✅(推临时分支 → ls-remote 确认 → 删分支,无残留)
  · 三处 main 对齐 448b898e(本地 / origin / gitea);工作区干净

二、备份侧:修掉一个静默漏洞 —— 打包前必须先 fetch

git bundle create --all 取的是**本地已知** ref,其中 refs/remotes/origin/main
停在上次 fetch/pull 的位置。而这份备份跑在**家里**那台机器上,工作区并不会
随写作后台(editor-api)的发布自动更新。所以原先是:后台新发的文章**一篇都不
在备份里**,而备份照样报「成功」—— 失败是静默的。

  · backup-bundle.mjs 打包前插入 git fetch --all --tags --prune
    - 失败**不致命**(离线也得出得来备份)→ 降级为「用本地已有 ref 打包」+ 显著告警
    - 新增 --no-fetch 可跳过;步骤号 1/6..6/6 → 0/7..6/7
    - 新增一行 `快照 origin/main = <sha> <时间> <标题>` —— 恢复时第一件要确认的就是它
  · backup-run.mjs:头部补三层说明;失败邮件的「常见原因」加上 fetch 降级这一条

验证:fetch 3s 拉完 origin+gitea → 打包 3.6s → 加密 1.7s → 上传 19.2s(31.5MB/s)
      → 读回 sha256 一致,快照 = 448b898e

三、bootstrap.sh:修掉另一个静默陷阱

脚本每次都会**重写** .env,而 .env 是 GITEA_PASS 的唯一落点 →
「重跑一次但没现给 GITEA_PASS」会把辅仓推送**静默关掉**(PUSH_REMOTES 降级成
origin),不报错、不提示,直到要恢复时才发现 Gitea 早就没在同步。
  · 改为:命令行没给就从旧 .env 里捡回来(显式传新值仍优先)
  · 并把 GITEA_URL/USER/PASS 也写进 .env(容器不读这几个键,仅作复用锚点)
  · 该逻辑用 4 个用例离线验证(有/无 .env、显式覆盖、.env 里没 PASS),
    并确认 set -euo pipefail 下不会被 [ -n ] && cmd 这类写法误触发退出

四、文档

· 架构总览.md:§1.2 地址地图加 editor-api 行;§2 旅程图补「两条推送入口同一套语义」;
  §5.2 加「线上容器的推送目标」段(含验证方法与 pull 源只有 origin 的铁律);
  §5.6 加「打包前必须 git fetch」+「覆盖范围」表(诚实列出够不到的部分);
  §6 待办 #4/#10 更新、新增 #12
· editor-api/README.md:新增「落盘:写一次,存三处」
· README.md:推送说明补线上后台那一路与 fetch 说明;脚本表更新
2026-10-06 22:27:36 +08:00

179 lines
7.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
/**
* 备份包装器 —— 给「离线那一层」加一道失败告警
* =====================================================================
* 本项目的三层留存(失效模式各不相同,不能互相替代):
* ① 主仓 `origin` = CNB —— 源码 + 触发构建,依赖 CNB 平台
* ② 代码辅仓 `gitea` = 自建 23.254.236.47:3001 —— 不受第三方平台规则约束,可 clone/追溯
* ③ 离线备份 = 中兴 F50 上的 OpenList —— 单一加密文件,平台全挂也能恢复
* 本脚本负责第 ③ 层。
*
* 为什么需要它:
* 第 ③ 层是**唯一不依赖任何 git 服务**的一份 —— 平台全被清、服务器被回收,
* 只有它能恢复。而这类「每天自动跑」的任务最典型的失败模式恰恰是**静默**的 ——
* F50 被带出门、换了网段、OpenList 容器重启后没起来、WebDAV 口令改过……
* 这些都不会有人主动发现,直到某天真的要用备份时才发现最近三个月一份都没成功。
*
* ①②两层都还在,所以这不会立刻致命;但「离线备份悄悄失效」这件事本身
* 必须被告知。于是加这一层:跑备份 → 失败就发邮件。
*
* 它做什么:
* 1. 读 .workbuddy-backup/openlist-backup.env(KEY=VALUE,# 开头为注释)
* —— 只填充**尚未设置**的环境变量,环境变量优先(与 backup-bundle.mjs 一致)
* 2. 跑 scripts/backup-bundle.mjs,参数原样转发,输出实时透传
* (backup-bundle.mjs 会先 `git fetch --all` 再打包 —— 否则备的是过期快照,
* 后台新发的文章一份都不在里面;fetch 失败只降级告警,不影响出备份)
* 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 账号或口令改过',
' · 仓库体积增长导致超时',
'',
'另:若日志里出现「fetch 失败,降级为用本地已有 ref 打包」,',
'说明备份本身出得来,但快照可能不含最新提交 —— 修复对应远端的凭据/网络后重跑即可。',
'',
'--------- 输出尾部 ---------',
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;
}