Files
blog/scripts/backup-run.mjs
T
zqlit 448b898e40 feat(远端): 恢复自建 Gitea 代码辅仓,OpenList 降为备选备份
用户定案:「自建 gitea 同步一下吧,openlist 也作为备选备份」。
于是把上一轮「撤销全部 git 辅仓」的决定部分回退:在线那一路回到自建 Gitea,
OpenList 上的加密 bundle 从「唯一异地备份」改为「备选异地备份」。

两层不是重复,而是失效模式不同:
  · 自建 Gitea(在线、可增量、可浏览)—— 不受任何第三方平台规则约束
  · 离线 bundle(离线、单一文件、完整历史)—— 平台全挂也能恢复

一、实际同步
- Gitea 位于 23.254.236.47:3001(Gitea 28.0.0),本机直连即可推,
  **不需要广州中转机**(旧结论「沙箱跑不通 git smart HTTP」只针对当时的代理)
- 远端停在 ed38f938(2026-10-04),落后 57 个提交,且是本地 HEAD 的**祖先**
  → 一次 fast-forward 追平,**未强推、未丢历史**
- 同步后 origin 与 gitea 均指向 5f27ad32

二、git 配置
- 恢复 remote `gitea`(凭据编在 URL 里,只落本机 .git/config,不入库)
- `pushall` 回到双推:
    !git push origin main; ec=$?; git remote | grep -qx gitea && git push gitea main; exit $ec
  · 用 `;` 而非 `&&` → 主仓挂了辅仓照样推
  · `exit $ec` → 退出码仍以主仓为准,辅仓成功不掩盖主仓失败
  · gitea 段带存在性前置判断 → 没配辅仓时静默跳过,不白报错

三、发布链路恢复 gitea(前一轮刚去掉,现按新定案加回)
- `deploy/editor-api/bootstrap.sh`
    · 新增 GITEA_URL / GITEA_USER / GITEA_PASS(凭据必须现给,不写死在脚本里)
    · PUSH_REMOTES 默认 → origin,gitea;没给 GITEA_PASS 则降级为 origin
    · 恢复「配 gitea 远端」一节;退役远端名单去掉 gitea(只留 gh / gitee)
      —— 否则新部署会把现行辅仓当成退役远端清掉
    · 结尾补一句「未启用 Gitea 辅仓」的提示
- `docker-compose.editor.yml`、`editor-api/server.mjs` → 默认 origin,gitea
- `editor-api/README.md` → 变量表同步;`editor-api/Dockerfile` → 注释补 http 情形

四、`scripts/setup-cnb-remotes.sh` 改回双远端模式
- 头部沿革、USAGE、凭据段全部重写;GITEA_PASS 走环境变量,不入库
- 退役远端循环只清 gh / gitee;pushall 写同一份定义

五、文档
- `架构总览.md`:§1.2 地址地图加「代码辅仓」行、备份行降为「备选」;
  §2 旅程图改双推;§5.1 推送重写(含别名实际定义与三条取舍);
  §5.2 远端改双行表 + Gitea 接入细节;§5.3 由「不走 git 远端」改为
  「远端与备份的分工」(三层各自的失效模式);§5.6 定位改备选;
  §6 待办 #2/#4/#7/#10 更新
- `CNB构建落地方案.md` §4.0 目标形态与定案块、§4.1 第 4 条
- `README.md` 发布流程图、推送说明、脚本表
- `scripts/backup-run.mjs` 头部注释:不再是「唯一备份」

实测:
  git push gitea main → ed38f938..5f27ad32  main -> main
  git pushall         → origin / gitea 双向同步,退出码 0
  bash -n 校验 bootstrap.sh 与 setup-cnb-remotes.sh 均通过
2026-10-06 21:42:08 +08:00

170 lines
7.0 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)上这份加密 bundle 是**唯一不依赖任何 git 服务**的备份 ——
* 在线那一路虽有自建 Gitea 代码辅仓(contributor 机器上,推一次就在),
* 但平台全挂、服务器被回收这类事故只有它能挡。
* 而这类「每天自动跑」的任务最典型的失败模式恰恰是**静默**的 ——
* F50 被带出门、换了网段、OpenList 容器重启后没起来、WebDAV 口令改过……
* 这些都不会有人主动发现,直到某天真的要用备份时才发现最近三个月一份都没成功。
*
* 在线辅仓和主仓都还在,所以这不会立刻致命;但「离线备份悄悄失效」这件事本身
* 必须被告知。于是加这一层:跑备份 → 失败就发邮件。
*
* 它做什么:
* 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;
}