Files
Genarrative/scripts/deploy/production-api-deploy.sh
2026-05-21 15:27:19 +08:00

376 lines
11 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
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/healthz] [--api-env-file /etc/genarrative/api-server.env] [--database genarrative-prod] [--spacetime-server-url http://127.0.0.1:3101]
说明:
进入维护模式,校验并发布 api-server 单文件,更新 current 链接,重启 systemd 服务并执行 healthz 检查。
若传入 --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 auth_store_path auth_store_dir
# 旧生产环境文件会被 server-provision 保留,不一定包含新增的运行态写入路径。
# 发布前只补缺省值,不覆盖线上已经定制过的目录或开关。
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"
ensure_env_value "${api_env_file}" "GENARRATIVE_AUTH_STORE_PATH" "/var/lib/genarrative/auth/auth-store.json"
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
auth_store_path="$(read_env_value "${api_env_file}" "GENARRATIVE_AUTH_STORE_PATH")"
if [[ -n "${auth_store_path}" ]]; then
auth_store_dir="$(dirname "${auth_store_path}")"
ensure_runtime_dir "${auth_store_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/healthz"
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"
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] 等待 healthz: ${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] healthz 检查超时: ${HEALTH_URL}" >&2
exit 1