9db467d23f
增加 SpacetimeDB 阶段化健康检查与 /readyz 阶段输出 记录 procedure/reducer/read 失败的阶段和耗时 补充 release 健康巡检 systemd timer 与生产 ops 预检 同步 API 构建部署、provision 脚本和运维文档
400 lines
12 KiB
Bash
400 lines
12 KiB
Bash
#!/usr/bin/env bash
|
|
|
|
set -euo pipefail
|
|
|
|
usage() {
|
|
cat <<'EOF'
|
|
用法:
|
|
./scripts/deploy/production-api-deploy.sh --source-dir build/<version> [--version <version>] [--release-root /opt/genarrative/releases] [--current-link /opt/genarrative/current] [--service genarrative-api.service] [--health-url http://127.0.0.1:8082/readyz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
|
|
|
|
说明:
|
|
进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 readiness 检查。
|
|
若传入 --database,会在重启前把 GENARRATIVE_SPACETIME_DATABASE 写入 api-server 环境文件,避免服务继续读取旧库。
|
|
失败时保留维护模式。
|
|
EOF
|
|
}
|
|
|
|
require_argument() {
|
|
local value="$1"
|
|
local label="$2"
|
|
|
|
if [[ -z "${value}" ]]; then
|
|
echo "[production-api-deploy] 缺少参数: ${label}" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
validate_spacetime_database_name() {
|
|
local database="$1"
|
|
|
|
if [[ ! "${database}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
|
|
echo "[production-api-deploy] --database 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${database}" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
write_env_value() {
|
|
local file_path="$1"
|
|
local key="$2"
|
|
local value="$3"
|
|
|
|
local can_write_direct=0
|
|
|
|
if [[ -e "${file_path}" ]]; then
|
|
[[ -w "${file_path}" ]] && can_write_direct=1
|
|
else
|
|
mkdir -p "$(dirname "${file_path}")"
|
|
[[ -w "$(dirname "${file_path}")" ]] && can_write_direct=1
|
|
fi
|
|
|
|
# api-server 环境文件通常由 server-provision 以 root:root 0600 创建。
|
|
# 发布流水线可能以非 root Jenkins 用户运行,因此仅在不能直接写入时使用 sudo -n,避免因为 env 文件权限导致 API 发布中断。
|
|
if [[ "$(id -u)" -eq 0 || "${can_write_direct}" -eq 1 ]]; then
|
|
python3 - "${file_path}" "${key}" "${value}" <<'PY'
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
file_path = Path(sys.argv[1])
|
|
key = sys.argv[2]
|
|
value = sys.argv[3]
|
|
was_missing = not file_path.exists()
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
lines = []
|
|
if file_path.exists():
|
|
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
|
|
updated = False
|
|
next_lines = []
|
|
for line in lines:
|
|
if line.startswith(f"{key}="):
|
|
next_lines.append(f"{key}={value}")
|
|
updated = True
|
|
else:
|
|
next_lines.append(line)
|
|
|
|
if not updated:
|
|
next_lines.append(f"{key}={value}")
|
|
|
|
file_path.write_text("\n".join(next_lines) + "\n", encoding="utf-8")
|
|
if was_missing:
|
|
os.chmod(file_path, 0o600)
|
|
PY
|
|
else
|
|
if ! sudo -n true >/dev/null 2>&1; then
|
|
echo "[production-api-deploy] 当前用户无权写入 ${file_path},且 sudo -n 不可用;请给部署用户配置免密写入该环境文件或以 root 执行发布。" >&2
|
|
exit 1
|
|
fi
|
|
sudo -n python3 - "${file_path}" "${key}" "${value}" <<'PY'
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
file_path = Path(sys.argv[1])
|
|
key = sys.argv[2]
|
|
value = sys.argv[3]
|
|
was_missing = not file_path.exists()
|
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
lines = []
|
|
if file_path.exists():
|
|
lines = file_path.read_text(encoding="utf-8").splitlines()
|
|
|
|
updated = False
|
|
next_lines = []
|
|
for line in lines:
|
|
if line.startswith(f"{key}="):
|
|
next_lines.append(f"{key}={value}")
|
|
updated = True
|
|
else:
|
|
next_lines.append(line)
|
|
|
|
if not updated:
|
|
next_lines.append(f"{key}={value}")
|
|
|
|
file_path.write_text("\n".join(next_lines) + "\n", encoding="utf-8")
|
|
if was_missing:
|
|
os.chmod(file_path, 0o600)
|
|
PY
|
|
fi
|
|
}
|
|
|
|
read_env_value() {
|
|
local file_path="$1"
|
|
local key="$2"
|
|
|
|
if [[ ! -f "${file_path}" ]]; then
|
|
return 0
|
|
fi
|
|
|
|
local python_script='
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
path = Path(sys.argv[1])
|
|
key = sys.argv[2]
|
|
if not path.exists():
|
|
raise SystemExit(0)
|
|
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
current_key, value = line.split("=", 1)
|
|
if current_key == key:
|
|
value = value.strip()
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("\"", "'\''"):
|
|
value = value[1:-1]
|
|
print(value)
|
|
raise SystemExit(0)
|
|
'
|
|
|
|
if [[ -r "${file_path}" ]]; then
|
|
python3 -c "${python_script}" "${file_path}" "${key}"
|
|
else
|
|
if ! sudo -n true >/dev/null 2>&1; then
|
|
echo "[production-api-deploy] 当前用户无权读取 ${file_path},且 sudo -n 不可用;无法检查运行态环境变量。" >&2
|
|
exit 1
|
|
fi
|
|
sudo -n python3 -c "${python_script}" "${file_path}" "${key}"
|
|
fi
|
|
}
|
|
|
|
ensure_env_value() {
|
|
local file_path="$1"
|
|
local key="$2"
|
|
local default_value="$3"
|
|
local current_value
|
|
|
|
current_value="$(read_env_value "${file_path}" "${key}")"
|
|
if [[ -n "${current_value}" ]]; then
|
|
return
|
|
fi
|
|
|
|
echo "[production-api-deploy] 补齐 api-server 环境变量: ${key} -> ${file_path}"
|
|
write_env_value "${file_path}" "${key}" "${default_value}"
|
|
}
|
|
|
|
run_privileged() {
|
|
if [[ "$(id -u)" -eq 0 ]]; then
|
|
"$@"
|
|
return
|
|
fi
|
|
if ! sudo -n true >/dev/null 2>&1; then
|
|
echo "[production-api-deploy] 当前用户不是 root,且 sudo -n 不可用;无法执行: $*" >&2
|
|
exit 1
|
|
fi
|
|
sudo -n "$@"
|
|
}
|
|
|
|
ensure_runtime_dir() {
|
|
local path="$1"
|
|
local mode="$2"
|
|
|
|
if [[ -z "${path}" ]]; then
|
|
return
|
|
fi
|
|
if [[ "${path}" != /* ]]; then
|
|
echo "[production-api-deploy] 运行态目录必须使用绝对路径,避免写入只读发布目录: ${path}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "[production-api-deploy] 确保运行态目录可写: ${path}"
|
|
run_privileged install -d -o genarrative -g genarrative -m "${mode}" "${path}"
|
|
}
|
|
|
|
ensure_runtime_env_and_dirs() {
|
|
local api_env_file="$1"
|
|
local tracking_enabled tracking_outbox_dir
|
|
|
|
# 旧生产环境文件会被 server-provision 保留,不一定包含新增的运行态写入路径。
|
|
# 发布前只补缺省值,不覆盖线上已经定制过的目录或开关。
|
|
ensure_env_value "${api_env_file}" "GENARRATIVE_API_SHUTDOWN_OUTBOX_FLUSH_TIMEOUT_MS" "5000"
|
|
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED" "true"
|
|
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_DIR" "/var/lib/genarrative/tracking-outbox"
|
|
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_BATCH_SIZE" "500"
|
|
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_FLUSH_INTERVAL_MS" "1000"
|
|
ensure_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_MAX_BYTES" "268435456"
|
|
|
|
tracking_enabled="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_ENABLED")"
|
|
tracking_outbox_dir="$(read_env_value "${api_env_file}" "GENARRATIVE_TRACKING_OUTBOX_DIR")"
|
|
if [[ "$(printf "%s" "${tracking_enabled}" | tr '[:upper:]' '[:lower:]')" != "false" ]]; then
|
|
ensure_runtime_dir "${tracking_outbox_dir}" "0750"
|
|
fi
|
|
}
|
|
|
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
SOURCE_DIR=""
|
|
VERSION=""
|
|
RELEASE_ROOT="/opt/genarrative/releases"
|
|
CURRENT_LINK="/opt/genarrative/current"
|
|
SERVICE_NAME="genarrative-api.service"
|
|
HEALTH_URL="http://127.0.0.1:8082/readyz"
|
|
API_ENV_FILE="/etc/genarrative/api-server.env"
|
|
DATABASE=""
|
|
SPACETIME_SERVER_URL=""
|
|
DEPLOY_COMPLETED=0
|
|
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
-h|--help)
|
|
usage
|
|
exit 0
|
|
;;
|
|
--source-dir)
|
|
SOURCE_DIR="${2:?缺少 --source-dir 的值}"
|
|
shift 2
|
|
;;
|
|
--version)
|
|
VERSION="${2:?缺少 --version 的值}"
|
|
shift 2
|
|
;;
|
|
--release-root)
|
|
RELEASE_ROOT="${2:?缺少 --release-root 的值}"
|
|
shift 2
|
|
;;
|
|
--current-link)
|
|
CURRENT_LINK="${2:?缺少 --current-link 的值}"
|
|
shift 2
|
|
;;
|
|
--service)
|
|
SERVICE_NAME="${2:?缺少 --service 的值}"
|
|
shift 2
|
|
;;
|
|
--health-url)
|
|
HEALTH_URL="${2:?缺少 --health-url 的值}"
|
|
shift 2
|
|
;;
|
|
--api-env-file)
|
|
API_ENV_FILE="${2:?缺少 --api-env-file 的值}"
|
|
shift 2
|
|
;;
|
|
--database)
|
|
DATABASE="${2:?缺少 --database 的值}"
|
|
shift 2
|
|
;;
|
|
--spacetime-server-url)
|
|
SPACETIME_SERVER_URL="${2:?缺少 --spacetime-server-url 的值}"
|
|
shift 2
|
|
;;
|
|
*)
|
|
echo "[production-api-deploy] 未知参数: $1" >&2
|
|
usage >&2
|
|
exit 1
|
|
;;
|
|
esac
|
|
done
|
|
|
|
require_argument "${SOURCE_DIR}" "--source-dir"
|
|
|
|
if [[ -n "${DATABASE}" ]]; then
|
|
validate_spacetime_database_name "${DATABASE}"
|
|
fi
|
|
|
|
if [[ ! -d "${SOURCE_DIR}" ]]; then
|
|
echo "[production-api-deploy] 发布目录不存在: ${SOURCE_DIR}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
SOURCE_DIR="$(cd "${SOURCE_DIR}" && pwd)"
|
|
VERSION="${VERSION:-$(basename "${SOURCE_DIR}")}"
|
|
|
|
if [[ ! "${VERSION}" =~ ^[0-9A-Za-z._-]+$ ]]; then
|
|
echo "[production-api-deploy] --version 只能包含数字、字母、点、下划线和短横线: ${VERSION}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ ! -f "${SOURCE_DIR}/api-server" || ! -f "${SOURCE_DIR}/api-server.sha256" ]]; then
|
|
echo "[production-api-deploy] 缺少 api-server 或 api-server.sha256: ${SOURCE_DIR}" >&2
|
|
exit 1
|
|
fi
|
|
|
|
on_exit() {
|
|
local exit_code=$?
|
|
if [[ "${exit_code}" -ne 0 && "${DEPLOY_COMPLETED}" -ne 1 ]]; then
|
|
echo "[production-api-deploy] 部署失败,保持维护模式。" >&2
|
|
fi
|
|
exit "${exit_code}"
|
|
}
|
|
|
|
trap on_exit EXIT
|
|
|
|
"${SCRIPT_DIR}/maintenance-on.sh" "api deploy ${VERSION}"
|
|
|
|
echo "[production-api-deploy] 校验 api-server"
|
|
(
|
|
cd "${SOURCE_DIR}"
|
|
sha256sum -c api-server.sha256
|
|
)
|
|
|
|
RELEASE_DIR="${RELEASE_ROOT}/${VERSION}"
|
|
mkdir -p "${RELEASE_DIR}"
|
|
cp "${SOURCE_DIR}/api-server" "${RELEASE_DIR}/api-server"
|
|
chmod +x "${RELEASE_DIR}/api-server"
|
|
|
|
BACKUP_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
|
|
WORKSPACE_BACKUP_SCRIPT_SOURCE="$(cd "${SCRIPT_DIR}/../.." && pwd)/scripts/database-backup-to-oss.mjs"
|
|
HEALTH_PATROL_SCRIPT_SOURCE="${SOURCE_DIR}/scripts/ops/production-health-patrol.mjs"
|
|
WORKSPACE_HEALTH_PATROL_SCRIPT_SOURCE="$(cd "${SCRIPT_DIR}/../.." && pwd)/scripts/ops/production-health-patrol.mjs"
|
|
mkdir -p "${RELEASE_DIR}/scripts" "${RELEASE_DIR}/scripts/ops"
|
|
if [[ ! -f "${BACKUP_SCRIPT_SOURCE}" ]]; then
|
|
if [[ -f "${WORKSPACE_BACKUP_SCRIPT_SOURCE}" ]]; then
|
|
echo "[production-api-deploy] 发布产物缺少 scripts/database-backup-to-oss.mjs,回退使用部署工作区脚本;请重新触发包含该脚本的 API 构建。" >&2
|
|
BACKUP_SCRIPT_SOURCE="${WORKSPACE_BACKUP_SCRIPT_SOURCE}"
|
|
else
|
|
echo "[production-api-deploy] 缺少数据库备份脚本: ${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
cp "${BACKUP_SCRIPT_SOURCE}" "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs"
|
|
chmod 0644 "${RELEASE_DIR}/scripts/database-backup-to-oss.mjs"
|
|
if [[ ! -f "${HEALTH_PATROL_SCRIPT_SOURCE}" ]]; then
|
|
if [[ -f "${WORKSPACE_HEALTH_PATROL_SCRIPT_SOURCE}" ]]; then
|
|
echo "[production-api-deploy] 发布产物缺少 scripts/ops/production-health-patrol.mjs,回退使用部署工作区脚本;请重新触发包含该脚本的 API 构建。" >&2
|
|
HEALTH_PATROL_SCRIPT_SOURCE="${WORKSPACE_HEALTH_PATROL_SCRIPT_SOURCE}"
|
|
else
|
|
echo "[production-api-deploy] 未找到生产健康巡检脚本,跳过复制;genarrative-health-patrol.service 会因脚本缺失而跳过执行。" >&2
|
|
HEALTH_PATROL_SCRIPT_SOURCE=""
|
|
fi
|
|
fi
|
|
if [[ -n "${HEALTH_PATROL_SCRIPT_SOURCE}" ]]; then
|
|
cp "${HEALTH_PATROL_SCRIPT_SOURCE}" "${RELEASE_DIR}/scripts/ops/production-health-patrol.mjs"
|
|
chmod 0644 "${RELEASE_DIR}/scripts/ops/production-health-patrol.mjs"
|
|
fi
|
|
|
|
if [[ -f "${SOURCE_DIR}/release-manifest.json" ]]; then
|
|
cp "${SOURCE_DIR}/release-manifest.json" "${RELEASE_DIR}/release-manifest.api-server.json"
|
|
fi
|
|
|
|
if [[ -n "${DATABASE}" ]]; then
|
|
echo "[production-api-deploy] 写入 api-server SpacetimeDB database: ${DATABASE} -> ${API_ENV_FILE}"
|
|
write_env_value "${API_ENV_FILE}" "GENARRATIVE_SPACETIME_DATABASE" "${DATABASE}"
|
|
fi
|
|
|
|
if [[ -n "${SPACETIME_SERVER_URL}" ]]; then
|
|
echo "[production-api-deploy] 写入 api-server SpacetimeDB server: ${SPACETIME_SERVER_URL} -> ${API_ENV_FILE}"
|
|
write_env_value "${API_ENV_FILE}" "GENARRATIVE_SPACETIME_SERVER_URL" "${SPACETIME_SERVER_URL}"
|
|
fi
|
|
|
|
ensure_runtime_env_and_dirs "${API_ENV_FILE}"
|
|
|
|
mkdir -p "$(dirname "${CURRENT_LINK}")"
|
|
ln -sfn "${RELEASE_DIR}" "${CURRENT_LINK}"
|
|
|
|
echo "[production-api-deploy] 重启服务: ${SERVICE_NAME}"
|
|
systemctl restart "${SERVICE_NAME}"
|
|
|
|
echo "[production-api-deploy] 等待 readiness: ${HEALTH_URL}"
|
|
for _ in {1..30}; do
|
|
if curl -fsS "${HEALTH_URL}" >/dev/null; then
|
|
"${SCRIPT_DIR}/maintenance-off.sh"
|
|
DEPLOY_COMPLETED=1
|
|
echo "[production-api-deploy] 完成: ${RELEASE_DIR}/api-server"
|
|
exit 0
|
|
fi
|
|
sleep 2
|
|
done
|
|
|
|
echo "[production-api-deploy] readiness 检查超时: ${HEALTH_URL}" >&2
|
|
exit 1
|