Files
Genarrative/scripts/deploy/production-stdb-publish.sh
T
kdletters 324f99efdf 修复外部生成任务大载荷导致的内存膨胀
阻止图片编辑器将 Data URL 和超限 JSON 写入生成任务,复用 objectKey 等轻量引用
新增外部生成任务摘要投影与分批 payload 压缩回填流程,避免正式列表读取大字段
收紧 SpacetimeDB 发布为永不删数据并移除 Jenkins 普通清库入口,补齐维护窗口门禁
更新生成绑定、运维文档和回归测试,并完成生产数据副本迁移与压缩验证
2026-07-11 13:55:05 +08:00

740 lines
28 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -euo pipefail
umask 077
usage() {
cat <<'EOF'
用法:
./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] [--keep-maintenance-mode] [--backup-mode async|sync|skip]
说明:
进入维护模式,校验 spacetime_module.wasm.sha256,并在生产实例本机执行 spacetime publish。
publish 固定使用 --delete-data=never 与 scoped --yes=migrate,break-clients;任何需要删除数据的 schema 冲突都会阻断发布。
默认使用 http://127.0.0.1:3101,避免与部署机本机 Git/Web 服务的 3000 端口冲突。
默认使用 /stdb 作为 spacetime CLI root-dir,并以 spacetimedb 用户发布,避免 root CLI 身份污染自托管实例。
发布时固定追加 --no-config,只使用显式参数,避免工作区或用户目录里的 spacetime 配置干扰目标。
async 模式会在 publish 前先做本地冷备份,再在 publish 完成后后台上传 OSS,避免低带宽上传阻塞部署。
如需强制等待备份完成并在失败时阻断 publish,传入 --backup-mode sync。
发布成功后会补齐生产 API/worker env 的固定 bootstrap secret FILE 路径,再重启并验活重启前 active 的服务。
--keep-maintenance-mode 会在 publish 前停止旧 API/controller/worker,并在成功后保持维护态,交由后续 API deploy 恢复服务。
migration bootstrap secret 必须由 Jenkins Secret File credential 或等价的受保护文件提供,不从构建 artifact 读取。
如果 API 重启前为 active,会在退出维护模式前等待本机 /healthz readiness 通过。
失败时保留维护模式。
EOF
}
require_argument() {
local value="$1"
local label="$2"
if [[ -z "${value}" ]]; then
echo "[production-stdb-publish] 缺少参数: ${label}" >&2
exit 1
fi
}
validate_spacetime_database_name() {
local database="$1"
if [[ ! "${database}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
echo "[production-stdb-publish] --database 必须匹配 ^[a-z0-9]+(-[a-z0-9]+)*$: ${database}" >&2
exit 1
fi
}
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
SOURCE_DIR=""
DATABASE=""
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}"
KEEP_MAINTENANCE_MODE=0
BACKUP_MODE="${GENARRATIVE_STDB_PUBLISH_BACKUP_MODE:-async}"
DEPLOY_COMPLETED=0
PUBLISH_TMP_DIR=""
ASYNC_BACKUP_STATUS_FILE=""
ASYNC_BACKUP_SCRIPT=""
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
}
stop_runtime_services_for_rollout_gate() {
local api_state=""
local controller_state=""
local worker_service=""
local worker_units_output=""
local -a services_to_stop=()
api_state="$(get_runtime_service_active_state genarrative-api.service)"
controller_state="$(get_runtime_service_active_state genarrative-external-generation-controller.service)"
if ! worker_units_output="$(
run_privileged systemctl list-units \
--type=service \
--state=active \
--no-legend \
--plain \
'genarrative-external-generation-worker@*.service'
)"; then
echo "[production-stdb-publish] 查询 active worker 服务失败,无法建立受控维护窗口。" >&2
return 1
fi
if [[ "${controller_state}" == "active" ]]; then
services_to_stop+=(genarrative-external-generation-controller.service)
fi
while read -r worker_service _; do
if [[ "${worker_service}" =~ ^genarrative-external-generation-worker@[A-Za-z0-9_.@:-]+\.service$ ]]; then
services_to_stop+=("${worker_service}")
fi
done <<<"${worker_units_output}"
if [[ "${api_state}" == "active" ]]; then
services_to_stop+=(genarrative-api.service)
fi
if [[ "${#services_to_stop[@]}" -eq 0 ]]; then
echo "[production-stdb-publish] 受控维护窗口开始前没有 active API/controller/worker。"
return 0
fi
echo "[production-stdb-publish] 停止旧运行时服务并保持维护态: ${services_to_stop[*]}"
run_privileged systemctl stop "${services_to_stop[@]}"
for worker_service in "${services_to_stop[@]}"; do
if [[ "$(get_runtime_service_active_state "${worker_service}")" == "active" ]]; then
echo "[production-stdb-publish] 运行时服务停止后仍为 active: ${worker_service}" >&2
return 1
fi
done
}
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--source-dir)
SOURCE_DIR="${2:?缺少 --source-dir 的值}"
shift 2
;;
--database)
DATABASE="${2:?缺少 --database 的值}"
shift 2
;;
--server)
SERVER_ALIAS="${2:?缺少 --server 的值}"
SERVER_URL=""
shift 2
;;
--server-url)
SERVER_URL="${2:?缺少 --server-url 的值}"
shift 2
;;
--root-dir)
SPACETIME_ROOT_DIR="${2:?缺少 --root-dir 的值}"
shift 2
;;
--run-as-user)
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
;;
--keep-maintenance-mode)
KEEP_MAINTENANCE_MODE=1
shift
;;
--skip-backup)
BACKUP_MODE="skip"
shift
;;
--sync-backup)
BACKUP_MODE="sync"
shift
;;
--backup-mode)
BACKUP_MODE="${2:?缺少 --backup-mode 的值}"
shift 2
;;
*)
echo "[production-stdb-publish] 未知参数: $1" >&2
usage >&2
exit 1
;;
esac
done
require_argument "${SOURCE_DIR}" "--source-dir"
require_argument "${DATABASE}" "--database"
validate_spacetime_database_name "${DATABASE}"
if [[ ! "${SPACETIME_ROOT_DIR}" == /* || "${SPACETIME_ROOT_DIR}" == *".."* ]]; then
echo "[production-stdb-publish] --root-dir 必须是 Linux 绝对路径且不能包含 ..: ${SPACETIME_ROOT_DIR}" >&2
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
fi
if [[ -n "${RUN_AS_USER}" && ! "${RUN_AS_USER}" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]]; then
echo "[production-stdb-publish] --run-as-user 只能是本机用户名: ${RUN_AS_USER}" >&2
exit 1
fi
if [[ ! "${SPACETIME_READY_TIMEOUT_SECONDS}" =~ ^[0-9]+$ || "${SPACETIME_READY_TIMEOUT_SECONDS}" -le 0 ]]; then
echo "[production-stdb-publish] GENARRATIVE_STDB_PUBLISH_READY_TIMEOUT_SECONDS 必须是正整数: ${SPACETIME_READY_TIMEOUT_SECONDS}" >&2
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
fi
SOURCE_DIR="$(cd "${SOURCE_DIR}" && pwd)"
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
start_async_backup_upload || true
fi
if [[ -n "${PUBLISH_TMP_DIR}" && -d "${PUBLISH_TMP_DIR}" ]]; then
rm -rf "${PUBLISH_TMP_DIR}"
fi
if [[ "${exit_code}" -ne 0 && "${DEPLOY_COMPLETED}" -ne 1 ]]; then
echo "[production-stdb-publish] 发布失败,保持维护模式。" >&2
fi
exit "${exit_code}"
}
trap on_exit EXIT
prepare_async_backup() {
local -a restart_service_args=()
ASYNC_BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs"
if [[ ! -f "${ASYNC_BACKUP_SCRIPT}" ]]; then
ASYNC_BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
fi
if [[ ! -f "${ASYNC_BACKUP_SCRIPT}" ]]; then
echo "[production-stdb-publish] 缺少数据库备份脚本: ${ASYNC_BACKUP_SCRIPT}" >&2
exit 1
fi
if [[ "${KEEP_MAINTENANCE_MODE}" -ne 1 ]]; then
restart_service_args+=(--restart-service-after genarrative-api.service)
fi
ASYNC_BACKUP_STATUS_FILE="$(mktemp /tmp/genarrative-stdb-backup-status.XXXXXX.json)"
echo "[production-stdb-publish] publish 前生成本地冷备份,随后会异步上传 OSS"
node -- "${ASYNC_BACKUP_SCRIPT}" \
--env-file /etc/genarrative/api-server.env \
--data-dir "${SPACETIME_ROOT_DIR}" \
--database "${DATABASE}" \
--stop-service spacetimedb.service \
"${restart_service_args[@]}" \
--defer-upload \
--result-file "${ASYNC_BACKUP_STATUS_FILE}"
}
start_async_backup_upload() {
if [[ -z "${ASYNC_BACKUP_STATUS_FILE}" || ! -f "${ASYNC_BACKUP_STATUS_FILE}" ]]; then
echo "[production-stdb-publish] 警告:未找到可上传的本地备份状态文件,跳过异步上传" >&2
return 0
fi
ASYNC_BACKUP_ARCHIVE="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const o=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(o.archivePath || "");' "${ASYNC_BACKUP_STATUS_FILE}")"
ASYNC_BACKUP_MANIFEST="$(node -e 'const fs=require("node:fs"); const p=process.argv[1]; const o=JSON.parse(fs.readFileSync(p,"utf8")); process.stdout.write(o.manifestPath || "");' "${ASYNC_BACKUP_STATUS_FILE}")"
if [[ -z "${ASYNC_BACKUP_ARCHIVE}" || -z "${ASYNC_BACKUP_MANIFEST}" ]]; then
echo "[production-stdb-publish] 警告:备份状态文件缺少 archivePath 或 manifestPath,跳过异步上传" >&2
return 0
fi
mkdir -p "$(dirname "${ASYNC_BACKUP_ARCHIVE}")"
ASYNC_BACKUP_LOG="$(dirname "${ASYNC_BACKUP_ARCHIVE}")/${DATABASE}-upload.log"
echo "[production-stdb-publish] 后台上传本地备份到 OSS: ${ASYNC_BACKUP_ARCHIVE}"
nohup node -- "${ASYNC_BACKUP_SCRIPT}" \
--env-file /etc/genarrative/api-server.env \
--upload-archive "${ASYNC_BACKUP_ARCHIVE}" \
--manifest-file "${ASYNC_BACKUP_MANIFEST}" \
>"${ASYNC_BACKUP_LOG}" 2>&1 &
echo "[production-stdb-publish] OSS 后台上传日志: ${ASYNC_BACKUP_LOG}"
rm -f "${ASYNC_BACKUP_STATUS_FILE}"
ASYNC_BACKUP_STATUS_FILE=""
}
wait_for_spacetime_ready() {
if [[ -z "${SERVER_URL}" ]]; then
echo "[production-stdb-publish] 使用 server alias=${SERVER_ALIAS},跳过 URL 健康检查等待"
return 0
fi
local ping_url="${SERVER_URL%/}/v1/ping"
local deadline=$((SECONDS + SPACETIME_READY_TIMEOUT_SECONDS))
local last_status=""
echo "[production-stdb-publish] 等待 SpacetimeDB 就绪: ${ping_url}timeout=${SPACETIME_READY_TIMEOUT_SECONDS}s"
while (( SECONDS < deadline )); do
# curl 失败时通常表示服务尚未监听;不立即失败,等待冷备份恢复后的 systemd 启动完成。
if last_status="$(curl -fsS --max-time 2 "${ping_url}" 2>&1)"; then
echo "[production-stdb-publish] SpacetimeDB 已就绪: ${ping_url}"
return 0
fi
sleep 2
done
echo "[production-stdb-publish] SpacetimeDB 未在超时内就绪: ${ping_url}" >&2
if [[ -n "${last_status}" ]]; then
echo "[production-stdb-publish] 最后一次健康检查输出: ${last_status}" >&2
fi
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}"
if [[ "${KEEP_MAINTENANCE_MODE}" -eq 1 ]]; then
stop_runtime_services_for_rollout_gate
fi
case "${BACKUP_MODE}" in
async)
prepare_async_backup
;;
sync)
SYNC_BACKUP_RESTART_SERVICE_ARGS=()
BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs"
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
fi
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
echo "[production-stdb-publish] 缺少 publish 前数据库备份脚本: ${BACKUP_SCRIPT}" >&2
exit 1
fi
if [[ "${KEEP_MAINTENANCE_MODE}" -ne 1 ]]; then
SYNC_BACKUP_RESTART_SERVICE_ARGS+=(--restart-service-after genarrative-api.service)
fi
echo "[production-stdb-publish] publish 前同步执行 OSS 冷备份,失败会阻断发布"
node -- "${BACKUP_SCRIPT}" \
--env-file /etc/genarrative/api-server.env \
--data-dir "${SPACETIME_ROOT_DIR}" \
--database "${DATABASE}" \
--stop-service spacetimedb.service \
"${SYNC_BACKUP_RESTART_SERVICE_ARGS[@]}"
;;
skip)
echo "[production-stdb-publish] 已按参数跳过 publish 前数据库备份"
;;
esac
echo "[production-stdb-publish] 校验 wasm"
(
cd "${SOURCE_DIR}"
sha256sum -c spacetime_module.wasm.sha256
)
wait_for_spacetime_ready
PUBLISH_ARGS=(
--root-dir="${SPACETIME_ROOT_DIR}"
publish
"${DATABASE}"
--bin-path "${SOURCE_DIR}/spacetime_module.wasm"
--delete-data=never
--yes=migrate,break-clients
--no-config
)
if [[ -n "${SERVER_URL}" ]]; then
PUBLISH_ARGS+=(--server "${SERVER_URL}")
else
PUBLISH_ARGS+=(--server "${SERVER_ALIAS}")
fi
if [[ -n "${SERVER_URL}" ]]; then
echo "[production-stdb-publish] 发布 SpacetimeDB module: ${DATABASE} -> ${SERVER_URL}, root=${SPACETIME_ROOT_DIR}"
else
echo "[production-stdb-publish] 发布 SpacetimeDB module: ${DATABASE} -> ${SERVER_ALIAS}, root=${SPACETIME_ROOT_DIR}"
fi
if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then
if ! id "${RUN_AS_USER}" >/dev/null 2>&1; then
echo "[production-stdb-publish] 发布用户不存在: ${RUN_AS_USER}" >&2
exit 1
fi
PUBLISH_TMP_DIR="$(mktemp -d /tmp/genarrative-stdb-publish.XXXXXX)"
install -m 0644 "${SOURCE_DIR}/spacetime_module.wasm" "${PUBLISH_TMP_DIR}/spacetime_module.wasm"
chown -R "${RUN_AS_USER}:${RUN_AS_USER}" "${PUBLISH_TMP_DIR}"
PUBLISH_ARGS=(
--root-dir="${SPACETIME_ROOT_DIR}"
publish
"${DATABASE}"
--bin-path "${PUBLISH_TMP_DIR}/spacetime_module.wasm"
--delete-data=never
--yes=migrate,break-clients
--no-config
)
if [[ -n "${SERVER_URL}" ]]; then
PUBLISH_ARGS+=(--server "${SERVER_URL}")
else
PUBLISH_ARGS+=(--server "${SERVER_ALIAS}")
fi
runuser -u "${RUN_AS_USER}" -- spacetime "${PUBLISH_ARGS[@]}"
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
if [[ "${KEEP_MAINTENANCE_MODE}" -eq 1 ]]; then
echo "[production-stdb-publish] module 发布完成;按参数保持维护模式和旧运行时服务停止状态,等待人工维护与 API deploy。"
DEPLOY_COMPLETED=1
exit 0
fi
restart_runtime_services_after_bootstrap_secret_install
wait_for_api_healthz_ready
"${SCRIPT_DIR}/maintenance-off.sh"
DEPLOY_COMPLETED=1
echo "[production-stdb-publish] 完成"