Files
Genarrative/scripts/jenkins-deploy-release.sh
kdletters a2c71fcb3a
Some checks failed
CI / verify (push) Has been cancelled
chore: remove maincloud configuration
2026-05-02 17:04:11 +08:00

487 lines
16 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/jenkins-deploy-release.sh --source-dir /path/to/build/123 --deploy-dir /var/lib/jenkins/deploy/Genarrative --web-port 25001 [--clear-database] [--no-migrate-on-conflict] [--migration-dir /path/to/migrations] [--migration-export-token <token>] [--migration-import-token <token>] [--hook-with-sudo]
说明:
1. 如果部署目录已有旧版本且存在 stop.sh则先执行旧版本 stop.sh。
2. 仅删除并替换发布产物文件或目录,保留部署目录中的运行数据目录。
3. 把指定发布目录中的白名单产物复制覆盖到部署目录,后台前端随 web/admin/ 一并覆盖。
4. 如指定 --clear-database则以清库模式执行新版本 start.sh。
5. 默认允许新版本 start.sh 在 schema 冲突时自动导出、清库发布、导入回灌。
6. 覆盖 .env.local 时保留目标机已有 SpacetimeDB 运行 token供 api-server 后台概览读取 private 表统计。
7. 最后执行新版本 start.sh。
参数:
--source-dir <path> 必填,待部署的发布目录,例如 build/123
--deploy-dir <path> 必填,固定部署目录,例如 /var/lib/jenkins/deploy/Genarrative
--web-port <port> 必填,本次部署后静态网站监听端口
--clear-database 可选,启动新版本时追加 --clear-database
--migrate-on-conflict 可选,普通发布遇到 schema 冲突时自动迁移,默认启用
--no-migrate-on-conflict 可选,禁用 schema 冲突自动迁移
--migration-dir <path> 可选,自动迁移 JSON 输出目录,默认部署目录内 database-migrations/<database>
--migration-export-token <token> 可选,旧库已授权迁移操作员 token仅用于 schema 冲突导出
--migration-import-token <token> 可选,新库已授权迁移操作员 token仅用于 schema 冲突导入
--hook-with-sudo 可选,仅对 start.sh/stop.sh 使用 sudo -n 执行
EOF
}
require_argument() {
local value="$1"
local label="$2"
if [[ -z "${value}" ]]; then
echo "[jenkins-deploy] 缺少参数: ${label}" >&2
exit 1
fi
}
validate_port() {
local value="$1"
local label="$2"
local numeric_value
if [[ ! "${value}" =~ ^[0-9]+$ ]]; then
echo "[jenkins-deploy] ${label} 必须是数字端口: ${value}" >&2
exit 1
fi
if ((${#value} > 5)); then
echo "[jenkins-deploy] ${label} 必须在 1-65535 之间: ${value}" >&2
exit 1
fi
numeric_value=$((10#${value}))
if ((numeric_value < 1 || numeric_value > 65535)); then
echo "[jenkins-deploy] ${label} 必须在 1-65535 之间: ${value}" >&2
exit 1
fi
}
validate_spacetime_database_name() {
local database="$1"
if [[ ! "${database}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then
echo "[jenkins-deploy] GENARRATIVE_SPACETIME_DATABASE 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${database}" >&2
exit 1
fi
}
normalize_env_file() {
local env_file="$1"
local temp_file="${env_file}.tmp.$$"
if [[ ! -f "${env_file}" ]]; then
return
fi
# 兼容由 Windows 编辑器或 Jenkins 参数落盘产生的 BOM/CRLF避免 start.sh 加载时报命令不存在。
LC_ALL=C sed $'1s/^\xef\xbb\xbf//;s/\r$//' "${env_file}" >"${temp_file}"
cp "${temp_file}" "${env_file}"
}
read_env_value() {
local key="$1"
shift
local env_file=""
local line=""
local line_number=0
local parsed_key=""
local parsed_value=""
local value=""
local utf8_bom=$'\xef\xbb\xbf'
for env_file in "$@"; do
if [[ ! -f "${env_file}" ]]; then
continue
fi
line_number=0
while IFS= read -r line || [[ -n "${line}" ]]; do
line_number=$((line_number + 1))
if [[ "${line_number}" -eq 1 ]]; then
line="${line#"${utf8_bom}"}"
fi
line="${line%$'\r'}"
if [[ "${line}" =~ ^[[:space:]]*$ || "${line}" =~ ^[[:space:]]*# ]]; then
continue
fi
if [[ ! "${line}" =~ ^[[:space:]]*(export[[:space:]]+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]]; then
continue
fi
parsed_key="${BASH_REMATCH[2]}"
parsed_value="${BASH_REMATCH[3]}"
if [[ "${parsed_key}" != "${key}" ]]; then
continue
fi
value="${parsed_value}"
if [[ "${#value}" -ge 2 && "${value:0:1}" == '"' && "${value: -1}" == '"' ]]; then
value="${value:1:${#value}-2}"
value="${value//\\\"/\"}"
elif [[ "${#value}" -ge 2 && "${value:0:1}" == "'" && "${value: -1}" == "'" ]]; then
value="${value:1:${#value}-2}"
fi
done <"${env_file}"
done
printf "%s" "${value}"
}
normalize_release_env_files() {
local release_dir="$1"
normalize_env_file "${release_dir}/.env"
normalize_env_file "${release_dir}/.env.local"
normalize_env_file "${release_dir}/web/.env"
normalize_env_file "${release_dir}/web/.env.local"
}
write_env_override() {
local env_file="$1"
local key="$2"
local value="$3"
local temp_file="${env_file}.tmp.$$"
mkdir -p "$(dirname "${env_file}")"
if [[ -f "${env_file}" ]]; then
# 先移除旧的同名变量,再追加 Jenkins 本次部署参数,确保 sudo 启动时也能被 start.sh 读取。
awk -v target_key="${key}" '
BEGIN {
pattern = "^[[:space:]]*(export[[:space:]]+)?" target_key "="
}
$0 !~ pattern {
print
}
' "${env_file}" >"${temp_file}"
else
: >"${temp_file}"
fi
printf "%s=%s\n" "${key}" "${value}" >>"${temp_file}"
cp "${temp_file}" "${env_file}"
}
SOURCE_DIR=""
DEPLOY_DIR=""
WEB_PORT=""
CLEAR_DATABASE="0"
MIGRATE_ON_CONFLICT="true"
MIGRATION_DIR=""
HOOK_WITH_SUDO="0"
MIGRATION_EXPORT_TOKEN=""
MIGRATION_IMPORT_TOKEN=""
PRESERVED_MIGRATION_EXPORT_TOKEN=""
PRESERVED_MIGRATION_IMPORT_TOKEN=""
PRESERVED_SPACETIME_TOKEN=""
DEPLOY_COMPLETED="0"
RESTORE_PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_ON_FAILURE="0"
DEPLOY_ITEMS=(
".env"
".env.local"
"README.md"
"api-server"
"migration-bootstrap-secret.txt"
"spacetime_module.wasm"
"scripts"
"start.sh"
"stop.sh"
"web"
"web-server.mjs"
)
PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_NAME="migration-bootstrap-secret.previous.txt"
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
usage
exit 0
;;
--source-dir)
SOURCE_DIR="${2:?缺少 --source-dir 的值}"
shift 2
;;
--deploy-dir)
DEPLOY_DIR="${2:?缺少 --deploy-dir 的值}"
shift 2
;;
--web-port)
WEB_PORT="${2:?缺少 --web-port 的值}"
shift 2
;;
--clear-database)
CLEAR_DATABASE="1"
shift
;;
--migrate-on-conflict)
MIGRATE_ON_CONFLICT="true"
shift
;;
--no-migrate-on-conflict)
MIGRATE_ON_CONFLICT="false"
shift
;;
--migration-dir)
MIGRATION_DIR="${2:?缺少 --migration-dir 的值}"
shift 2
;;
--migration-export-token)
MIGRATION_EXPORT_TOKEN="${2:?缺少 --migration-export-token 的值}"
shift 2
;;
--migration-import-token)
MIGRATION_IMPORT_TOKEN="${2:?缺少 --migration-import-token 的值}"
shift 2
;;
--hook-with-sudo)
HOOK_WITH_SUDO="1"
shift
;;
*)
echo "[jenkins-deploy] 未知参数: $1" >&2
usage >&2
exit 1
;;
esac
done
require_argument "${SOURCE_DIR}" "--source-dir"
require_argument "${DEPLOY_DIR}" "--deploy-dir"
require_argument "${WEB_PORT}" "--web-port"
validate_port "${WEB_PORT}" "--web-port"
run_hook() {
local hook_dir="$1"
local hook_name="$2"
shift 2
local hook_path="${hook_dir}/${hook_name}"
if [[ ! -x "${hook_path}" ]]; then
echo "[jenkins-deploy] hook 不存在或不可执行: ${hook_path}" >&2
exit 1
fi
# 仅在启停脚本阶段使用 sudo文件清理与移动仍保持普通权限避免放大授权范围。
if [[ "${HOOK_WITH_SUDO}" == "1" ]]; then
echo "[jenkins-deploy] 使用 sudo 执行 ${hook_name}: ${hook_path}"
(
cd "${hook_dir}"
sudo -n "${hook_path}" "$@"
) || {
echo "[jenkins-deploy] sudo 执行 ${hook_name} 失败,请确认 jenkins 用户已配置免密 sudo 权限。" >&2
exit 1
}
return
fi
(
cd "${hook_dir}"
"./${hook_name}" "$@"
)
}
previous_migration_bootstrap_secret_file() {
printf "%s/deploy-state/%s" "${DEPLOY_DIR}" "${PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_NAME}"
}
save_previous_migration_bootstrap_secret() {
local source_file="${DEPLOY_DIR}/migration-bootstrap-secret.txt"
local state_dir="${DEPLOY_DIR}/deploy-state"
local target_file
target_file="$(previous_migration_bootstrap_secret_file)"
mkdir -p "${state_dir}" || {
echo "[jenkins-deploy] 创建部署状态目录失败: ${state_dir}" >&2
exit 1
}
# 旧迁移密钥属于部署阶段要维护的状态,不再写入 run/,避免 sudo 启停生成的 root 私有 pid 目录阻断覆盖部署。
cp "${source_file}" "${target_file}" || {
echo "[jenkins-deploy] 保存旧模块迁移引导密钥失败: ${target_file}" >&2
exit 1
}
chmod 600 "${target_file}" 2>/dev/null || true
RESTORE_PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_ON_FAILURE="1"
echo "[jenkins-deploy] 已保存旧模块迁移引导密钥,用于 schema 冲突时导出旧库。"
}
restore_previous_migration_bootstrap_secret_on_failure() {
local exit_code=$?
local source_file=""
local target_file=""
if [[ "${exit_code}" -eq 0 || "${DEPLOY_COMPLETED}" == "1" ]]; then
return 0
fi
if [[ "${RESTORE_PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_ON_FAILURE}" != "1" ]]; then
exit "${exit_code}"
fi
source_file="$(previous_migration_bootstrap_secret_file)"
target_file="${DEPLOY_DIR}/migration-bootstrap-secret.txt"
if [[ ! -f "${source_file}" ]]; then
echo "[jenkins-deploy] 部署失败,但未找到旧迁移引导密钥快照,无法恢复: ${source_file}" >&2
exit "${exit_code}"
fi
if cp "${source_file}" "${target_file}"; then
chmod 600 "${target_file}" 2>/dev/null || true
echo "[jenkins-deploy] 部署失败,已恢复旧迁移引导密钥: ${target_file}" >&2
else
echo "[jenkins-deploy] 部署失败,且恢复旧迁移引导密钥失败: ${target_file}" >&2
fi
exit "${exit_code}"
}
clear_previous_migration_bootstrap_secret() {
local target_file
target_file="$(previous_migration_bootstrap_secret_file)"
if [[ ! -e "${target_file}" ]]; then
return
fi
rm -f "${target_file}" || {
echo "[jenkins-deploy] 清理旧迁移引导密钥快照失败: ${target_file}" >&2
exit 1
}
}
normalize_start_previous_secret_path() {
local start_file="${DEPLOY_DIR}/start.sh"
local legacy_line='PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE="${SCRIPT_DIR}/run/migration-bootstrap-secret.previous.txt"'
local state_line='PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_FILE="${SCRIPT_DIR}/deploy-state/migration-bootstrap-secret.previous.txt"'
local temp_file="${start_file}.tmp.$$"
if [[ ! -f "${start_file}" ]]; then
return
fi
if grep -Fq "${legacy_line}" "${start_file}"; then
# 兼容已经构建出的旧发布包:部署阶段统一让 start.sh 从 Jenkins 可写的部署状态目录读取旧密钥。
awk -v legacy="${legacy_line}" -v state="${state_line}" '
$0 == legacy {
print state
next
}
{
print
}
' "${start_file}" >"${temp_file}"
cp "${temp_file}" "${start_file}"
rm -f "${temp_file}"
fi
}
if [[ ! -d "${SOURCE_DIR}" ]]; then
echo "[jenkins-deploy] 发布目录不存在: ${SOURCE_DIR}" >&2
exit 1
fi
SOURCE_DIR="$(cd "${SOURCE_DIR}" && pwd)"
mkdir -p "${DEPLOY_DIR}"
DEPLOY_DIR="$(cd "${DEPLOY_DIR}" && pwd)"
trap restore_previous_migration_bootstrap_secret_on_failure EXIT
if [[ ! -f "${SOURCE_DIR}/start.sh" ]]; then
echo "[jenkins-deploy] 发布目录缺少 start.sh: ${SOURCE_DIR}" >&2
exit 1
fi
normalize_release_env_files "${SOURCE_DIR}"
PRESERVED_MIGRATION_EXPORT_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
PRESERVED_MIGRATION_IMPORT_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
PRESERVED_SPACETIME_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
if [[ -x "${DEPLOY_DIR}/stop.sh" ]]; then
echo "[jenkins-deploy] 先停止旧版本: ${DEPLOY_DIR}"
run_hook "${DEPLOY_DIR}" "stop.sh"
else
echo "[jenkins-deploy] 部署目录无可执行 stop.sh跳过停服"
fi
if [[ -f "${DEPLOY_DIR}/migration-bootstrap-secret.txt" ]]; then
save_previous_migration_bootstrap_secret
else
clear_previous_migration_bootstrap_secret
fi
echo "[jenkins-deploy] 清空部署目录: ${DEPLOY_DIR}"
for item in "${DEPLOY_ITEMS[@]}"; do
if [[ -e "${DEPLOY_DIR}/${item}" ]]; then
echo "[jenkins-deploy] 删除旧产物: ${DEPLOY_DIR}/${item}"
rm -rf "${DEPLOY_DIR:?}/${item}"
fi
done
echo "[jenkins-deploy] 复制发布内容: ${SOURCE_DIR} -> ${DEPLOY_DIR}"
for item in "${DEPLOY_ITEMS[@]}"; do
source_item="${SOURCE_DIR}/${item}"
if [[ -e "${source_item}" ]]; then
echo "[jenkins-deploy] 覆盖产物: ${item}"
# web 是目录产物,必须递归复制;文件产物保持普通复制,避免误扩大复制语义。
if [[ -d "${source_item}" ]]; then
cp -R "${source_item}" "${DEPLOY_DIR}/"
else
cp "${source_item}" "${DEPLOY_DIR}/"
fi
fi
done
normalize_start_previous_secret_path
chmod +x "${DEPLOY_DIR}/start.sh"
if [[ -f "${DEPLOY_DIR}/stop.sh" ]]; then
chmod +x "${DEPLOY_DIR}/stop.sh"
fi
normalize_release_env_files "${DEPLOY_DIR}"
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_WEB_PORT" "${WEB_PORT}"
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT" "${MIGRATE_ON_CONFLICT}"
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_DIR" "${MIGRATION_DIR}"
if [[ -n "${MIGRATION_EXPORT_TOKEN}" ]]; then
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN" "${MIGRATION_EXPORT_TOKEN}"
elif [[ -n "${PRESERVED_MIGRATION_EXPORT_TOKEN}" ]] \
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN" "${PRESERVED_MIGRATION_EXPORT_TOKEN}"
fi
if [[ -n "${MIGRATION_IMPORT_TOKEN}" ]]; then
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${MIGRATION_IMPORT_TOKEN}"
elif [[ -n "${PRESERVED_MIGRATION_IMPORT_TOKEN}" ]] \
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${PRESERVED_MIGRATION_IMPORT_TOKEN}"
fi
if [[ -n "${PRESERVED_SPACETIME_TOKEN}" ]] \
&& [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then
write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_TOKEN" "${PRESERVED_SPACETIME_TOKEN}"
fi
DEPLOY_DATABASE="$(read_env_value "GENARRATIVE_SPACETIME_DATABASE" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")"
if [[ -z "${DEPLOY_DATABASE}" ]]; then
echo "[jenkins-deploy] 部署包未显式写入 GENARRATIVE_SPACETIME_DATABASE将由 start.sh 使用构建时默认值。" >&2
else
validate_spacetime_database_name "${DEPLOY_DATABASE}"
echo "[jenkins-deploy] SpacetimeDB 发布数据库: ${DEPLOY_DATABASE}"
fi
echo "[jenkins-deploy] 启动新版本: ${DEPLOY_DIR}"
if [[ "${CLEAR_DATABASE}" == "1" ]]; then
echo "[jenkins-deploy] 以清库模式启动新版本"
run_hook "${DEPLOY_DIR}" "start.sh" --clear-database
else
run_hook "${DEPLOY_DIR}" "start.sh"
fi
echo "[jenkins-deploy] 完成"
DEPLOY_COMPLETED="1"