Files
Genarrative/scripts/deploy/production-stdb-publish.sh
T
kdletters 117d482f93
Project CI / AI game creator shell Rust smoke (push) Successful in 2m13s
Project CI / AI game creator shell Rust crates (push) Successful in 1m7s
Project CI / AI game creator shell Rust lane 1/2 (push) Failing after 7m9s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 6m57s
Project CI / Native shell tests (push) Successful in 7m58s
Project CI / Repository checks (push) Successful in 5m13s
Project CI / Frontend tests (push) Successful in 7m19s
Project CI / Backend tests (push) Successful in 10m10s
Project CI / AI game creator shell web tests (push) Successful in 4m47s
发布前备份空间预检前置并支持自动降级
- database-backup-to-oss.mjs 新增 --check-space-only:archive 按 data×1.1、files 按 max(data×0.05, 2GiB) 计算门槛,空间不足以退出码 3 返回
- production-stdb-publish.sh 在进入维护与停服务之前先做空间预检,archive 不足时自动降级 files(files 不支持 defer,async 收敛为 sync),可用环境变量关闭
- 尚未开始 publish 的失败自动恢复本次停掉的 API/controller/worker 并退出维护,只有真正开始发布之后的失败才保持维护态
- 备份检查脚本与生产运维门禁补上新口径与执行顺序断言,运维文档与共享记忆记录事故与规则
2026-09-21 10:57:29 +08:00

