Merge pull request 'feat: automate database OSS backups' (#47) from codex/database-backup-oss-autotimer into master
Reviewed-on: #47
This commit was merged in pull request #47.
This commit is contained in:
11
.env.example
11
.env.example
@@ -145,6 +145,17 @@ ALIYUN_OSS_POST_EXPIRE_SECONDS="600"
|
||||
ALIYUN_OSS_POST_MAX_SIZE_BYTES="20971520"
|
||||
ALIYUN_OSS_SUCCESS_ACTION_STATUS="200"
|
||||
|
||||
# SpacetimeDB 数据目录备份到 OSS。备份 bucket 可与资源 bucket 分离;未设置时脚本回退使用 ALIYUN_OSS_BUCKET。
|
||||
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=""
|
||||
GENARRATIVE_DATABASE_BACKUP_WORK_DIR=""
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET=""
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT=""
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX="database-backups"
|
||||
GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL="false"
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID=""
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET=""
|
||||
GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE=""
|
||||
|
||||
# Optional model name for custom-world scene image generation.
|
||||
DASHSCOPE_IMAGE_MODEL="wan2.7-image"
|
||||
|
||||
|
||||
12
deploy/env/api-server.env.example
vendored
12
deploy/env/api-server.env.example
vendored
@@ -119,3 +119,15 @@ ALIYUN_OSS_READ_EXPIRE_SECONDS=600
|
||||
ALIYUN_OSS_POST_EXPIRE_SECONDS=600
|
||||
ALIYUN_OSS_POST_MAX_SIZE_BYTES=20971520
|
||||
ALIYUN_OSS_SUCCESS_ACTION_STATUS=200
|
||||
|
||||
# SpacetimeDB 数据目录 OSS 冷备份配置。可由 cron / Jenkins 调用发布包内 scripts/database-backup-to-oss.mjs。
|
||||
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb
|
||||
GENARRATIVE_DATABASE_BACKUP_WORK_DIR=/var/lib/genarrative/database-backups
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET=
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX=database-backups
|
||||
GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL=false
|
||||
# 可选:定时 / publish 前备份使用独立最小权限 AccessKey;为空时回退 ALIYUN_OSS_ACCESS_KEY_*。
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID=
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET=
|
||||
GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE=spacetimedb.service
|
||||
|
||||
18
deploy/systemd/genarrative-database-backup.service
Normal file
18
deploy/systemd/genarrative-database-backup.service
Normal file
@@ -0,0 +1,18 @@
|
||||
[Unit]
|
||||
Description=Genarrative SpacetimeDB OSS Backup
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
User=root
|
||||
Group=root
|
||||
WorkingDirectory=/opt/genarrative/current
|
||||
EnvironmentFile=/etc/genarrative/api-server.env
|
||||
ExecStart=/usr/bin/node /opt/genarrative/current/scripts/database-backup-to-oss.mjs --env-file /etc/genarrative/api-server.env --stop-service spacetimedb.service
|
||||
|
||||
# 备份需要停止 / 启动 spacetimedb.service,并读取 /stdb、写入 /var/lib/genarrative/database-backups。
|
||||
PrivateTmp=true
|
||||
ProtectSystem=full
|
||||
ReadWritePaths=/stdb /var/lib/genarrative
|
||||
|
||||
12
deploy/systemd/genarrative-database-backup.timer
Normal file
12
deploy/systemd/genarrative-database-backup.timer
Normal file
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Run Genarrative SpacetimeDB OSS Backup Daily
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:20:00
|
||||
Persistent=true
|
||||
RandomizedDelaySec=600
|
||||
Unit=genarrative-database-backup.service
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# 本地开发验证与生产运维
|
||||
# 本地开发验证与生产运维
|
||||
|
||||
更新时间:`2026-05-15`
|
||||
|
||||
@@ -193,6 +193,31 @@ UI 相关修改要重点验证:
|
||||
6. Jenkins 数据库导入 / 导出流水线会先加载 `scripts/jenkins-prepare-toolchain-env.sh`,显式补齐 Jenkins 用户的 Node、Cargo、SpacetimeDB 工具链目录;如果目标机器安装路径不同,用 `GENARRATIVE_JENKINS_TOOL_PATHS` 传入额外 `bin` 目录。
|
||||
7. 本地 `npm run dev` / `npm run dev:api-server` 若没有显式 `GENARRATIVE_SPACETIME_TOKEN`,会在 SpacetimeDB 就绪后调用 `/v1/identity` 创建当前进程专用 Web API identity token,并只注入本次 `api-server` 环境,不写回 `.env.local`。启动日志只打印 identity 前缀,禁止打印 token 明文;若仍出现 `subscribe ... 401 Unauthorized`,先确认是否绕过了项目 dev 脚本或是否连接到非本次启动的 SpacetimeDB server。
|
||||
|
||||
### SpacetimeDB 数据目录 OSS 备份
|
||||
|
||||
数据库备份不放进 `spacetime-module` reducer / procedure:备份属于文件系统与 OSS 外部副作用,必须由运维脚本在 SpacetimeDB 宿主外执行。当前统一脚本为;生产 provision 还会安装 `genarrative-database-backup.timer`,每天 `03:20` 左右自动执行一次 OSS 冷备份:
|
||||
|
||||
```bash
|
||||
npm run database:backup:oss -- --data-dir /stdb --stop-service spacetimedb.service
|
||||
```
|
||||
|
||||
脚本会将数据目录打包成 `tar.gz`,上传到 `oss://<bucket>/<prefix>/<database>/<database>-<UTC时间>.tar.gz`。生产建议做冷备份:传入 `--stop-service spacetimedb.service`,脚本会在打包前停止服务、打包后恢复服务,再上传 OSS。`Genarrative-Stdb-Module-Publish` 默认也会在 `spacetime publish` 前执行同一脚本;备份失败会阻断 publish,只有显式勾选 `SKIP_DATABASE_BACKUP` 或脚本参数 `--skip-backup` 才跳过。若业务不能接受停机窗口,应先规划 SpacetimeDB 原生快照或主备策略,不要直接在写入中的数据目录上做热拷贝并当作强一致备份。
|
||||
|
||||
生产环境变量模板在 `deploy/env/api-server.env.example`:
|
||||
|
||||
```env
|
||||
GENARRATIVE_DATABASE_BACKUP_DATA_DIR=/stdb
|
||||
GENARRATIVE_DATABASE_BACKUP_WORK_DIR=/var/lib/genarrative/database-backups
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET=
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT=oss-cn-shanghai.aliyuncs.com
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX=database-backups
|
||||
GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL=false
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID=
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET=
|
||||
```
|
||||
|
||||
`GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET` 为空时会回退 `ALIYUN_OSS_BUCKET`;AccessKey 默认复用 `ALIYUN_OSS_ACCESS_KEY_ID` / `ALIYUN_OSS_ACCESS_KEY_SECRET`,也可用 `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID` / `GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET` 为备份 bucket 单独配置最小权限账号。`Genarrative-Server-Provision` 会创建 `/var/lib/genarrative/database-backups` 并归属 `genarrative:genarrative`,同时安装并启用 `genarrative-database-backup.timer`。手动检查定时器:`systemctl list-timers genarrative-database-backup.timer`;手动触发一次:`systemctl start genarrative-database-backup.service`。
|
||||
|
||||
## 生产运维
|
||||
|
||||
生产部署当前口径:
|
||||
@@ -264,6 +289,7 @@ OpenTelemetry 现阶段默认开启 OTLP traces / metrics / logs,但本地日
|
||||
- `GENARRATIVE_SPACETIME_SERVER_URL`
|
||||
- `GENARRATIVE_SPACETIME_DATABASE`
|
||||
- `GENARRATIVE_SPACETIME_TOKEN`
|
||||
- `GENARRATIVE_DATABASE_BACKUP_*`
|
||||
- `GENARRATIVE_LLM_*`
|
||||
- `APIMART_*`
|
||||
- `VECTOR_ENGINE_*`
|
||||
@@ -386,3 +412,5 @@ SELECT * FROM profile_recharge_product_config ORDER BY sort_order ASC;
|
||||
## 文档维护
|
||||
|
||||
当前 `docs/` 只保留少量融合文档。新增稳定知识时优先更新现有文档;只有现有文档无法容纳时才新增带 `【标签名】` 的 Markdown。阶段性流水账、一次性修复记录和已关闭实验不要再新增成长期文档。
|
||||
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
pipeline {
|
||||
pipeline {
|
||||
agent none
|
||||
|
||||
options {
|
||||
@@ -27,6 +27,7 @@ pipeline {
|
||||
string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'spacetime CLI root-dir;需与自托管 spacetimedb.service 一致')
|
||||
string(name: 'SPACETIME_RUN_AS_USER', defaultValue: 'spacetimedb', description: '执行 spacetime publish 的本机用户,默认使用自托管服务用户')
|
||||
booleanParam(name: 'CLEAR_DATABASE', defaultValue: false, description: '是否清空数据库后发布')
|
||||
booleanParam(name: 'SKIP_DATABASE_BACKUP', defaultValue: false, description: '是否跳过 publish 前 OSS 数据库备份;默认不跳过,备份失败会阻断发布')
|
||||
}
|
||||
|
||||
stages {
|
||||
@@ -138,6 +139,7 @@ pipeline {
|
||||
steps {
|
||||
script {
|
||||
def clearArg = params.CLEAR_DATABASE ? '--clear-database' : ''
|
||||
def backupArg = params.SKIP_DATABASE_BACKUP ? '--skip-backup' : ''
|
||||
def rootArg = "--root-dir \"${params.SPACETIME_ROOT_DIR?.trim() ? params.SPACETIME_ROOT_DIR.trim() : '/stdb'}\""
|
||||
def runAsArg = params.SPACETIME_RUN_AS_USER?.trim()
|
||||
? "--run-as-user \"${params.SPACETIME_RUN_AS_USER.trim()}\""
|
||||
@@ -155,7 +157,8 @@ pipeline {
|
||||
${rootArg} \\
|
||||
${runAsArg} \\
|
||||
${serverArg} \\
|
||||
${clearArg}
|
||||
${clearArg} \\
|
||||
${backupArg}
|
||||
'
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -61,7 +61,8 @@
|
||||
"codegraph:init": "codegraph init -i .",
|
||||
"codegraph:index": "codegraph index .",
|
||||
"codegraph:sync": "codegraph sync .",
|
||||
"codegraph:status": "codegraph status ."
|
||||
"codegraph:status": "codegraph status .",
|
||||
"database:backup:oss": "node scripts/database-backup-to-oss.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
|
||||
@@ -461,6 +461,7 @@ copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}/scripts/spacetime-migration-common.mjs" "数据库迁移公共脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/database-backup-to-oss.mjs" "${TARGET_DIR}/scripts/database-backup-to-oss.mjs" "数据库 OSS 备份脚本"
|
||||
|
||||
copy_required_dir "${REPO_ROOT}/deploy/systemd" "${TARGET_DIR}/deploy/systemd" "systemd 配置"
|
||||
copy_required_dir "${REPO_ROOT}/deploy/nginx" "${TARGET_DIR}/deploy/nginx" "Nginx 配置"
|
||||
@@ -480,7 +481,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
|
||||
- \`migration-bootstrap-secret.txt\`:构建 \`spacetime_module.wasm\` 时注入的迁移引导密钥,仅用于创建首个迁移操作员;请作为敏感文件保存到 Jenkins Secret Text,授权完成后不要长期留在公开归档中。
|
||||
- \`*.sha256\`:发布产物 checksum,用于部署前校验。
|
||||
- \`release-manifest.json\`:发布版本、源码 commit 与产物清单。
|
||||
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
|
||||
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、数据库 OSS 备份脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
|
||||
- \`deploy/\`:systemd、Nginx 和生产环境变量示例;\`deploy/nginx/genarrative-dev-http.conf\` 仅供无域名开发服初始化使用。
|
||||
|
||||
## 生产部署口径
|
||||
|
||||
452
scripts/database-backup-to-oss.mjs
Normal file
452
scripts/database-backup-to-oss.mjs
Normal file
@@ -0,0 +1,452 @@
|
||||
#!/usr/bin/env node
|
||||
import {spawnSync} from 'node:child_process';
|
||||
import {createHash, createHmac} from 'node:crypto';
|
||||
import {createReadStream, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync} from 'node:fs';
|
||||
import {basename, dirname, isAbsolute, resolve} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const REPO_ROOT = resolve(__dirname, '..');
|
||||
const DEFAULT_LOCAL_DATA_DIR = resolve(REPO_ROOT, 'server-rs/.spacetimedb/local/data');
|
||||
const DEFAULT_LOCAL_WORK_DIR = resolve(REPO_ROOT, 'server-rs/.data/database-backups');
|
||||
const DEFAULT_PRODUCTION_DATA_DIR = '/stdb';
|
||||
const DEFAULT_PRODUCTION_WORK_DIR = '/var/lib/genarrative/database-backups';
|
||||
const OSS_ALGORITHM = 'OSS4-HMAC-SHA256';
|
||||
const OSS_SERVICE = 'oss';
|
||||
const OSS_REQUEST = 'aliyun_v4_request';
|
||||
const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
|
||||
|
||||
function usage() {
|
||||
console.log(`用法:
|
||||
npm run database:backup:oss -- [--data-dir <path>] [--work-dir <path>] [--bucket <bucket>] [--object-prefix <prefix>] [--keep-local]
|
||||
node scripts/database-backup-to-oss.mjs [--stop-service spacetimedb.service]
|
||||
|
||||
说明:
|
||||
将 SpacetimeDB 数据目录打包成 .tar.gz,并上传到阿里云 OSS 指定 bucket。
|
||||
默认读取 .env / .env.local / .env.secrets.local;生产服务可传 --env-file /etc/genarrative/api-server.env。
|
||||
shell 环境变量优先级最高,不会被 env 文件覆盖。
|
||||
|
||||
常用环境变量:
|
||||
GENARRATIVE_DATABASE_BACKUP_DATA_DIR 数据目录;生产建议 /stdb
|
||||
GENARRATIVE_DATABASE_BACKUP_WORK_DIR 本地临时备份目录;生产建议 /var/lib/genarrative/database-backups
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET 备份 bucket;未设置时回退 ALIYUN_OSS_BUCKET
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX 对象前缀,默认 database-backups
|
||||
GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT OSS endpoint;未设置时回退 ALIYUN_OSS_ENDPOINT
|
||||
GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL true 时保留本地 tar.gz
|
||||
ALIYUN_OSS_ACCESS_KEY_ID / ALIYUN_OSS_ACCESS_KEY_SECRET
|
||||
`);
|
||||
}
|
||||
|
||||
function loadEnvFile(filePath, target, protectedKeys) {
|
||||
if (!existsSync(filePath)) {
|
||||
return;
|
||||
}
|
||||
const rawText = readFileSync(filePath, 'utf8');
|
||||
for (const rawLine of rawText.split(/\r?\n/u)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const [, key, rawValue] = match;
|
||||
if (protectedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
target[key] = rawValue.replace(/^['"]|['"]$/gu, '');
|
||||
}
|
||||
}
|
||||
|
||||
function loadRepoEnv() {
|
||||
const env = {...process.env};
|
||||
const protectedKeys = new Set(
|
||||
Object.entries(process.env)
|
||||
.filter(([, value]) => String(value ?? '').trim())
|
||||
.map(([key]) => key),
|
||||
);
|
||||
for (const fileName of ['.env', '.env.local', '.env.secrets.local']) {
|
||||
loadEnvFile(resolve(REPO_ROOT, fileName), env, protectedKeys);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function loadEffectiveEnv(envFiles) {
|
||||
const env = loadRepoEnv();
|
||||
const protectedKeys = new Set(
|
||||
Object.entries(process.env)
|
||||
.filter(([, value]) => String(value ?? '').trim())
|
||||
.map(([key]) => key),
|
||||
);
|
||||
for (const filePath of envFiles) {
|
||||
loadEnvFile(resolvePath(filePath), env, protectedKeys);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
dataDir: '',
|
||||
workDir: '',
|
||||
bucket: '',
|
||||
endpoint: '',
|
||||
objectPrefix: '',
|
||||
accessKeyId: '',
|
||||
accessKeySecret: '',
|
||||
envFiles: [],
|
||||
keepLocal: false,
|
||||
stopService: '',
|
||||
database: '',
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const readValue = () => {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${arg} 缺少参数值`);
|
||||
}
|
||||
index += 1;
|
||||
return value;
|
||||
};
|
||||
|
||||
switch (arg) {
|
||||
case '--help':
|
||||
case '-h':
|
||||
usage();
|
||||
process.exit(0);
|
||||
break;
|
||||
case '--data-dir':
|
||||
options.dataDir = readValue();
|
||||
break;
|
||||
case '--work-dir':
|
||||
options.workDir = readValue();
|
||||
break;
|
||||
case '--bucket':
|
||||
options.bucket = readValue();
|
||||
break;
|
||||
case '--endpoint':
|
||||
options.endpoint = readValue();
|
||||
break;
|
||||
case '--object-prefix':
|
||||
options.objectPrefix = readValue();
|
||||
break;
|
||||
case '--access-key-id':
|
||||
options.accessKeyId = readValue();
|
||||
break;
|
||||
case '--access-key-secret':
|
||||
options.accessKeySecret = readValue();
|
||||
break;
|
||||
case '--env-file':
|
||||
options.envFiles.push(readValue());
|
||||
break;
|
||||
case '--database':
|
||||
options.database = readValue();
|
||||
break;
|
||||
case '--stop-service':
|
||||
options.stopService = readValue();
|
||||
break;
|
||||
case '--keep-local':
|
||||
options.keepLocal = true;
|
||||
break;
|
||||
case '--dry-run':
|
||||
options.dryRun = true;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function firstNonEmpty(...values) {
|
||||
return values.map((value) => String(value ?? '').trim()).find(Boolean) ?? '';
|
||||
}
|
||||
|
||||
function resolvePath(value) {
|
||||
return isAbsolute(value) ? value : resolve(REPO_ROOT, value);
|
||||
}
|
||||
|
||||
function normalizeEndpoint(raw) {
|
||||
return String(raw ?? '')
|
||||
.trim()
|
||||
.replace(/^https?:\/\//u, '')
|
||||
.replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
function sanitizeObjectPart(value, fallback) {
|
||||
const sanitized = String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/gu, '-')
|
||||
.replace(/-+/gu, '-')
|
||||
.replace(/^-|-$/gu, '');
|
||||
return sanitized || fallback;
|
||||
}
|
||||
|
||||
function timestampForFile(date = new Date()) {
|
||||
const pad = (value) => String(value).padStart(2, '0');
|
||||
return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`;
|
||||
}
|
||||
|
||||
function buildBackupNames({database, dataDir, objectPrefix}) {
|
||||
const timestamp = timestampForFile();
|
||||
const databasePart = sanitizeObjectPart(database || basename(dataDir), 'spacetimedb');
|
||||
const fileName = `${databasePart}-${timestamp}.tar.gz`;
|
||||
const prefix = String(objectPrefix || 'database-backups')
|
||||
.trim()
|
||||
.replace(/^\/+|\/+$/gu, '')
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map((part) => sanitizeObjectPart(part, 'backup'))
|
||||
.join('/');
|
||||
const objectKey = [prefix, databasePart, fileName].filter(Boolean).join('/');
|
||||
return {fileName, objectKey};
|
||||
}
|
||||
|
||||
function runCommand(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? REPO_ROOT,
|
||||
env: options.env ?? process.env,
|
||||
encoding: 'utf8',
|
||||
stdio: options.stdio ?? 'pipe',
|
||||
shell: process.platform === 'win32',
|
||||
});
|
||||
if (result.error) {
|
||||
throw new Error(`${command} 启动失败: ${result.error.message}`);
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`.trim();
|
||||
throw new Error(`${command} 退出码 ${result.status}: ${output}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function stopServiceIfNeeded(serviceName) {
|
||||
if (!serviceName) {
|
||||
return false;
|
||||
}
|
||||
console.log(`[database-backup] 停止服务以获取冷备份: ${serviceName}`);
|
||||
runCommand('systemctl', ['stop', serviceName], {stdio: 'inherit'});
|
||||
return true;
|
||||
}
|
||||
|
||||
function startServiceIfNeeded(serviceName, wasStopped) {
|
||||
if (!serviceName || !wasStopped) {
|
||||
return;
|
||||
}
|
||||
console.log(`[database-backup] 恢复服务: ${serviceName}`);
|
||||
runCommand('systemctl', ['start', serviceName], {stdio: 'inherit'});
|
||||
}
|
||||
|
||||
function createArchive({dataDir, workDir, fileName}) {
|
||||
if (!existsSync(dataDir)) {
|
||||
throw new Error(`数据库数据目录不存在: ${dataDir}`);
|
||||
}
|
||||
const stat = statSync(dataDir);
|
||||
if (!stat.isDirectory()) {
|
||||
throw new Error(`数据库数据路径不是目录: ${dataDir}`);
|
||||
}
|
||||
mkdirSync(workDir, {recursive: true});
|
||||
const archivePath = resolve(workDir, fileName);
|
||||
const parentDir = dirname(dataDir);
|
||||
const entryName = basename(dataDir);
|
||||
console.log(`[database-backup] 打包: ${dataDir} -> ${archivePath}`);
|
||||
runCommand('tar', ['-czf', archivePath, '-C', parentDir, entryName], {stdio: 'inherit'});
|
||||
return archivePath;
|
||||
}
|
||||
|
||||
function hmac(key, content, encoding) {
|
||||
return createHmac('sha256', key).update(content).digest(encoding);
|
||||
}
|
||||
|
||||
function sha256Hex(content) {
|
||||
return createHash('sha256').update(content).digest('hex');
|
||||
}
|
||||
|
||||
function regionFromEndpoint(endpoint) {
|
||||
const match = /^oss-([a-z0-9-]+)\./u.exec(endpoint);
|
||||
if (!match) {
|
||||
throw new Error(`无法从 OSS endpoint 推断 region: ${endpoint}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function formatScopeDate(date) {
|
||||
return timestampForFile(date).slice(0, 8);
|
||||
}
|
||||
|
||||
function formatOssDate(date) {
|
||||
return timestampForFile(date).replace(/[-:]/gu, '');
|
||||
}
|
||||
|
||||
function encodePath(path) {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment).replace(/[!'()*]/gu, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function canonicalHeaderValue(value) {
|
||||
return String(value).trim().replace(/\s+/gu, ' ');
|
||||
}
|
||||
|
||||
function buildAuthorization({method, bucket, endpoint, objectKey, accessKeyId, accessKeySecret, headers, date}) {
|
||||
const region = regionFromEndpoint(endpoint);
|
||||
const scopeDate = formatScopeDate(date);
|
||||
const scope = `${scopeDate}/${region}/${OSS_SERVICE}/${OSS_REQUEST}`;
|
||||
const canonicalUri = `/${encodeURIComponent(bucket)}/${encodePath(objectKey)}`;
|
||||
const signedHeaders = Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), canonicalHeaderValue(value)]),
|
||||
);
|
||||
const canonicalHeaders = Object.entries(signedHeaders)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, value]) => `${key}:${value}\n`)
|
||||
.join('');
|
||||
const additionalHeaders = 'host';
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
canonicalUri,
|
||||
'',
|
||||
canonicalHeaders,
|
||||
additionalHeaders,
|
||||
UNSIGNED_PAYLOAD,
|
||||
].join('\n');
|
||||
const stringToSign = [OSS_ALGORITHM, headers['x-oss-date'], scope, sha256Hex(canonicalRequest)].join('\n');
|
||||
const signature = hmac(Buffer.from(`aliyun_v4${accessKeySecret}`, 'utf8'), scopeDate);
|
||||
const regionKey = hmac(signature, region);
|
||||
const serviceKey = hmac(regionKey, OSS_SERVICE);
|
||||
const signingKey = hmac(serviceKey, OSS_REQUEST);
|
||||
const finalSignature = hmac(signingKey, stringToSign, 'hex');
|
||||
return `${OSS_ALGORITHM} Credential=${accessKeyId}/${scope},AdditionalHeaders=${additionalHeaders},Signature=${finalSignature}`;
|
||||
}
|
||||
|
||||
async function uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret}) {
|
||||
const fileStat = statSync(archivePath);
|
||||
const now = new Date();
|
||||
const targetUrl = `https://${bucket}.${endpoint}/${encodePath(objectKey)}`;
|
||||
const headers = {
|
||||
host: `${bucket}.${endpoint}`,
|
||||
'content-type': 'application/gzip',
|
||||
'x-oss-content-sha256': UNSIGNED_PAYLOAD,
|
||||
'x-oss-date': formatOssDate(now),
|
||||
'x-oss-meta-backup-kind': 'spacetimedb-data-dir',
|
||||
};
|
||||
const authorization = buildAuthorization({
|
||||
method: 'PUT',
|
||||
bucket,
|
||||
endpoint,
|
||||
objectKey,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
headers,
|
||||
date: now,
|
||||
});
|
||||
|
||||
console.log(`[database-backup] 上传 OSS: oss://${bucket}/${objectKey}`);
|
||||
const response = await fetch(targetUrl, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
...headers,
|
||||
authorization,
|
||||
'content-length': String(fileStat.size),
|
||||
},
|
||||
body: createReadStream(archivePath),
|
||||
duplex: 'half',
|
||||
});
|
||||
|
||||
const responseText = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(`OSS 上传失败 HTTP ${response.status}: ${responseText.slice(0, 500)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
bucket,
|
||||
objectKey,
|
||||
contentLength: fileStat.size,
|
||||
etag: response.headers.get('etag')?.replace(/^"|"$/gu, '') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const env = loadEffectiveEnv(args.envFiles);
|
||||
const isProductionLike = existsSync(DEFAULT_PRODUCTION_DATA_DIR) && process.platform !== 'win32';
|
||||
const dataDir = resolvePath(firstNonEmpty(
|
||||
args.dataDir,
|
||||
env.GENARRATIVE_DATABASE_BACKUP_DATA_DIR,
|
||||
isProductionLike ? DEFAULT_PRODUCTION_DATA_DIR : DEFAULT_LOCAL_DATA_DIR,
|
||||
));
|
||||
const workDir = resolvePath(firstNonEmpty(
|
||||
args.workDir,
|
||||
env.GENARRATIVE_DATABASE_BACKUP_WORK_DIR,
|
||||
isProductionLike ? DEFAULT_PRODUCTION_WORK_DIR : DEFAULT_LOCAL_WORK_DIR,
|
||||
));
|
||||
const bucket = firstNonEmpty(args.bucket, env.GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET, env.ALIYUN_OSS_BUCKET);
|
||||
const endpoint = normalizeEndpoint(firstNonEmpty(args.endpoint, env.GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT, env.ALIYUN_OSS_ENDPOINT));
|
||||
const accessKeyId = firstNonEmpty(args.accessKeyId, env.GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID, env.ALIYUN_OSS_ACCESS_KEY_ID);
|
||||
const accessKeySecret = firstNonEmpty(args.accessKeySecret, env.GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET, env.ALIYUN_OSS_ACCESS_KEY_SECRET);
|
||||
const objectPrefix = firstNonEmpty(args.objectPrefix, env.GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX, 'database-backups');
|
||||
const database = firstNonEmpty(args.database, env.GENARRATIVE_SPACETIME_DATABASE, basename(dataDir));
|
||||
const keepLocal = args.keepLocal || String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '').trim().toLowerCase() === 'true';
|
||||
|
||||
for (const [label, value] of Object.entries({bucket, endpoint, accessKeyId, accessKeySecret})) {
|
||||
if (!value) {
|
||||
throw new Error(`缺少 ${label} 配置`);
|
||||
}
|
||||
}
|
||||
|
||||
const {fileName, objectKey} = buildBackupNames({database, dataDir, objectPrefix});
|
||||
console.log(`[database-backup] 数据目录: ${dataDir}`);
|
||||
console.log(`[database-backup] 本地临时目录: ${workDir}`);
|
||||
console.log(`[database-backup] 目标对象: oss://${bucket}/${objectKey}`);
|
||||
|
||||
if (args.dryRun) {
|
||||
console.log('[database-backup] dry-run,仅校验配置,不打包上传。');
|
||||
return;
|
||||
}
|
||||
|
||||
let archivePath = '';
|
||||
let serviceStopped = false;
|
||||
try {
|
||||
serviceStopped = stopServiceIfNeeded(args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE));
|
||||
archivePath = createArchive({dataDir, workDir, fileName});
|
||||
} finally {
|
||||
startServiceIfNeeded(args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE), serviceStopped);
|
||||
}
|
||||
|
||||
const result = await uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret});
|
||||
console.log(`[database-backup] 上传完成: ${JSON.stringify(result)}`);
|
||||
|
||||
const manifestPath = `${archivePath}.manifest.json`;
|
||||
writeFileSync(
|
||||
manifestPath,
|
||||
`${JSON.stringify({
|
||||
createdAt: new Date().toISOString(),
|
||||
dataDir,
|
||||
bucket: result.bucket,
|
||||
objectKey: result.objectKey,
|
||||
contentLength: result.contentLength,
|
||||
etag: result.etag,
|
||||
}, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
|
||||
if (!keepLocal) {
|
||||
rmSync(archivePath, {force: true});
|
||||
rmSync(manifestPath, {force: true});
|
||||
console.log('[database-backup] 已删除本地临时备份文件;如需保留请设置 --keep-local。');
|
||||
} else {
|
||||
console.log(`[database-backup] 已保留本地备份: ${archivePath}`);
|
||||
console.log(`[database-backup] 已保留备份清单: ${manifestPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(`[database-backup] ${error instanceof Error ? error.message : String(error)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -5,13 +5,14 @@ set -euo pipefail
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
用法:
|
||||
./scripts/deploy/production-stdb-publish.sh --source-dir build/<version> --database <database> [--server-url http://127.0.0.1:3101] [--server local] [--root-dir /stdb] [--run-as-user spacetimedb] [--clear-database]
|
||||
./scripts/deploy/production-stdb-publish.sh --source-dir build/<version> --database <database> [--server-url http://127.0.0.1:3101] [--server local] [--root-dir /stdb] [--run-as-user spacetimedb] [--clear-database] [--skip-backup]
|
||||
|
||||
说明:
|
||||
进入维护模式,校验 spacetime_module.wasm.sha256,并在生产实例本机执行 spacetime publish。
|
||||
默认使用 http://127.0.0.1:3101,避免与部署机本机 Git/Web 服务的 3000 端口冲突。
|
||||
默认使用 /stdb 作为 spacetime CLI root-dir,并以 spacetimedb 用户发布,避免 root CLI 身份污染自托管实例。
|
||||
发布时固定追加 --no-config,只使用显式参数,避免工作区或用户目录里的 spacetime 配置干扰目标。
|
||||
publish 前默认执行一次 OSS 冷备份;备份失败会阻断 publish。仅明确传入 --skip-backup 时跳过。
|
||||
失败时保留维护模式。
|
||||
EOF
|
||||
}
|
||||
@@ -43,6 +44,7 @@ SERVER_URL="http://127.0.0.1:3101"
|
||||
SPACETIME_ROOT_DIR="/stdb"
|
||||
RUN_AS_USER="spacetimedb"
|
||||
CLEAR_DATABASE=0
|
||||
SKIP_BACKUP=0
|
||||
DEPLOY_COMPLETED=0
|
||||
PUBLISH_TMP_DIR=""
|
||||
|
||||
@@ -81,6 +83,10 @@ while [[ $# -gt 0 ]]; do
|
||||
CLEAR_DATABASE=1
|
||||
shift
|
||||
;;
|
||||
--skip-backup)
|
||||
SKIP_BACKUP=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
echo "[production-stdb-publish] 未知参数: $1" >&2
|
||||
usage >&2
|
||||
@@ -130,6 +136,26 @@ trap on_exit EXIT
|
||||
|
||||
"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}"
|
||||
|
||||
if [[ "${SKIP_BACKUP}" -ne 1 ]]; then
|
||||
BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs"
|
||||
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
|
||||
BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
|
||||
fi
|
||||
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
|
||||
echo "[production-stdb-publish] 缺少 publish 前数据库备份脚本: ${BACKUP_SCRIPT}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[production-stdb-publish] publish 前执行 OSS 冷备份"
|
||||
node "${BACKUP_SCRIPT}" \
|
||||
--env-file /etc/genarrative/api-server.env \
|
||||
--data-dir "${SPACETIME_ROOT_DIR}" \
|
||||
--database "${DATABASE}" \
|
||||
--stop-service spacetimedb.service
|
||||
else
|
||||
echo "[production-stdb-publish] 已按参数跳过 publish 前数据库备份"
|
||||
fi
|
||||
|
||||
echo "[production-stdb-publish] 校验 wasm"
|
||||
(
|
||||
cd "${SOURCE_DIR}"
|
||||
|
||||
@@ -642,8 +642,20 @@ render_api_service() {
|
||||
deploy/systemd/genarrative-api.service
|
||||
}
|
||||
|
||||
render_database_backup_service() {
|
||||
local current_escaped env_escaped
|
||||
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
|
||||
env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")"
|
||||
sed \
|
||||
-e "s|/opt/genarrative/current|${current_escaped}|g" \
|
||||
-e "s|/etc/genarrative/api-server.env|${env_escaped}|g" \
|
||||
deploy/systemd/genarrative-database-backup.service
|
||||
}
|
||||
|
||||
require_path deploy/systemd/spacetimedb.service
|
||||
require_path deploy/systemd/genarrative-api.service
|
||||
require_path deploy/systemd/genarrative-database-backup.service
|
||||
require_path deploy/systemd/genarrative-database-backup.timer
|
||||
require_path deploy/systemd/otelcol-contrib.service
|
||||
require_path deploy/otelcol/genarrative-debug.yaml
|
||||
require_path deploy/nginx/genarrative.conf
|
||||
@@ -663,7 +675,7 @@ run_cmd id
|
||||
install_build_dependencies
|
||||
install_nginx_brotli_modules
|
||||
install_sccache
|
||||
run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox
|
||||
run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox /var/lib/genarrative/database-backups
|
||||
|
||||
if ! id spacetimedb >/dev/null 2>&1; then
|
||||
run_cmd useradd --system --home-dir "${SPACETIME_ROOT}" --shell /usr/sbin/nologin spacetimedb
|
||||
@@ -693,11 +705,15 @@ sync_spacetime_install "${SPACETIME_ROOT}"
|
||||
|
||||
spacetimedb_service="$(mktemp)"
|
||||
api_service="$(mktemp)"
|
||||
database_backup_service="$(mktemp)"
|
||||
render_spacetimedb_service >"${spacetimedb_service}"
|
||||
render_api_service >"${api_service}"
|
||||
render_database_backup_service >"${database_backup_service}"
|
||||
install_file "${spacetimedb_service}" /etc/systemd/system/spacetimedb.service 0644
|
||||
install_file "${api_service}" /etc/systemd/system/genarrative-api.service 0644
|
||||
rm -f "${spacetimedb_service}" "${api_service}"
|
||||
install_file "${database_backup_service}" /etc/systemd/system/genarrative-database-backup.service 0644
|
||||
install_file deploy/systemd/genarrative-database-backup.timer /etc/systemd/system/genarrative-database-backup.timer 0644
|
||||
rm -f "${spacetimedb_service}" "${api_service}" "${database_backup_service}"
|
||||
|
||||
if [[ ! -f "${API_ENV_FILE}" ]]; then
|
||||
echo "+ create ${API_ENV_FILE} from example"
|
||||
@@ -732,7 +748,7 @@ if [[ "${ENABLE_SERVICES}" == "true" ]]; then
|
||||
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
||||
run_cmd systemctl enable otelcol-contrib.service
|
||||
fi
|
||||
run_cmd systemctl enable spacetimedb.service genarrative-api.service
|
||||
run_cmd systemctl enable spacetimedb.service genarrative-api.service genarrative-database-backup.timer
|
||||
if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then
|
||||
run_cmd systemctl restart otelcol-contrib.service
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user