修复编辑器定价持久化与资源预览

将模型定价改为 SpacetimeDB 强类型持久化并兼容旧配置种子迁移
修复快速编辑支付弹窗期间框选显示和交互冻结
补齐后台图片放大以及视频音频资源预览
收紧主站和 External 资源换签授权并记录管理员跨用户审计
固化外部生成入队价格、attempt 钱包结算和最终 lease 失败收口
加固运行时身份轮换、bootstrap secret 与生产构建发布门禁
同步生成绑定、定向测试、运维脚本和项目文档
This commit is contained in:
2026-07-10 18:10:31 +08:00
parent 5c9b5ef69e
commit 187d7735b9
79 changed files with 7457 additions and 468 deletions
+34 -37
View File
@@ -105,6 +105,16 @@ generate_migration_bootstrap_secret() {
node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));'
}
persist_generated_migration_bootstrap_secret() {
local secret_dir="${SERVER_RS_DIR}/.spacetimedb/build-secrets"
local secret_path="${secret_dir}/${BUILD_NAME}.txt"
mkdir -p "${secret_dir}"
chmod 700 "${secret_dir}" 2>/dev/null || true
(umask 077; printf '%s\n' "${MIGRATION_BOOTSTRAP_SECRET}" >"${secret_path}")
chmod 600 "${secret_path}" 2>/dev/null || true
echo "[production-release] 自动生成的迁移引导密钥仅写入本机受保护文件: ${secret_path}"
}
prepare_migration_bootstrap_secret() {
local secret_source="generated"
@@ -114,48 +124,43 @@ prepare_migration_bootstrap_secret() {
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" == "disabled" ]]; then
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256
echo "[production-release] 未启用迁移引导密钥。"
return
fi
if [[ -n "${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256:-}" ]]; then
if [[ ! "${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256}" =~ ^[0-9a-fA-F]{64}$ ]]; then
echo "[production-release] 迁移引导密钥 SHA-256 必须是 64 位十六进制。" >&2
exit 1
fi
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256="${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256,,}"
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
echo "[production-release] 已准备迁移引导密钥摘要: source=sha256-environment"
return
fi
if [[ -n "${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET:-}" ]]; then
MIGRATION_BOOTSTRAP_SECRET="${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET}"
secret_source="environment"
else
MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)"
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}"
persist_generated_migration_bootstrap_secret
fi
if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then
echo "[production-release] 迁移引导密钥至少需要 16 个字符。" >&2
if [[ ! "${MIGRATION_BOOTSTRAP_SECRET}" =~ ^[0-9a-fA-F]{64}$ ]]; then
echo "[production-release] 迁移引导密钥必须是 64 位十六进制高熵值。" >&2
exit 1
fi
local digest_line
digest_line="$(printf '%s' "${MIGRATION_BOOTSTRAP_SECRET}" | sha256sum)"
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256="${digest_line%% *}"
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
echo "[production-release] 已准备迁移引导密钥: source=${secret_source}, length=${#MIGRATION_BOOTSTRAP_SECRET}"
}
write_migration_bootstrap_secret_file() {
local target_path="${TARGET_DIR}/migration-bootstrap-secret.txt"
if [[ "${BUILD_SPACETIME}" -ne 1 || "${SKIP_SPACETIME_BUILD}" -eq 1 ]]; then
return
fi
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" == "disabled" ]]; then
return
fi
if [[ -z "${MIGRATION_BOOTSTRAP_SECRET}" ]]; then
echo "[production-release] 迁移引导密钥为空,无法写入发布产物。" >&2
exit 1
fi
printf "%s\n" "${MIGRATION_BOOTSTRAP_SECRET}" >"${target_path}"
chmod 600 "${target_path}" 2>/dev/null || true
MIGRATION_BOOTSTRAP_SECRET_ARTIFACT=1
echo "[production-release] 已写入迁移引导密钥文件: ${target_path}"
}
write_release_manifest() {
RELEASE_MANIFEST_PATH="${TARGET_DIR}/release-manifest.json" \
RELEASE_VERSION="${BUILD_NAME}" \
@@ -167,7 +172,7 @@ write_release_manifest() {
RELEASE_INCLUDE_API="${BUILD_API}" \
RELEASE_INCLUDE_SPACETIME="${BUILD_SPACETIME}" \
RELEASE_INCLUDE_PINGORA_GATEWAY="${INCLUDE_PINGORA_GATEWAY}" \
RELEASE_INCLUDE_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET_ARTIFACT}" \
RELEASE_MIGRATION_BOOTSTRAP_SECRET_SHA256="${GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256:-}" \
node <<'NODE'
const fs = require('fs');
@@ -200,20 +205,14 @@ if (process.env.RELEASE_INCLUDE_PINGORA_GATEWAY === '1') {
checksum_path: 'pingora-gateway.sha256',
});
}
if (process.env.RELEASE_INCLUDE_MIGRATION_BOOTSTRAP_SECRET === '1') {
artifacts.push({
component: 'spacetime-module',
path: 'migration-bootstrap-secret.txt',
sensitive: true,
});
}
const manifest = {
version: process.env.RELEASE_VERSION,
source_branch: process.env.RELEASE_SOURCE_BRANCH,
source_commit: process.env.RELEASE_SOURCE_COMMIT,
built_at: process.env.RELEASE_BUILT_AT,
component_type: process.env.RELEASE_COMPONENT,
migration_bootstrap_secret_sha256:
process.env.RELEASE_MIGRATION_BOOTSTRAP_SECRET_SHA256 || null,
artifacts,
};
@@ -233,7 +232,6 @@ SKIP_SPACETIME_BUILD=0
SKIP_PINGORA_GATEWAY_BUILD=0
INCLUDE_PINGORA_GATEWAY=0
MIGRATION_BOOTSTRAP_SECRET=""
MIGRATION_BOOTSTRAP_SECRET_ARTIFACT=0
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
BUILD_COMPLETED=0
@@ -508,7 +506,6 @@ fi
if [[ "${BUILD_SPACETIME}" -eq 1 ]]; then
copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm"
write_sha256_file "${TARGET_DIR}/spacetime_module.wasm"
write_migration_bootstrap_secret_file
fi
mkdir -p "${TARGET_DIR}/scripts" "${TARGET_DIR}/scripts/deploy" "${TARGET_DIR}/scripts/ops" "${TARGET_DIR}/deploy"
@@ -584,7 +581,7 @@ cat >"${TARGET_DIR}/README.md" <<EOF
- \`api-server\`:生产 Linux release 可执行文件。
- \`pingora-gateway\`:可选 Pingora 影子网关可执行文件,仅在显式 \`--include-pingora-gateway\` 时包含。
- \`spacetime_module.wasm\`SpacetimeDB 模块 wasm。
- \`migration-bootstrap-secret.txt\`:构建 \`spacetime_module.wasm\` 时注入的迁移引导密钥,仅用于创建首个迁移操作员;请作为敏感文件保存到 Jenkins Secret Text,授权完成后不要长期留在公开归档中
- 迁移引导密钥不进入发布包;WASM 仅嵌入 SHA-256 摘要,原始 secret 由 Jenkins Secret File 在 Stdb publish 时受保护地交付
- \`*.sha256\`:发布产物 checksum,用于部署前校验。
- \`release-manifest.json\`:发布版本、源码 commit 与产物清单。
- \`scripts/\`:维护模式脚本、数据库导入导出脚本、数据库 OSS 备份脚本、生产健康巡检脚本、Pingora release readiness 聚合门禁、直连启用 / 回退、realpath canary 启用 / 关闭、health patrol env 切换 / TLS 证书同步 / 预检 / direct live smoke / canary live smoke / canary access log 对账 / current release 自审 / 直连彩排状态 / 状态快照 / 证据包 / 命令证据 / 证据验真 / 证据根目录审计脚本、迁移授权脚本和 Jenkins inbound agent systemd 安装脚本。
+298
View File
@@ -3,6 +3,304 @@
import { readFileSync } from 'node:fs';
const checks = [
{
file: 'server-rs/crates/spacetime-module/src/migration.rs',
includes:
'option_env!("GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256")',
reason: '可下载的 SpacetimeDB WASM 只能嵌入 bootstrap secret 摘要。',
},
{
file: 'server-rs/crates/spacetime-module/src/migration.rs',
excludes: 'option_env!("GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET")',
reason: 'SpacetimeDB WASM 不得嵌入原始 bootstrap secret。',
},
{
file: 'server-rs/crates/spacetime-module/src/migration.rs',
includes: 'secret.len() == MIGRATION_BOOTSTRAP_SECRET_HEX_LEN',
reason: '模块授权入口必须拒绝非 64 位十六进制 bootstrap secret。',
},
{
file: 'scripts/build-production-release.sh',
includes: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256',
reason: '生产模块构建前必须把受保护 secret 转换为 SHA-256 摘要。',
},
{
file: 'scripts/build-production-release.sh',
excludes: "path: 'migration-bootstrap-secret.txt'",
reason: '生产发布清单不得包含原始 bootstrap secret artifact。',
},
{
file: 'scripts/build-production-release.sh',
includes: 'migration_bootstrap_secret_sha256:',
reason: 'Stdb release manifest 必须记录非敏感 bootstrap secret 摘要。',
},
{
file: 'scripts/deploy-rust-remote.sh',
includes: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256',
reason: '远程 Rust 模块构建也只能向 rustc 传递 bootstrap secret 摘要。',
},
{
file: 'scripts/deploy-rust-remote.sh',
includes: 'server-rs/.spacetimedb/build-secrets',
reason: '旧 Ubuntu 直传入口也必须把原始 bootstrap secret 留在发布包外的受保护 sidecar。',
},
{
file: 'scripts/deploy-rust-remote.sh',
excludes: '"${TARGET_DIR}/migration-bootstrap-secret.txt"',
reason: '旧 Ubuntu 发布包目录不得再次包含原始 bootstrap secret。',
},
{
file: 'scripts/deploy-rust-remote.sh',
includes: '<"${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"',
reason: '远端所需原始 secret 必须通过独立 SSH 标准输入交付,不得进入命令参数或发布包。',
},
{
file: 'scripts/deploy-rust-remote.sh',
includes: '--migration-bootstrap-secret-file)',
reason: '手工直传入口只能从受保护文件读取 bootstrap secret。',
},
{
file: 'scripts/deploy-rust-remote.sh',
excludes: '--migration-bootstrap-secret)',
reason: 'bootstrap secret 明文不得出现在进程命令参数中。',
},
{
file: 'scripts/deploy-rust-remote.sh',
includes: '[[ ! "${MIGRATION_BOOTSTRAP_SECRET}" =~ ^[0-9a-fA-F]{64}$ ]]',
reason: '旧 Ubuntu 手工直传入口必须拒绝非 64 位十六进制 bootstrap secret。',
},
{
file: 'scripts/deploy-rust-remote.sh',
excludes: "tr -d '\\r\\n'",
reason: '旧 Ubuntu 手工直传入口不得把 secret 文件内部换行静默拼接。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: 'install -o root -g genarrative -m 0440',
reason:
'生产 Stdb publish 必须把运行时 bootstrap secret 安装成 root 持有、服务组只读的文件。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: 'restart_runtime_services_after_bootstrap_secret_install',
reason:
'替换运行时 bootstrap secret 后必须重启已运行的 API、worker 和 controller,避免进程继续持有旧值。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: 'get_runtime_service_active_state',
reason:
'生产 Stdb publish 必须区分 systemctl 明确返回的非 active 状态与查询错误,查询错误不得静默跳过服务重启。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: '"${exit_code}" -eq 3',
reason:
'生产 Stdb publish 只能把 systemctl 明确的非 active 返回码作为可跳过状态,其他错误必须阻断。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: 'if worker_units_output="$(',
reason:
'生产 Stdb publish 必须直接检查 worker list-units 的退出状态,不能用会吞掉失败的进程替换管道。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: '查询 active worker 服务失败,阻断退出维护模式',
reason:
'生产 Stdb publish 无法枚举 active worker 时必须阻断,不能在未知 worker 状态下退出维护模式。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: 'restart_runtime_service_and_require_active',
reason:
'生产 Stdb publish 必须确认重启前 active 的 API、controller 和 worker 在重启后恢复 active。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: 'if ! restart_runtime_service_and_require_active genarrative-api.service; then',
reason:
'生产 Stdb publish 必须显式传播 API 重启或验活失败,不能依赖调用上下文中的 set -e。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes:
'restart_runtime_services_after_bootstrap_secret_install\nwait_for_api_healthz_ready\n\n"${SCRIPT_DIR}/maintenance-off.sh"',
reason:
'生产 Stdb publish 在 API 原本 active 时必须先通过本机 /healthz readiness 再退出维护模式。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: 'ensure_runtime_bootstrap_secret_env_file "${API_ENV_FILE}" true',
reason:
'Stdb 先于 API 发布时必须先补齐 api-server env 的 FILE 路径,保证首次 rollout 重启即可读取 secret。',
},
{
file: 'scripts/deploy/production-api-deploy.sh',
includes: 'ensure_runtime_bootstrap_secret_file_env',
reason:
'生产 API/worker env 必须统一指向 Stdb publish 写入的受保护 bootstrap secret 文件。',
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'ensure_runtime_bootstrap_secret_file_env',
reason:
'Server-Provision 必须为新建及存量 API/worker env 补齐 bootstrap secret FILE 配置。',
},
{
file: 'deploy/env/api-server.env.example',
includes:
'GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE=/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt',
reason: '生产 API env 示例只能登记 bootstrap secret 的固定 FILE 路径。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
includes: "file(credentialsId: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID.trim(), variable: 'MIGRATION_BOOTSTRAP_SECRET_FILE')",
reason:
'Stdb Publish 必须从 Jenkins Secret File credential 受保护地取得与 wasm 匹配的 bootstrap secret。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
excludes: 'migration-bootstrap-secret.txt',
reason:
'Stdb Publish 不得通过普通 Jenkins artifact 或工作区副本交付 bootstrap secret。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
includes: 'MIGRATION_BOOTSTRAP_SECRET_FILE:?MIGRATION_BOOTSTRAP_SECRET_FILE 不能为空',
reason:
'Stdb Publish 必须把受保护文件直接传给发布脚本,且不能展开明文。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
includes: "file(credentialsId: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID.trim(), variable: 'MIGRATION_BOOTSTRAP_SECRET_FILE')",
reason: 'Stdb Build 必须用 Jenkins Secret File credential 为 option_env! 提供受控编译输入。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
excludes: 'archiveArtifacts artifacts: "build/${env.EFFECTIVE_BUILD_VERSION}/migration-bootstrap-secret.txt"',
reason: 'bootstrap secret 不得作为普通、可下载的 Jenkins archive artifact。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
includes: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256',
reason: 'Stdb Build 只能把 File credential 的 SHA-256 摘要传给 Rust 编译。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
excludes: 'export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET=',
reason: 'Jenkins Stdb Build 不得把原始 bootstrap secret 导出给 Rust 编译进程。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: '--migration-bootstrap-secret-file',
reason: '生产 Stdb publish 必须只接受受保护的 bootstrap secret FILE。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
excludes: '${SOURCE_DIR}/migration-bootstrap-secret.txt',
reason: '生产 Stdb publish 不得从可下载的构建 artifact 读取 bootstrap secret。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: 'EXPECTED_MIGRATION_BOOTSTRAP_SECRET_SHA256=',
reason: '生产 Stdb publish 必须读取 release manifest 中的 bootstrap secret 摘要。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: 'Secret File 与构建 WASM 的 bootstrap secret 摘要不一致',
reason: '生产 Stdb publish 必须阻断 Secret File 与构建 WASM 摘要不一致。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: '[[ ! "${MIGRATION_BOOTSTRAP_SECRET}" =~ ^[0-9a-fA-F]{64}$ ]]',
reason: '生产 Stdb publish 必须拒绝非 64 位十六进制 Secret File。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
includes: '[[ ! "${migration_bootstrap_secret}" =~ ^[0-9a-fA-F]{64}$ ]]',
reason: 'Stdb Build 必须在计算摘要前拒绝非 64 位十六进制 Secret File。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
excludes: 'tr -d "\\\\r\\\\n"',
reason: 'Stdb Build 不得把 Secret File 内部换行静默拼接。',
},
{
file: 'scripts/spacetime-migration-common.mjs',
includes: "if (!/^[0-9a-f]{64}$/iu.test(secret))",
reason: '迁移脚本 FILE 入口必须拒绝非 64 位十六进制 bootstrap secret。',
},
{
file: 'scripts/dev.mjs',
includes: 'const BOOTSTRAP_SECRET_PATTERN = /^[0-9a-fA-F]{64}$/u;',
reason: '本地 dev 构建与运行服务必须共用严格的 64 位十六进制 secret 规则。',
},
{
file: 'scripts/deploy/production-runtime-writer-identity-rotate.mjs',
includes: 'rotate_editor_generation_runtime_service_identity_and_return',
reason: 'runtime writer identity rotation 必须复用模块的受审计 procedure。',
},
{
file: 'scripts/deploy/production-runtime-writer-identity-rotate.mjs',
includes: '--confirm-next-writer-identity',
reason: 'runtime writer identity rotation 必须要求 operator 二次确认目标 identity。',
},
{
file: 'scripts/deploy/production-runtime-writer-identity-rotate.mjs',
includes: 'runtime writer identity 不能设为 migration operator identity',
reason: 'rotation 入口不能把 migration operator 自动或直接设为 runtime writer。',
},
{
file: 'server-rs/crates/spacetime-module/src/editor_project_storage.rs',
includes:
'crate::migration::is_database_migration_operator(ctx, next_writer_identity)',
reason:
'rotation procedure 必须按数据库中的完整 migration operator 集合校验目标 identity。',
},
{
file: 'scripts/deploy/production-runtime-writer-identity-rotate.mjs',
includes: "spawn('spacetime', ['login', 'show']",
reason: 'rotation 入口必须校验声明的 operator identity 与当前 CLI 登录 identity 一致。',
},
{
file: 'scripts/deploy/production-runtime-writer-identity-rotate.mjs',
excludes: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET',
reason: 'runtime writer identity rotation 不得读取或传递 migration bootstrap secret。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
includes: '--worker-env-file "${params.WORKER_ENV_FILE}"',
reason: 'Stdb Publish 必须把 worker env 路径传给随包发布脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
includes: "string(name: 'WORKER_ENV_FILE', value: params.WORKER_ENV_FILE",
reason: '全量发布必须向 Stdb 与 API 两段透传同一 worker env 路径。',
},
{
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
includes:
"error('MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID 必须引用受保护的 Jenkins Secret File 凭据。')",
reason: '全量构建必须在启动并行子流水线前拒绝缺失的 Secret File 凭据。',
},
{
file: 'jenkins/Jenkinsfile.production-full-build-and-deploy',
includes:
"string(name: 'MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID', value: params.MIGRATION_BOOTSTRAP_SECRET_CREDENTIAL_ID)",
reason: '全量发布必须把与 wasm 构建一致的 Secret File 凭据透传给 Stdb Publish。',
},
{
file: 'scripts/deploy-rust-remote.sh',
excludes:
'echo "[deploy:rust] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}"',
reason: '发布包构建日志不得输出 bootstrap secret 明文。',
},
{
file: 'scripts/dev.mjs',
excludes: '[dev:spacetime] 迁移引导密钥: ${',
reason: '本地 dev 日志不得输出 bootstrap secret 明文。',
},
{
file: 'deploy/systemd/genarrative-database-backup.service',
includes: '--restart-service-after genarrative-api.service',
+80 -31
View File
@@ -28,6 +28,8 @@ usage() {
--skip-web-build 跳过 Vite 构建,仅用于调试
--skip-api-build 跳过 api-server 构建,仅用于调试
--skip-spacetime-build 跳过 wasm 构建,仅用于调试;此时必须同时传 --no-migration-bootstrap-secret
--migration-bootstrap-secret-file <path>
从受保护文件读取迁移引导密钥,明文不得放进命令参数
--no-migration-bootstrap-secret 构建不带迁移引导密钥的 spacetime-module wasm
目标服务器要求:
@@ -163,13 +165,20 @@ prepare_migration_bootstrap_secret() {
MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)"
;;
manual)
if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then
echo "[deploy:rust] 迁移引导密钥至少需要 16 个字符。" >&2
if [[ ! -f "${MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE}" || -L "${MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE}" || ! -r "${MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE}" ]]; then
echo "[deploy:rust] 迁移引导密钥必须来自可读普通文件,且不能是符号链接: ${MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE}" >&2
exit 1
fi
MIGRATION_BOOTSTRAP_SECRET="$(cat "${MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE}")"
MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET%$'\r'}"
if [[ ! "${MIGRATION_BOOTSTRAP_SECRET}" =~ ^[0-9a-fA-F]{64}$ ]]; then
echo "[deploy:rust] 迁移引导密钥必须是 64 位十六进制高熵值。" >&2
exit 1
fi
;;
disabled)
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256
echo "[deploy:rust] 未启用迁移引导密钥。"
return
;;
@@ -179,8 +188,11 @@ prepare_migration_bootstrap_secret() {
;;
esac
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}"
echo "[deploy:rust] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}"
local digest_line
digest_line="$(printf '%s' "${MIGRATION_BOOTSTRAP_SECRET}" | sha256sum)"
export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_SHA256="${digest_line%% *}"
unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET
echo "[deploy:rust] 已启用迁移引导密钥: mode=${MIGRATION_BOOTSTRAP_SECRET_MODE}, length=${#MIGRATION_BOOTSTRAP_SECRET}"
}
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
@@ -204,6 +216,7 @@ SKIP_API_BUILD=0
SKIP_SPACETIME_BUILD=0
BUILD_COMPLETED=0
MIGRATION_BOOTSTRAP_SECRET=""
MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE=""
MIGRATION_BOOTSTRAP_SECRET_MODE="auto"
while [[ $# -gt 0 ]]; do
@@ -272,8 +285,8 @@ while [[ $# -gt 0 ]]; do
SKIP_SPACETIME_BUILD=1
shift
;;
--migration-bootstrap-secret)
MIGRATION_BOOTSTRAP_SECRET="${2:?缺少 --migration-bootstrap-secret 的值}"
--migration-bootstrap-secret-file)
MIGRATION_BOOTSTRAP_SECRET_SOURCE_FILE="$(normalize_local_path_for_bash "${2:?缺少 --migration-bootstrap-secret-file 的值}")"
MIGRATION_BOOTSTRAP_SECRET_MODE="manual"
shift 2
;;
@@ -313,12 +326,16 @@ WEB_DIR="${TARGET_DIR}/web"
ADMIN_WEB_DIR="${WEB_DIR}/admin"
API_BINARY_SOURCE="${SERVER_RS_DIR}/target/x86_64-unknown-linux-gnu/release/api-server"
WASM_SOURCE="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm"
MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE=""
cleanup_partial_build() {
if [[ "${BUILD_COMPLETED}" -ne 1 && -n "${TARGET_DIR:-}" && -d "${TARGET_DIR}" ]]; then
echo "[deploy:rust] 清理未完成发布包: ${TARGET_DIR}" >&2
rm -rf "${TARGET_DIR}"
fi
if [[ "${BUILD_COMPLETED}" -ne 1 && -n "${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE:-}" ]]; then
rm -f "${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"
fi
}
trap cleanup_partial_build EXIT
@@ -333,6 +350,24 @@ require_command cargo
prepare_migration_bootstrap_secret
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" != "disabled" ]]; then
migration_bootstrap_secret_dir="${SERVER_RS_DIR}/.spacetimedb/build-secrets"
MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE="${migration_bootstrap_secret_dir}/${BUILD_NAME}.txt"
if [[ -e "${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}" ]]; then
echo "[deploy:rust] 迁移引导密钥文件已存在: ${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}" >&2
exit 1
fi
mkdir -p "${migration_bootstrap_secret_dir}"
chmod 700 "${migration_bootstrap_secret_dir}"
(
umask 077
printf "%s\n" "${MIGRATION_BOOTSTRAP_SECRET}" >"${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"
)
chmod 600 "${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"
echo "[deploy:rust] 迁移引导密钥已单独写入受保护文件: ${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"
unset MIGRATION_BOOTSTRAP_SECRET
fi
if [[ "${SKIP_WEB_BUILD}" -ne 1 ]]; then
require_command npm
fi
@@ -402,11 +437,6 @@ fi
copy_required_file "${WASM_SOURCE}" "${TARGET_DIR}/spacetime_module.wasm" "spacetime-module wasm"
if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" != "disabled" ]]; then
printf "%s\n" "${MIGRATION_BOOTSTRAP_SECRET}" >"${TARGET_DIR}/migration-bootstrap-secret.txt"
chmod 600 "${TARGET_DIR}/migration-bootstrap-secret.txt"
fi
mkdir -p "${TARGET_DIR}/scripts"
for migration_script in \
spacetime-migration-common.mjs \
@@ -735,37 +765,38 @@ is_publish_conflict_output() {
|| [[ "${normalized}" == *"clear database"* && "${normalized}" == *"publish"* ]]
}
read_migration_bootstrap_secret() {
resolve_migration_bootstrap_secret_file() {
local secret_file="$1"
local label="$2"
local secret=""
if [[ ! -f "${secret_file}" ]]; then
if [[ ! -f "${secret_file}" || -L "${secret_file}" || ! -r "${secret_file}" ]]; then
echo "[start] schema 冲突自动迁移需要${label}: ${secret_file}" >&2
echo "[start] 请使用默认带迁移引导密钥的发布包,或设置 GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT=false 后人工处理。" >&2
return 1
fi
secret="$(tr -d '\r\n' <"${secret_file}")"
if [[ -z "${secret}" ]]; then
echo "[start] 迁移引导密钥为空${label}: ${secret_file}" >&2
secret="$(cat "${secret_file}")"
secret="${secret%$'\r'}"
if [[ ! "${secret}" =~ ^[0-9a-fA-F]{64}$ ]]; then
echo "[start] 迁移引导密钥必须是 64 位十六进制高熵值${label}: ${secret_file}" >&2
return 1
fi
printf "%s" "${secret}"
printf "%s" "${secret_file}"
}
read_export_migration_bootstrap_secret() {
resolve_export_migration_bootstrap_secret_file() {
if [[ -f "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
read_migration_bootstrap_secret "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE}" "(旧模块导出)"
resolve_migration_bootstrap_secret_file "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE}" "(旧模块导出)"
return
fi
read_migration_bootstrap_secret "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(当前模块导出兜底)"
resolve_migration_bootstrap_secret_file "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(当前模块导出兜底)"
}
read_import_migration_bootstrap_secret() {
read_migration_bootstrap_secret "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(新模块导入)"
resolve_import_migration_bootstrap_secret_file() {
resolve_migration_bootstrap_secret_file "${MIGRATION_BOOTSTRAP_SECRET_FILE}" "(新模块导入)"
}
require_migration_script() {
@@ -790,8 +821,8 @@ run_publish() {
}
run_conflict_migration_publish() {
local export_bootstrap_secret=""
local import_bootstrap_secret=""
local export_bootstrap_secret_file=""
local import_bootstrap_secret_file=""
local export_auth_args=()
local import_auth_args=()
local migration_database_slug=""
@@ -803,16 +834,16 @@ run_conflict_migration_publish() {
echo "[start] 使用 GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN 导出旧库"
export_auth_args=(--token "${SPACETIME_MIGRATION_EXPORT_TOKEN}")
else
export_bootstrap_secret="$(read_export_migration_bootstrap_secret)"
export_auth_args=(--bootstrap-secret "${export_bootstrap_secret}")
export_bootstrap_secret_file="$(resolve_export_migration_bootstrap_secret_file)"
export_auth_args=(--bootstrap-secret-file "${export_bootstrap_secret_file}")
fi
if [[ -n "${SPACETIME_MIGRATION_IMPORT_TOKEN}" ]]; then
echo "[start] 使用 GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN 导入新库"
import_auth_args=(--token "${SPACETIME_MIGRATION_IMPORT_TOKEN}")
else
import_bootstrap_secret="$(read_import_migration_bootstrap_secret)"
import_auth_args=(--bootstrap-secret "${import_bootstrap_secret}")
import_bootstrap_secret_file="$(resolve_import_migration_bootstrap_secret_file)"
import_auth_args=(--bootstrap-secret-file "${import_bootstrap_secret_file}")
fi
require_migration_script "${MIGRATION_EXPORT_SCRIPT}"
require_migration_script "${MIGRATION_IMPORT_SCRIPT}"
@@ -1107,7 +1138,7 @@ fi
echo "[start] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE}"
if [[ -f "${MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
echo "[start] 迁移引导密钥: $(cat "${MIGRATION_BOOTSTRAP_SECRET_FILE}")"
echo "[start] 已启用迁移引导密钥"
else
echo "[start] 未启用迁移引导密钥。"
fi
@@ -1140,7 +1171,14 @@ export GENARRATIVE_API_PORT="${API_PORT}"
export GENARRATIVE_API_LOG="${API_LOG}"
export GENARRATIVE_SPACETIME_SERVER_URL="${SPACETIME_SERVER_URL}"
export GENARRATIVE_SPACETIME_DATABASE="${SPACETIME_DATABASE}"
if [[ -f "${MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
export GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE="${MIGRATION_BOOTSTRAP_SECRET_FILE}"
else
unset GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE
fi
start_process api-server "${SCRIPT_DIR}/api-server"
unset GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE
export GENARRATIVE_WEB_HOST="${WEB_HOST}"
export GENARRATIVE_WEB_PORT="${WEB_PORT}"
@@ -1216,7 +1254,7 @@ cat >"${TARGET_DIR}/README.md" <<'EOF'
- \`web/\`:主前端 Vite release 静态资源,\`web/admin/\` 为后台管理前端静态资源
- \`api-server\`x86_64-unknown-linux-gnu release 可执行文件
- \`spacetime_module.wasm\`wasm32-unknown-unknown release 模块
- \`migration-bootstrap-secret.txt\`本发布包 wasm 编译时注入的迁移引导密钥;服务器 \`start.sh\` 发布时会显示,迁移授权完成后可删除
- \`migration-bootstrap-secret.txt\`不包含在发布包中;上传模式会通过独立 SSH 输入流安装为 \`0600\`\`--skip-upload\` 时必须从构建端受保护的 \`server-rs/.spacetimedb/build-secrets/<build-name>.txt\` 单独交付到发布目录
- \`scripts/spacetime-*.mjs\`:部署时 schema 冲突自动导出、导入回灌使用的 SpacetimeDB 迁移脚本
- \`web-server.mjs\`:静态网站与 API 反代入口
- \`start.sh\` / \`stop.sh\`:目标服务器启动与停止脚本
@@ -1255,7 +1293,7 @@ cat >"${TARGET_DIR}/README.md" <<'EOF'
- \`GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT\`:默认 \`true\`,普通发布遇到 schema 冲突时自动导出、清库发布、导入回灌;设为 \`false\` 时保留原始发布失败。
- \`GENARRATIVE_SPACETIME_MIGRATION_DIR\`:自动迁移 JSON 输出目录,默认 \`database-migrations/<database>/\`。
- OSS、LLM、短信、微信、SpacetimeDB owner token 等业务密钥仍通过目标服务器环境变量或同目录 \`.env.local\` 管理;后台表统计读取 private 表时需要 \`GENARRATIVE_SPACETIME_TOKEN\` 对目标库有 owner 权限。
- 迁移引导密钥由构建发布包时随机生成,构建日志和服务器 \`start.sh\` 发布日志都会显示同一份密钥
- 迁移引导密钥由构建随机生成,原文不进入发布包、构建日志或生成 README;服务器 \`start.sh\` 只读取同目录受保护文件且不会输出明文
EOF
replace_placeholder_in_file "${TARGET_DIR}/README.md" "__GENARRATIVE_BUILD_NAME__" "${BUILD_NAME}"
@@ -1267,6 +1305,17 @@ if [[ "${UPLOAD_ENABLED}" -eq 1 ]]; then
echo "[deploy:rust] 上传发布包: ${TARGET_DIR} -> ${REMOTE_TARGET}:${REMOTE_DIR}/"
scp -r -i "${NORMALIZED_SSH_KEY}" "${TARGET_DIR}" "${REMOTE_TARGET}:${REMOTE_DIR}/"
if [[ -n "${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}" ]]; then
remote_secret_path="${REMOTE_DIR%/}/${BUILD_NAME}/migration-bootstrap-secret.txt"
remote_secret_temp_path="${REMOTE_DIR%/}/${BUILD_NAME}/.migration-bootstrap-secret.$$.tmp"
quoted_remote_secret_path="$(remote_shell_quote "${remote_secret_path}")"
quoted_remote_secret_temp_path="$(remote_shell_quote "${remote_secret_temp_path}")"
echo "[deploy:rust] 通过独立 SSH 输入流安装迁移引导密钥: ${REMOTE_TARGET}:${remote_secret_path}"
ssh -i "${NORMALIZED_SSH_KEY}" "${REMOTE_TARGET}" \
"set -e; umask 077; cat > ${quoted_remote_secret_temp_path}; chmod 600 ${quoted_remote_secret_temp_path}; mv -f ${quoted_remote_secret_temp_path} ${quoted_remote_secret_path}" \
<"${MIGRATION_BOOTSTRAP_SECRET_OUTPUT_FILE}"
fi
fi
echo "[deploy:rust] 完成: ${TARGET_DIR}"
+47
View File
@@ -207,6 +207,32 @@ ensure_env_value_migrates_old_default() {
write_env_value "${file_path}" "${key}" "${new_default}"
}
ensure_runtime_bootstrap_secret_file_env() {
local file_path="$1"
local key="GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE"
local direct_key="GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET"
local canonical_path="/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt"
local current_value direct_value
direct_value="$(read_env_value "${file_path}" "${direct_key}")"
if [[ -n "${direct_value}" ]]; then
unset direct_value
echo "[production-api-deploy] ${file_path} 不得保存 ${direct_key} 明文;生产环境只允许使用 ${key}" >&2
exit 1
fi
unset direct_value
current_value="$(read_env_value "${file_path}" "${key}")"
if [[ -z "${current_value}" ]]; then
ensure_env_value "${file_path}" "${key}" "${canonical_path}"
return
fi
if [[ "${current_value}" != "${canonical_path}" ]]; then
echo "[production-api-deploy] ${key} 必须使用 Stdb publish 同步写入的固定路径 ${canonical_path}: ${file_path}" >&2
exit 1
fi
}
run_privileged() {
if [[ "$(id -u)" -eq 0 ]]; then
"$@"
@@ -235,6 +261,24 @@ ensure_runtime_dir() {
run_privileged install -d -o genarrative -g genarrative -m "${mode}" "${path}"
}
migrate_legacy_editor_generation_pricing_override() {
local current_link="$1"
local target_dir="/var/lib/genarrative/editor-generation-pricing"
local target_file="${target_dir}/editor-generation-pricing.override.json"
local legacy_file="${current_link}/.app/editor-generation-pricing.override.json"
if [[ -f "${target_file}" ]]; then
return
fi
if [[ ! -f "${legacy_file}" ]]; then
return
fi
echo "[production-api-deploy] 迁移旧模型定价 override 到运行态目录: ${legacy_file} -> ${target_file}"
ensure_runtime_dir "${target_dir}" "0750"
run_privileged install -o genarrative -g genarrative -m 0640 "${legacy_file}" "${target_file}"
}
ensure_runtime_env_and_dirs() {
local api_env_file="$1"
local tracking_enabled tracking_outbox_dir wallet_refund_enabled wallet_refund_outbox_dir
@@ -255,6 +299,7 @@ ensure_runtime_env_and_dirs() {
ensure_env_value_migrates_old_default "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600"
ensure_env_value "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900"
ensure_env_value "${api_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800"
ensure_runtime_bootstrap_secret_file_env "${api_env_file}"
ensure_env_value_migrates_old_default "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS" "45000" "180000"
ensure_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD" "3"
ensure_env_value "${api_env_file}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300"
@@ -285,6 +330,7 @@ ensure_worker_runtime_env_defaults() {
ensure_env_value_migrates_old_default "${worker_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600"
ensure_env_value "${worker_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900"
ensure_env_value "${worker_env_file}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800"
ensure_runtime_bootstrap_secret_file_env "${worker_env_file}"
}
extract_pingora_env_files_from_unit() {
@@ -1038,6 +1084,7 @@ fi
ensure_runtime_env_and_dirs "${API_ENV_FILE}"
ensure_worker_runtime_env_defaults "${WORKER_ENV_FILE}"
migrate_legacy_editor_generation_pricing_override "${CURRENT_LINK}"
if [[ "${PINGORA_INCLUDED}" -eq 1 ]]; then
PINGORA_SHADOW_ENV_FILE="$(check_pingora_shadow_service_config "${PINGORA_SERVICE_NAME}")"
@@ -0,0 +1,163 @@
#!/usr/bin/env node
import {
callSpacetimeProcedureViaCli,
ensureProcedureOk,
validateSpacetimeDatabaseName,
} from '../spacetime-migration-common.mjs';
import { spawn } from 'node:child_process';
const PROCEDURE = 'rotate_editor_generation_runtime_service_identity_and_return';
function usage() {
console.error(
'用法: production-runtime-writer-identity-rotate.mjs --database <database> --server-url <http(s)://host:port> --operator-identity <64-hex> --operator-user-id <user-id> --next-writer-identity <64-hex> --confirm-next-writer-identity <64-hex> --note <audit-note>',
);
}
function readValue(argv, index, name) {
const value = argv[index + 1];
if (!value || value.startsWith('--')) {
throw new Error(`${name} 缺少参数值。`);
}
return value;
}
function normalizeIdentity(value, name) {
const identity = value.trim().replace(/^0x/u, '').toLowerCase();
if (!/^[0-9a-f]{64}$/u.test(identity)) {
throw new Error(`${name} 必须是 64 位十六进制 SpacetimeDB identity。`);
}
return identity;
}
async function readCurrentCliIdentity() {
const output = await new Promise((resolve, reject) => {
const child = spawn('spacetime', ['login', 'show'], {
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
});
let text = '';
child.stdout.on('data', (chunk) => {
text += chunk.toString();
});
child.stderr.on('data', (chunk) => {
text += chunk.toString();
});
child.on('error', reject);
child.on('exit', (code, signal) => {
if (signal) {
reject(new Error(`spacetime login show 被信号中断: ${signal}`));
} else if (code !== 0) {
reject(new Error(`无法读取当前 SpacetimeDB CLI identity,退出码 ${code}`));
} else {
resolve(text);
}
});
});
const identities = [...output.matchAll(/\b[0-9a-f]{64}\b/giu)].map((match) =>
match[0].toLowerCase(),
);
if (identities.length !== 1) {
throw new Error('spacetime login show 未返回唯一的当前 CLI identity。');
}
return identities[0];
}
function parseOptions(argv) {
const options = {
database: '',
serverUrl: '',
operatorIdentity: '',
operatorUserId: '',
nextWriterIdentity: '',
confirmNextWriterIdentity: '',
note: '',
passthrough: [],
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === '--help' || arg === '-h') {
usage();
process.exit(0);
}
if (arg === '--database') {
options.database = readValue(argv, index, arg);
} else if (arg === '--server-url') {
options.serverUrl = readValue(argv, index, arg);
} else if (arg === '--operator-identity') {
options.operatorIdentity = readValue(argv, index, arg);
} else if (arg === '--operator-user-id') {
options.operatorUserId = readValue(argv, index, arg);
} else if (arg === '--next-writer-identity') {
options.nextWriterIdentity = readValue(argv, index, arg);
} else if (arg === '--confirm-next-writer-identity') {
options.confirmNextWriterIdentity = readValue(argv, index, arg);
} else if (arg === '--note') {
options.note = readValue(argv, index, arg);
} else {
throw new Error(`未知参数: ${arg}`);
}
index += 1;
}
return options;
}
try {
const options = parseOptions(process.argv.slice(2));
validateSpacetimeDatabaseName(options.database);
if (!/^https?:\/\/[A-Za-z0-9.-]+(?::[0-9]{1,5})?$/u.test(options.serverUrl)) {
throw new Error('--server-url 必须是无路径、无查询参数的显式 http(s) URL。');
}
const operatorIdentity = normalizeIdentity(options.operatorIdentity, '--operator-identity');
const nextWriterIdentity = normalizeIdentity(
options.nextWriterIdentity,
'--next-writer-identity',
);
const confirmedIdentity = normalizeIdentity(
options.confirmNextWriterIdentity,
'--confirm-next-writer-identity',
);
if (nextWriterIdentity !== confirmedIdentity) {
throw new Error('--confirm-next-writer-identity 必须与 --next-writer-identity 完全一致。');
}
if (nextWriterIdentity === operatorIdentity) {
throw new Error('runtime writer identity 不能设为 migration operator identity。');
}
const currentCliIdentity = await readCurrentCliIdentity();
if (operatorIdentity !== currentCliIdentity) {
throw new Error('--operator-identity 必须与当前 SpacetimeDB CLI 登录 identity 一致。');
}
const operatorUserId = options.operatorUserId.trim();
if (!/^[A-Za-z0-9][A-Za-z0-9._:@-]{0,127}$/u.test(operatorUserId)) {
throw new Error('--operator-user-id 格式不合法。');
}
const note = options.note.trim();
if (!note || note.length > 500) {
throw new Error('--note 必填且不能超过 500 个字符。');
}
const result = await callSpacetimeProcedureViaCli(
{ database: options.database, serverUrl: options.serverUrl, passthrough: [] },
PROCEDURE,
{
next_writer_identity_hex: nextWriterIdentity,
operator_user_id: operatorUserId,
note,
},
);
ensureProcedureOk(result);
console.log(
`[runtime-writer-identity-rotation] 已完成 identity rotation: database=${options.database}, operator_identity=${operatorIdentity}, next_writer_identity=${nextWriterIdentity}; 审计记录已写入 editor_generation_runtime_identity_rotation。`,
);
} catch (error) {
console.error(
`[runtime-writer-identity-rotation] 操作失败;必须使用已授权的 migration operator CLI 登录态。${error instanceof Error ? ` ${error.message}` : String(error)}`,
);
process.exit(1);
}
+346 -3
View File
@@ -1,11 +1,12 @@
#!/usr/bin/env bash
set -euo pipefail
umask 077
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] [--backup-mode async|sync|skip]
./scripts/deploy/production-stdb-publish.sh --source-dir build/<version> --database <database> --migration-bootstrap-secret-file <protected-file> [--server-url http://127.0.0.1:3101] [--server local] [--root-dir /stdb] [--run-as-user spacetimedb] [--api-env-file /etc/genarrative/api-server.env] [--worker-env-file /etc/genarrative/external-generation-worker.env] [--api-health-url http://127.0.0.1:8082/healthz] [--api-readiness-timeout-seconds 60] [--clear-database] [--backup-mode async|sync|skip]
说明:
进入维护模式,校验 spacetime_module.wasm.sha256,并在生产实例本机执行 spacetime publish。
@@ -14,6 +15,9 @@ usage() {
发布时固定追加 --no-config,只使用显式参数,避免工作区或用户目录里的 spacetime 配置干扰目标。
async 模式会在 publish 前先做本地冷备份,再在 publish 完成后后台上传 OSS,避免低带宽上传阻塞部署。
如需强制等待备份完成并在失败时阻断 publish,传入 --backup-mode sync。
发布成功后会补齐生产 API/worker env 的固定 bootstrap secret FILE 路径,再重启并验活重启前 active 的服务。
migration bootstrap secret 必须由 Jenkins Secret File credential 或等价的受保护文件提供,不从构建 artifact 读取。
如果 API 重启前为 active,会在退出维护模式前等待本机 /healthz readiness 通过。
失败时保留维护模式。
EOF
}
@@ -44,6 +48,9 @@ SERVER_ALIAS="local"
SERVER_URL="http://127.0.0.1:3101"
SPACETIME_ROOT_DIR="/stdb"
RUN_AS_USER="spacetimedb"
MIGRATION_BOOTSTRAP_SECRET_FILE=""
API_ENV_FILE="${GENARRATIVE_STDB_PUBLISH_API_ENV_FILE:-/etc/genarrative/api-server.env}"
WORKER_ENV_FILE="${GENARRATIVE_STDB_PUBLISH_WORKER_ENV_FILE:-/etc/genarrative/external-generation-worker.env}"
CLEAR_DATABASE=0
BACKUP_MODE="${GENARRATIVE_STDB_PUBLISH_BACKUP_MODE:-async}"
DEPLOY_COMPLETED=0
@@ -54,6 +61,229 @@ ASYNC_BACKUP_ARCHIVE=""
ASYNC_BACKUP_MANIFEST=""
ASYNC_BACKUP_LOG=""
SPACETIME_READY_TIMEOUT_SECONDS="${GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS:-60}"
API_HEALTH_URL="${GENARRATIVE_STDB_PUBLISH_API_HEALTH_URL:-http://127.0.0.1:8082/healthz}"
API_READINESS_TIMEOUT_SECONDS="${GENARRATIVE_STDB_PUBLISH_API_READINESS_TIMEOUT_SECONDS:-60}"
API_WAS_ACTIVE=0
RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE="${GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE:-/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt}"
RUNTIME_SERVICE_BOOTSTRAP_SECRET_CANONICAL_FILE="/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt"
run_privileged() {
if [[ "$(id -u)" -eq 0 ]]; then
"$@"
elif command -v sudo >/dev/null 2>&1; then
sudo -n "$@"
else
echo "[production-stdb-publish] 当前用户不是 root,且 sudo 不可用;无法完成受保护运行态文件或服务操作。" >&2
return 1
fi
}
ensure_runtime_bootstrap_secret_env_file() {
local env_file="$1"
local required="$2"
if [[ ! -e "${env_file}" ]]; then
if [[ "${required}" == "true" ]]; then
echo "[production-stdb-publish] 运行时环境文件不存在,无法补齐 bootstrap secret FILE: ${env_file}" >&2
exit 1
fi
echo "[production-stdb-publish] worker 环境文件不存在,跳过 bootstrap secret FILE 补齐: ${env_file}"
return
fi
if [[ ! -f "${env_file}" || -L "${env_file}" ]]; then
echo "[production-stdb-publish] 运行时环境文件必须是普通文件且不能是符号链接: ${env_file}" >&2
exit 1
fi
run_privileged python3 - "${env_file}" "${RUNTIME_SERVICE_BOOTSTRAP_SECRET_CANONICAL_FILE}" <<'PY'
import os
import stat
import sys
import tempfile
from pathlib import Path
env_path = Path(sys.argv[1])
canonical_path = sys.argv[2]
file_key = "GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE"
direct_key = "GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET"
def normalize_value(value: str) -> str:
value = value.strip()
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
value = value[1:-1]
return value.strip()
lines = env_path.read_text(encoding="utf-8").splitlines()
next_lines: list[str] = []
file_key_written = False
changed = False
for raw_line in lines:
stripped = raw_line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
next_lines.append(raw_line)
continue
key, value = stripped.split("=", 1)
if key == direct_key and normalize_value(value):
print(
f"[production-stdb-publish] {env_path} 不得保存 {direct_key} 明文;生产环境只允许使用 {file_key}。",
file=sys.stderr,
)
raise SystemExit(1)
if key != file_key:
next_lines.append(raw_line)
continue
current_value = normalize_value(value)
if current_value and current_value != canonical_path:
print(
f"[production-stdb-publish] {file_key} 必须使用固定路径 {canonical_path}: {env_path}",
file=sys.stderr,
)
raise SystemExit(1)
if file_key_written:
changed = True
continue
next_lines.append(f"{file_key}={canonical_path}")
file_key_written = True
changed = changed or raw_line != next_lines[-1]
if not file_key_written:
next_lines.append(f"{file_key}={canonical_path}")
changed = True
if changed:
metadata = env_path.stat()
fd, temp_name = tempfile.mkstemp(prefix=f".{env_path.name}.", dir=env_path.parent)
try:
os.fchmod(fd, stat.S_IMODE(metadata.st_mode))
os.fchown(fd, metadata.st_uid, metadata.st_gid)
with os.fdopen(fd, "w", encoding="utf-8") as temp_file:
temp_file.write("\n".join(next_lines) + "\n")
os.replace(temp_name, env_path)
finally:
if os.path.exists(temp_name):
os.unlink(temp_name)
PY
echo "[production-stdb-publish] 已确认 bootstrap secret FILE 配置: ${env_file}"
}
get_runtime_service_active_state() {
local service_name="$1"
local state=""
local exit_code=0
if state="$(run_privileged systemctl is-active "${service_name}")"; then
if [[ "${state}" != "active" ]]; then
echo "[production-stdb-publish] systemctl is-active 返回成功但状态异常: ${service_name}, state=${state}" >&2
return 1
fi
printf '%s\n' "${state}"
return 0
else
exit_code=$?
fi
if [[ "${exit_code}" -eq 3 && "${state}" =~ ^(inactive|failed|activating|deactivating|reloading|maintenance|refreshing)$ ]]; then
printf '%s\n' "${state}"
return 0
fi
echo "[production-stdb-publish] 查询运行时服务状态失败: ${service_name}, exit=${exit_code}, state=${state:-<empty>}" >&2
return 1
}
restart_runtime_service_and_require_active() {
local service_name="$1"
local state=""
echo "[production-stdb-publish] 重启运行时服务以加载新引导密钥: ${service_name}"
if ! run_privileged systemctl restart "${service_name}"; then
echo "[production-stdb-publish] 运行时服务重启失败: ${service_name}" >&2
return 1
fi
if ! state="$(get_runtime_service_active_state "${service_name}")"; then
return 1
fi
if [[ "${state}" != "active" ]]; then
echo "[production-stdb-publish] 运行时服务重启后未恢复 active: ${service_name}, state=${state}" >&2
return 1
fi
echo "[production-stdb-publish] 运行时服务重启后已恢复 active: ${service_name}"
}
restart_runtime_services_after_bootstrap_secret_install() {
local api_state=""
local controller_state=""
local worker_service=""
local worker_state=""
local worker_units_output=""
local list_units_exit_code=0
local -a active_worker_services=()
if ! api_state="$(get_runtime_service_active_state genarrative-api.service)"; then
return 1
fi
if ! controller_state="$(get_runtime_service_active_state genarrative-external-generation-controller.service)"; then
return 1
fi
if worker_units_output="$(
run_privileged systemctl list-units \
--type=service \
--state=active \
--no-legend \
--plain \
'genarrative-external-generation-worker@*.service'
)"; then
:
else
list_units_exit_code=$?
echo "[production-stdb-publish] 查询 active worker 服务失败,阻断退出维护模式: exit=${list_units_exit_code}" >&2
return 1
fi
while read -r worker_service _; do
if [[ "${worker_service}" =~ ^genarrative-external-generation-worker@[A-Za-z0-9_.@:-]+\.service$ ]]; then
active_worker_services+=("${worker_service}")
fi
done <<<"${worker_units_output}"
if [[ "${api_state}" == "active" ]]; then
API_WAS_ACTIVE=1
if ! restart_runtime_service_and_require_active genarrative-api.service; then
return 1
fi
fi
if [[ "${controller_state}" == "active" ]]; then
if ! restart_runtime_service_and_require_active genarrative-external-generation-controller.service; then
return 1
fi
fi
if [[ "${#active_worker_services[@]}" -gt 0 ]]; then
echo "[production-stdb-publish] 重启运行时 worker 以加载新引导密钥: ${active_worker_services[*]}"
if ! run_privileged systemctl restart "${active_worker_services[@]}"; then
echo "[production-stdb-publish] 运行时 worker 重启失败: ${active_worker_services[*]}" >&2
return 1
fi
for worker_service in "${active_worker_services[@]}"; do
if ! worker_state="$(get_runtime_service_active_state "${worker_service}")"; then
return 1
fi
if [[ "${worker_state}" != "active" ]]; then
echo "[production-stdb-publish] 运行时 worker 重启后未恢复 active: ${worker_service}, state=${worker_state}" >&2
return 1
fi
echo "[production-stdb-publish] 运行时 worker 重启后已恢复 active: ${worker_service}"
done
fi
}
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -86,6 +316,26 @@ while [[ $# -gt 0 ]]; do
RUN_AS_USER="${2:?缺少 --run-as-user 的值}"
shift 2
;;
--migration-bootstrap-secret-file)
MIGRATION_BOOTSTRAP_SECRET_FILE="${2:?缺少 --migration-bootstrap-secret-file 的值}"
shift 2
;;
--api-env-file)
API_ENV_FILE="${2:?缺少 --api-env-file 的值}"
shift 2
;;
--worker-env-file)
WORKER_ENV_FILE="${2:?缺少 --worker-env-file 的值}"
shift 2
;;
--api-health-url)
API_HEALTH_URL="${2:?缺少 --api-health-url 的值}"
shift 2
;;
--api-readiness-timeout-seconds)
API_READINESS_TIMEOUT_SECONDS="${2:?缺少 --api-readiness-timeout-seconds 的值}"
shift 2
;;
--clear-database)
CLEAR_DATABASE=1
shift
@@ -119,6 +369,18 @@ if [[ ! "${SPACETIME_ROOT_DIR}" == /* || "${SPACETIME_ROOT_DIR}" == *".."* ]]; t
exit 1
fi
if [[ "${RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE}" != "${RUNTIME_SERVICE_BOOTSTRAP_SECRET_CANONICAL_FILE}" ]]; then
echo "[production-stdb-publish] GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE 必须使用固定路径 ${RUNTIME_SERVICE_BOOTSTRAP_SECRET_CANONICAL_FILE}: ${RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE}" >&2
exit 1
fi
for runtime_env_file in "${API_ENV_FILE}" "${WORKER_ENV_FILE}"; do
if [[ "${runtime_env_file}" != /* || "${runtime_env_file}" == *".."* || "${runtime_env_file}" == "/" ]]; then
echo "[production-stdb-publish] 运行时环境文件必须是非根绝对路径且不能包含 ..: ${runtime_env_file}" >&2
exit 1
fi
done
if [[ ! "${BACKUP_MODE}" =~ ^(async|sync|skip)$ ]]; then
echo "[production-stdb-publish] --backup-mode 只能是 async、sync 或 skip: ${BACKUP_MODE}" >&2
exit 1
@@ -134,6 +396,16 @@ if [[ ! "${SPACETIME_READY_TIMEOUT_SECONDS}" =~ ^[0-9]+$ || "${SPACETIME_READY_T
exit 1
fi
if [[ ! "${API_READINESS_TIMEOUT_SECONDS}" =~ ^[0-9]+$ || "${API_READINESS_TIMEOUT_SECONDS}" -le 0 ]]; then
echo "[production-stdb-publish] API readiness timeout 必须是正整数: ${API_READINESS_TIMEOUT_SECONDS}" >&2
exit 1
fi
if [[ ! "${API_HEALTH_URL}" =~ ^http://(127\.0\.0\.1|localhost)(:[0-9]{1,5})?/healthz$ ]]; then
echo "[production-stdb-publish] API readiness 必须使用本机 HTTP /healthz: ${API_HEALTH_URL}" >&2
exit 1
fi
if [[ ! -d "${SOURCE_DIR}" ]]; then
echo "[production-stdb-publish] 发布目录不存在: ${SOURCE_DIR}" >&2
exit 1
@@ -141,11 +413,31 @@ fi
SOURCE_DIR="$(cd "${SOURCE_DIR}" && pwd)"
if [[ ! -f "${SOURCE_DIR}/spacetime_module.wasm" || ! -f "${SOURCE_DIR}/spacetime_module.wasm.sha256" ]]; then
echo "[production-stdb-publish] 缺少 spacetime_module.wasm 或 spacetime_module.wasm.sha256: ${SOURCE_DIR}" >&2
if [[ ! -f "${SOURCE_DIR}/spacetime_module.wasm" || ! -f "${SOURCE_DIR}/spacetime_module.wasm.sha256" || ! -f "${SOURCE_DIR}/release-manifest.json" ]]; then
echo "[production-stdb-publish] 缺少 spacetime_module.wasm、checksum 或 release-manifest.json: ${SOURCE_DIR}" >&2
exit 1
fi
if [[ -z "${MIGRATION_BOOTSTRAP_SECRET_FILE}" || ! -f "${MIGRATION_BOOTSTRAP_SECRET_FILE}" || -L "${MIGRATION_BOOTSTRAP_SECRET_FILE}" || ! -r "${MIGRATION_BOOTSTRAP_SECRET_FILE}" ]]; then
echo "[production-stdb-publish] --migration-bootstrap-secret-file 必须是可读、非符号链接的受保护普通文件。" >&2
exit 1
fi
MIGRATION_BOOTSTRAP_SECRET="$(cat "${MIGRATION_BOOTSTRAP_SECRET_FILE}")"
MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET%$'\r'}"
if [[ ! "${MIGRATION_BOOTSTRAP_SECRET}" =~ ^[0-9a-fA-F]{64}$ ]]; then
echo "[production-stdb-publish] 运行时服务身份引导密钥必须是 64 位十六进制高熵值。" >&2
exit 1
fi
MIGRATION_BOOTSTRAP_SECRET_DIGEST_LINE="$(printf '%s' "${MIGRATION_BOOTSTRAP_SECRET}" | sha256sum)"
MIGRATION_BOOTSTRAP_SECRET_SHA256="${MIGRATION_BOOTSTRAP_SECRET_DIGEST_LINE%% *}"
EXPECTED_MIGRATION_BOOTSTRAP_SECRET_SHA256="$(node -e 'const fs=require("node:fs"); const manifest=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); process.stdout.write(manifest.migration_bootstrap_secret_sha256 || "");' "${SOURCE_DIR}/release-manifest.json")"
if [[ ! "${EXPECTED_MIGRATION_BOOTSTRAP_SECRET_SHA256}" =~ ^[0-9a-f]{64}$ || "${MIGRATION_BOOTSTRAP_SECRET_SHA256}" != "${EXPECTED_MIGRATION_BOOTSTRAP_SECRET_SHA256}" ]]; then
echo "[production-stdb-publish] Secret File 与构建 WASM 的 bootstrap secret 摘要不一致。" >&2
exit 1
fi
unset MIGRATION_BOOTSTRAP_SECRET MIGRATION_BOOTSTRAP_SECRET_DIGEST_LINE
on_exit() {
local exit_code=$?
if [[ "${BACKUP_MODE}" == "async" && -n "${ASYNC_BACKUP_STATUS_FILE}" && -f "${ASYNC_BACKUP_STATUS_FILE}" ]]; then
@@ -237,6 +529,31 @@ wait_for_spacetime_ready() {
return 1
}
wait_for_api_healthz_ready() {
if [[ "${API_WAS_ACTIVE}" -ne 1 ]]; then
echo "[production-stdb-publish] API 重启前不是 active,跳过 /healthz readiness"
return 0
fi
local deadline=$((SECONDS + API_READINESS_TIMEOUT_SECONDS))
local last_status=""
echo "[production-stdb-publish] 等待 API /healthz readiness: ${API_HEALTH_URL}timeout=${API_READINESS_TIMEOUT_SECONDS}s"
while (( SECONDS < deadline )); do
if last_status="$(curl -fsS --max-time 2 "${API_HEALTH_URL}" 2>&1)"; then
echo "[production-stdb-publish] API /healthz readiness 已通过: ${API_HEALTH_URL}"
return 0
fi
sleep 2
done
echo "[production-stdb-publish] API /healthz readiness 未在超时内通过: ${API_HEALTH_URL}" >&2
if [[ -n "${last_status}" ]]; then
echo "[production-stdb-publish] 最后一次 /healthz 检查输出: ${last_status}" >&2
fi
return 1
}
"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}"
case "${BACKUP_MODE}" in
@@ -328,6 +645,32 @@ else
spacetime "${PUBLISH_ARGS[@]}"
fi
RUNTIME_SERVICE_BOOTSTRAP_SECRET_DIR="$(dirname "${RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE}")"
if [[ -L "${RUNTIME_SERVICE_BOOTSTRAP_SECRET_DIR}" || -L "${RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE}" ]]; then
echo "[production-stdb-publish] 运行时服务身份引导密钥路径不能是符号链接: ${RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE}" >&2
exit 1
fi
if ! id genarrative >/dev/null 2>&1; then
echo "[production-stdb-publish] 缺少运行时服务用户 genarrative,无法安全安装引导密钥。" >&2
exit 1
fi
run_privileged install -d -o root -g genarrative -m 0750 "${RUNTIME_SERVICE_BOOTSTRAP_SECRET_DIR}"
run_privileged install -o root -g genarrative -m 0440 \
"${MIGRATION_BOOTSTRAP_SECRET_FILE}" \
"${RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE}"
RUNTIME_SERVICE_BOOTSTRAP_SECRET_METADATA="$(run_privileged stat -c '%U:%G:%a' "${RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE}")"
if [[ "${RUNTIME_SERVICE_BOOTSTRAP_SECRET_METADATA}" != "root:genarrative:440" ]]; then
echo "[production-stdb-publish] 运行时服务身份引导密钥权限不符合 root:genarrative:0440: ${RUNTIME_SERVICE_BOOTSTRAP_SECRET_METADATA}" >&2
exit 1
fi
run_privileged runuser -u genarrative -- test -r "${RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE}"
echo "[production-stdb-publish] 已安装运行时服务身份引导密钥: ${RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE}"
ensure_runtime_bootstrap_secret_env_file "${API_ENV_FILE}" true
ensure_runtime_bootstrap_secret_env_file "${WORKER_ENV_FILE}" false
restart_runtime_services_after_bootstrap_secret_install
wait_for_api_healthz_ready
"${SCRIPT_DIR}/maintenance-off.sh"
DEPLOY_COMPLETED=1
+379 -30
View File
File diff suppressed because it is too large Load Diff
+494 -28
View File
File diff suppressed because it is too large Load Diff
+38 -1
View File
@@ -455,6 +455,32 @@ ensure_env_value_migrates_old_default() {
fi
}
ensure_runtime_bootstrap_secret_file_env() {
local file="$1"
local key="GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE"
local direct_key="GENARRATIVE_SPACETIME_RUNTIME_SERVICE_BOOTSTRAP_SECRET"
local canonical_path="/var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt"
local current_value direct_value
direct_value="$(read_env_value "${file}" "${direct_key}")"
if [[ -n "${direct_value}" ]]; then
unset direct_value
echo "[server-provision] ${file} 不得保存 ${direct_key} 明文;生产环境只允许使用 ${key}" >&2
exit 1
fi
unset direct_value
current_value="$(read_env_value "${file}" "${key}")"
if [[ -z "${current_value}" ]]; then
ensure_env_value "${file}" "${key}" "${canonical_path}"
return
fi
if [[ "${current_value}" != "${canonical_path}" ]]; then
echo "[server-provision] ${key} 必须使用 Stdb publish 同步写入的固定路径 ${canonical_path}: ${file}" >&2
exit 1
fi
}
ensure_api_runtime_env_defaults() {
if [[ "${DRY_RUN}" == "true" ]]; then
echo "+ ensure api-server runtime env defaults in ${API_ENV_FILE}"
@@ -479,6 +505,7 @@ ensure_api_runtime_env_defaults() {
ensure_env_value_migrates_old_default "${API_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800"
ensure_runtime_bootstrap_secret_file_env "${API_ENV_FILE}"
ensure_env_value_migrates_old_default "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS" "45000" "180000"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD" "3"
ensure_env_value "${API_ENV_FILE}" "GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS" "300"
@@ -497,6 +524,7 @@ ensure_worker_runtime_env_defaults() {
ensure_env_value_migrates_old_default "${WORKER_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS" "3600" "600"
ensure_env_value "${WORKER_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_JOB_TIMEOUT_SECONDS" "900"
ensure_env_value "${WORKER_ENV_FILE}" "GENARRATIVE_EXTERNAL_GENERATION_WORKER_LONG_JOB_TIMEOUT_SECONDS" "1800"
ensure_runtime_bootstrap_secret_file_env "${WORKER_ENV_FILE}"
}
parse_json_string_field() {
@@ -930,7 +958,7 @@ echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_conf
run_cmd id
require_root_for_real_provision
install_nginx_brotli_modules
run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /etc/genarrative/pingora /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox /var/lib/genarrative/wallet-refund-outbox /var/lib/genarrative/database-backups /var/lib/genarrative/health-patrol /var/log/genarrative
run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /etc/genarrative/pingora /var/lib/genarrative/maintenance /var/lib/genarrative/auth /var/lib/genarrative/tracking-outbox /var/lib/genarrative/wallet-refund-outbox /var/lib/genarrative/editor-generation-pricing /var/lib/genarrative/spacetime /var/lib/genarrative/database-backups /var/lib/genarrative/health-patrol /var/log/genarrative
if ! id spacetimedb >/dev/null 2>&1; then
run_cmd useradd --system --home-dir "${SPACETIME_ROOT}" --shell /usr/sbin/nologin spacetimedb
@@ -947,6 +975,15 @@ fi
run_cmd chown -R spacetimedb:spacetimedb "${SPACETIME_ROOT}"
run_cmd chown -R genarrative:genarrative /opt/genarrative /var/lib/genarrative /srv/genarrative
run_cmd chown -R genarrative:genarrative /var/log/genarrative
if [[ -L /var/lib/genarrative/spacetime || -L /var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt ]]; then
echo "[server-provision] 运行时服务身份引导密钥路径不能是符号链接。" >&2
exit 1
fi
run_cmd install -d -o root -g genarrative -m 0750 /var/lib/genarrative/spacetime
if [[ -f /var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt ]]; then
run_cmd chown root:genarrative /var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt
run_cmd chmod 0440 /var/lib/genarrative/spacetime/runtime-service-bootstrap-secret.txt
fi
install_genarrative_openssl_runtime
if [[ ! -x "${SPACETIME_BIN_SOURCE}" ]]; then
@@ -5,6 +5,7 @@ import {
callSpacetimeProcedureViaCli,
ensureProcedureOk,
parseArgs,
resolveBootstrapSecret,
} from './spacetime-migration-common.mjs';
try {
@@ -14,7 +15,7 @@ try {
}
const input = {
bootstrap_secret: options.bootstrapSecret || '',
bootstrap_secret: await resolveBootstrapSecret(options),
operator_identity_hex: options.operatorIdentity,
note: options.note || '',
};
+2 -1
View File
@@ -9,6 +9,7 @@ import {
ensureParentDir,
ensureProcedureOk,
parseArgs,
resolveBootstrapSecret,
} from './spacetime-migration-common.mjs';
try {
@@ -62,7 +63,7 @@ async function prepareWebExportOptions(options) {
options,
'authorize_database_migration_operator',
{
bootstrap_secret: options.bootstrapSecret || '',
bootstrap_secret: await resolveBootstrapSecret(options),
operator_identity_hex: identity.identity,
note: options.note || 'temporary web api migration export',
},
+2 -1
View File
@@ -10,6 +10,7 @@ import {
createSpacetimeWebIdentity,
ensureProcedureOk,
parseArgs,
resolveBootstrapSecret,
} from './spacetime-migration-common.mjs';
const DEFAULT_MIGRATION_IMPORT_CHUNK_SIZE = 512 * 1024;
@@ -66,7 +67,7 @@ async function prepareWebImportOptions(options) {
options,
'authorize_database_migration_operator',
{
bootstrap_secret: options.bootstrapSecret || '',
bootstrap_secret: await resolveBootstrapSecret(options),
operator_identity_hex: identity.identity,
note: options.note || 'temporary web api migration import',
},
+25 -3
View File
@@ -1,5 +1,5 @@
import { spawn } from 'node:child_process';
import { access, mkdir } from 'node:fs/promises';
import { access, lstat, mkdir, readFile } from 'node:fs/promises';
import path from 'node:path';
export function parseArgs(argv) {
@@ -10,6 +10,8 @@ export function parseArgs(argv) {
),
database: process.env.GENARRATIVE_SPACETIME_DATABASE || '',
bootstrapSecret: process.env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET || '',
bootstrapSecretFile:
process.env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_FILE || '',
includeTables: [],
operatorIdentity: process.env.GENARRATIVE_SPACETIME_MIGRATION_OPERATOR_IDENTITY || '',
passthrough: [],
@@ -38,8 +40,8 @@ export function parseArgs(argv) {
options.serverUrl = readValue(arg);
} else if (arg === '--token') {
options.token = readValue(arg);
} else if (arg === '--bootstrap-secret') {
options.bootstrapSecret = readValue(arg);
} else if (arg === '--bootstrap-secret-file') {
options.bootstrapSecretFile = readValue(arg);
} else if (arg === '--chunk-size') {
options.chunkSize = parsePositiveInteger(readValue(arg), arg);
} else if (arg === '--operator-identity') {
@@ -75,6 +77,26 @@ export function parseArgs(argv) {
return options;
}
export async function resolveBootstrapSecret(options) {
if (options.bootstrapSecret && options.bootstrapSecretFile) {
throw new Error('bootstrap secret 明文与文件参数不能同时使用。');
}
if (!options.bootstrapSecretFile) {
return options.bootstrapSecret || '';
}
const secretPath = path.resolve(options.bootstrapSecretFile);
const metadata = await lstat(secretPath);
if (!metadata.isFile() || metadata.isSymbolicLink()) {
throw new Error('--bootstrap-secret-file 必须是普通文件且不能是符号链接。');
}
const secret = (await readFile(secretPath, 'utf8')).replace(/\r?\n$/u, '');
if (!/^[0-9a-f]{64}$/iu.test(secret)) {
throw new Error('bootstrap secret 必须是 64 位十六进制高熵值。');
}
return secret;
}
export function parsePositiveInteger(value, name) {
if (!/^[1-9][0-9]*$/u.test(String(value).trim())) {
throw new Error(`${name} 必须是正整数。`);