951 lines
37 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 恢复服务。
发布前先做备份空间预检(不进入维护、不停服务);archive 空间不足且未显式关闭自动降级时,
自动改用 files 存储格式(不落地本地归档,改为同步直传 OSS),避免磁盘不足把生产留在维护态。
环境变量:
GENARRATIVE_STDB_PUBLISH_BACKUP_STORAGE_FORMAT=archive|files(默认 archive
GENARRATIVE_STDB_PUBLISH_AUTO_FILES_FALLBACK=1|0(默认 1archive 空间不足自动降级 files
GENARRATIVE_STDB_PUBLISH_AUTO_RECOVER_ON_PREPUBLISH_FAILURE=1|0(默认 1:尚未开始 publish 的失败自动恢复服务并退出维护)
migration bootstrap secret 必须由 Jenkins Secret File credential 或等价的受保护文件提供,不从构建 artifact 读取。
如果 API 重启前为 active,会在退出维护模式前等待本机 /healthz readiness 通过。
失败时:尚未开始 publish 的失败会自动恢复运行时服务并退出维护;真正开始 publish 之后的失败保留维护模式。
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}"
BACKUP_STORAGE_FORMAT="${GENARRATIVE_STDB_PUBLISH_BACKUP_STORAGE_FORMAT:-archive}"
AUTO_FILES_FALLBACK="${GENARRATIVE_STDB_PUBLISH_AUTO_FILES_FALLBACK:-1}"
AUTO_RECOVER_BEFORE_PUBLISH="${GENARRATIVE_STDB_PUBLISH_AUTO_RECOVER_ON_PREPUBLISH_FAILURE:-1}"
DEPLOY_COMPLETED=0
PUBLISH_STARTED=0
MAINTENANCE_ENTERED=0
STOPPED_RUNTIME_SERVICES=()
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
}
backup_script_path() {
local candidate=""
for candidate in \
"${SCRIPT_DIR}/../database-backup-to-oss.mjs" \
"${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"; do
if [[ -f "${candidate}" ]]; then
printf '%s\n' "${candidate}"
return 0
fi
done
return 1
}
run_backup_space_precheck() {
local storage_format="$1"
local backup_script=""
if ! backup_script="$(backup_script_path)"; then
echo "[production-stdb-publish] 缺少数据库备份脚本,无法做备份空间预检" >&2
return 1
fi
node -- "${backup_script}" \
--env-file /etc/genarrative/api-server.env \
--data-dir "${SPACETIME_ROOT_DIR}" \
--database "${DATABASE}" \
--storage-format "${storage_format}" \
--check-space-only
}
# 空间预检必须发生在进入维护模式与停服务之前:磁盘不够时不允许再动生产。
precheck_backup_space_before_maintenance() {
if [[ "${BACKUP_MODE}" == "skip" ]]; then
echo "[production-stdb-publish] 已跳过发布前备份空间预检(--backup-mode skip"
return 0
fi
local status=0
run_backup_space_precheck "${BACKUP_STORAGE_FORMAT}" || status=$?
if [[ "${status}" -eq 0 ]]; then
echo "[production-stdb-publish] 发布前备份空间预检通过: storage-format=${BACKUP_STORAGE_FORMAT}(尚未进入维护模式、未停服务)"
return 0
fi
if [[ "${status}" -ne 3 ]]; then
echo "[production-stdb-publish] 发布前备份空间预检失败(非空间原因),中止发布;未进入维护模式、未停服务。" >&2
exit 1
fi
if [[ "${BACKUP_STORAGE_FORMAT}" == "files" ]]; then
echo "[production-stdb-publish] files 模式备份空间仍不足,中止发布;未进入维护模式、未停服务。" >&2
exit 1
fi
if [[ "${AUTO_FILES_FALLBACK}" != "1" ]]; then
echo "[production-stdb-publish] archive 备份空间不足且已禁用自动降级(GENARRATIVE_STDB_PUBLISH_AUTO_FILES_FALLBACK=${AUTO_FILES_FALLBACK}),中止发布;未进入维护模式、未停服务。" >&2
exit 1
fi
echo "[production-stdb-publish] archive 冷备份空间不足:自动降级为 files 存储格式(不落地本地归档,改为文件级 catalog 直传 OSS)。" >&2
BACKUP_STORAGE_FORMAT="files"
if [[ "${BACKUP_MODE}" == "async" ]]; then
echo "[production-stdb-publish] files 模式不支持 --defer-upload,本次备份改为同步执行。" >&2
BACKUP_MODE="sync"
fi
status=0
run_backup_space_precheck "${BACKUP_STORAGE_FORMAT}" || status=$?
if [[ "${status}" -ne 0 ]]; then
echo "[production-stdb-publish] 降级为 files 后空间预检仍失败,中止发布;未进入维护模式、未停服务。" >&2
exit 1
fi
echo "[production-stdb-publish] 已降级为 files 存储格式且空间预检通过。"
}
# 仅在「尚未开始 publish」的失败路径调用:把本次停掉的运行时服务拉回来。
restore_runtime_services_before_publish() {
if [[ "${#STOPPED_RUNTIME_SERVICES[@]}" -eq 0 ]]; then
return 0
fi
local service=""
local state=""
local attempt=0
echo "[production-stdb-publish] 发布尚未开始,恢复本次停掉的运行时服务: ${STOPPED_RUNTIME_SERVICES[*]}"
if ! run_privileged systemctl start "${STOPPED_RUNTIME_SERVICES[@]}"; then
echo "[production-stdb-publish] 启动运行时服务失败: ${STOPPED_RUNTIME_SERVICES[*]}" >&2
return 1
fi
for service in "${STOPPED_RUNTIME_SERVICES[@]}"; do
state=""
for attempt in $(seq 1 15); do
state="$(get_runtime_service_active_state "${service}" 2>/dev/null || true)"
if [[ "${state}" == "active" ]]; then
break
fi
sleep 1
done
if [[ "${state}" != "active" ]]; then
echo "[production-stdb-publish] 运行时服务未恢复 active: ${service}, state=${state}" >&2
return 1
fi
echo "[production-stdb-publish] 运行时服务已恢复 active: ${service}"
done
return 0
}
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[*]}"
STOPPED_RUNTIME_SERVICES=("${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
;;
--backup-storage-format)
BACKUP_STORAGE_FORMAT="${2:?缺少 --backup-storage-format 的值}"
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_STORAGE_FORMAT}" =~ ^(archive|files)$ ]]; then
echo "[production-stdb-publish] --backup-storage-format 只能是 archive 或 files: ${BACKUP_STORAGE_FORMAT}" >&2
exit 1
fi
if [[ "${BACKUP_STORAGE_FORMAT}" == "files" && "${BACKUP_MODE}" == "async" ]]; then
echo "[production-stdb-publish] files 存储格式不支持 --defer-upload,备份模式由 async 调整为 sync" >&2
BACKUP_MODE="sync"
fi
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
if [[ "${PUBLISH_STARTED}" -ne 1 && "${AUTO_RECOVER_BEFORE_PUBLISH}" == "1" ]]; then
# 尚未开始 publish 就失败(例如备份空间/备份执行失败):本次没有任何发布变更,
# 必须把停掉的运行时服务拉回来并退出维护,避免生产停在维护态等人工救。
if restore_runtime_services_before_publish; then
if [[ "${MAINTENANCE_ENTERED}" -eq 1 ]]; then
if ! "${SCRIPT_DIR}/maintenance-off.sh"; then
echo "[production-stdb-publish] 自动退出维护模式失败,请手工执行 maintenance-off.sh。" >&2
fi
fi
echo "[production-stdb-publish] 发布尚未开始即失败,已自动恢复运行时服务并退出维护模式。"
else
echo "[production-stdb-publish] 自动恢复运行时服务失败,保持维护模式,请手工处理。" >&2
fi
else
echo "[production-stdb-publish] 发布失败,保持维护模式。" >&2
fi
fi
exit "${exit_code}"
}
trap on_exit EXIT
prepare_async_backup() {
local -a restart_service_args=()
if ! ASYNC_BACKUP_SCRIPT="$(backup_script_path)"; then
echo "[production-stdb-publish] 缺少数据库备份脚本: ${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" >&2
exit 1
fi
if [[ "${KEEP_MAINTENANCE_MODE}" -ne 1 ]]; then
restart_service_args+=(--restart-service-after genarrative-api.service)
fi
task_tmp_dir="${HOME}/data/tmp"
mkdir -p "${task_tmp_dir}"
ASYNC_BACKUP_STATUS_FILE="$(mktemp "${task_tmp_dir}/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}" \
--storage-format "${BACKUP_STORAGE_FORMAT}" \
--stop-service spacetimedb.service \
"${restart_service_args[@]}" \
--defer-upload \
--result-file "${ASYNC_BACKUP_STATUS_FILE}"
}
start_async_backup_upload() {
local log_dir=""
local node_binary=""
local unit_name=""
local unit_suffix=""
local backup_paths=""
if [[ -z "${ASYNC_BACKUP_STATUS_FILE}" || ! -f "${ASYNC_BACKUP_STATUS_FILE}" ]]; then
echo "[production-stdb-publish] 警告:未找到可上传的本地备份状态文件,跳过异步上传" >&2
return 0
fi
# 备份进程可能因数据库锁冲突在写入状态文件前退出,或只留下空/截断文件。
# 解析失败时保留状态文件并让调用方继续处理发布失败,不能再让 node JSON.parse
# 的堆栈噪声覆盖原始备份错误。
if ! backup_paths="$(node -e '
const fs = require("node:fs");
const p = process.argv[1];
let o;
try {
o = JSON.parse(fs.readFileSync(p, "utf8"));
} catch {
process.exit(2);
}
process.stdout.write(`${o.archivePath || ""}\n${o.manifestPath || ""}`);
' "${ASYNC_BACKUP_STATUS_FILE}" 2>/dev/null)"; then
echo "[production-stdb-publish] 警告:备份状态文件为空或不是有效 JSON,跳过异步上传并保留状态文件: ${ASYNC_BACKUP_STATUS_FILE}" >&2
return 1
fi
ASYNC_BACKUP_ARCHIVE="${backup_paths%%$'\n'*}"
ASYNC_BACKUP_MANIFEST="${backup_paths#*$'\n'}"
if [[ -z "${ASYNC_BACKUP_ARCHIVE}" || -z "${ASYNC_BACKUP_MANIFEST}" ]]; then
echo "[production-stdb-publish] 警告:备份状态文件缺少 archivePath 或 manifestPath,跳过异步上传" >&2
return 0
fi
if [[ "${ASYNC_BACKUP_ARCHIVE}" != /* || ! -f "${ASYNC_BACKUP_ARCHIVE}" || -L "${ASYNC_BACKUP_ARCHIVE}" ]]; then
echo "[production-stdb-publish] 警告:异步上传归档必须是现存、非符号链接的普通绝对路径文件,保留状态文件等待处理: ${ASYNC_BACKUP_ARCHIVE}" >&2
return 1
fi
if [[ "${ASYNC_BACKUP_MANIFEST}" != /* || ! -f "${ASYNC_BACKUP_MANIFEST}" || -L "${ASYNC_BACKUP_MANIFEST}" ]]; then
echo "[production-stdb-publish] 警告:异步上传 manifest 必须是现存、非符号链接的普通绝对路径文件,保留状态文件等待处理: ${ASYNC_BACKUP_MANIFEST}" >&2
return 1
fi
if ! command -v systemd-run >/dev/null 2>&1; then
echo "[production-stdb-publish] 警告:systemd-run 不可用,无法启动独立上传服务;保留状态文件等待处理" >&2
return 1
fi
node_binary="$(command -v node || true)"
if [[ "${node_binary}" != /* || ! -x "${node_binary}" ]]; then
echo "[production-stdb-publish] 警告:未找到可供 systemd 服务执行的绝对 node 路径;保留状态文件等待处理" >&2
return 1
fi
log_dir="$(dirname "${ASYNC_BACKUP_ARCHIVE}")"
unit_suffix="$(date -u +%Y%m%dT%H%M%S%N)-$$-${RANDOM}"
unit_name="genarrative-stdb-backup-upload-${unit_suffix}.service"
if ! ASYNC_BACKUP_LOG="$(mktemp "${log_dir}/${DATABASE}-upload-${unit_suffix}.XXXXXX.log")"; then
echo "[production-stdb-publish] 警告:无法创建独立 OSS 上传日志,保留状态文件和本地归档等待处理" >&2
return 1
fi
if ! chmod 0600 "${ASYNC_BACKUP_LOG}"; then
echo "[production-stdb-publish] 警告:无法收紧独立 OSS 上传日志权限,保留状态文件和本地归档等待处理: ${ASYNC_BACKUP_LOG}" >&2
return 1
fi
echo "[production-stdb-publish] 通过独立 systemd 服务串行上传 deferred/pending 本地备份到 OSS: ${log_dir}"
if ! run_privileged systemd-run \
--no-ask-password \
--unit="${unit_name}" \
--description="Genarrative SpacetimeDB backup upload ${DATABASE}" \
--collect \
--service-type=exec \
--property="Restart=no" \
--property="UMask=0077" \
--property="StandardOutput=append:${ASYNC_BACKUP_LOG}" \
--property="StandardError=append:${ASYNC_BACKUP_LOG}" \
-- "${node_binary}" -- "${ASYNC_BACKUP_SCRIPT}" \
--env-file /etc/genarrative/api-server.env \
--database "${DATABASE}" \
--upload-deferred-dir "${log_dir}"; then
echo "[production-stdb-publish] 警告:独立 OSS 上传服务启动失败,保留状态文件和本地归档等待处理;启动日志: ${ASYNC_BACKUP_LOG}" >&2
return 1
fi
echo "[production-stdb-publish] OSS 上传服务已启动: ${unit_name}"
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
}
precheck_backup_space_before_maintenance
"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}"
MAINTENANCE_ENTERED=1
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=()
if ! BACKUP_SCRIPT="$(backup_script_path)"; then
echo "[production-stdb-publish] 缺少 publish 前数据库备份脚本: ${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" >&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 冷备份(storage-format=${BACKUP_STORAGE_FORMAT}),失败会阻断发布"
node -- "${BACKUP_SCRIPT}" \
--env-file /etc/genarrative/api-server.env \
--data-dir "${SPACETIME_ROOT_DIR}" \
--database "${DATABASE}" \
--storage-format "${BACKUP_STORAGE_FORMAT}" \
--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
# runuser 需要能够穿过临时目录的父目录;Jenkins 以 root 运行时 HOME
# 通常是 /root,而 /root 对 spacetimedb 不可遍历。
task_tmp_dir="/var/tmp"
mkdir -p "${task_tmp_dir}"
PUBLISH_TMP_DIR="$(mktemp -d "${task_tmp_dir}/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
PUBLISH_STARTED=1
runuser -u "${RUN_AS_USER}" -- spacetime "${PUBLISH_ARGS[@]}"
else
PUBLISH_STARTED=1
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] 完成"