修复编辑器定价持久化与资源预览
将模型定价改为 SpacetimeDB 强类型持久化并兼容旧配置种子迁移 修复快速编辑支付弹窗期间框选显示和交互冻结 补齐后台图片放大以及视频音频资源预览 收紧主站和 External 资源换签授权并记录管理员跨用户审计 固化外部生成入队价格、attempt 钱包结算和最终 lease 失败收口 加固运行时身份轮换、bootstrap secret 与生产构建发布门禁 同步生成绑定、定向测试、运维脚本和项目文档
This commit is contained in:
@@ -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 安装脚本。
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+494
-28
File diff suppressed because it is too large
Load Diff
@@ -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 || '',
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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} 必须是正整数。`);
|
||||
|
||||
Reference in New Issue
Block a user