Files
Genarrative/jenkins/Jenkinsfile.production-server-provision

445 lines
20 KiB
Plaintext
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.
pipeline {
agent none
options {
disableConcurrentBuilds()
skipDefaultCheckout(true)
buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20'))
}
environment {
GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git'
}
parameters {
choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标development 使用当前 Linux 开发/构建/开发部署 agent')
booleanParam(name: 'CONFIRM_RELEASE_DEPLOY_AGENT', defaultValue: false, description: '确认 release 目标已有独立 release 部署 agent')
string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送')
booleanParam(name: 'CONFIRM_PROVISION', defaultValue: false, description: '确认执行服务器初始化;未勾选时只允许 dry-run')
booleanParam(name: 'DRY_RUN', defaultValue: true, description: '只打印将执行的服务器初始化命令,不写入系统配置')
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '部署脚本来源分支')
string(name: 'COMMIT_HASH', defaultValue: '', description: '部署脚本来源 commit')
string(name: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: 'Nginx server_name 与证书域名')
string(name: 'SPACETIME_BIN_SOURCE', defaultValue: '/usr/local/bin/spacetime', description: '服务器上已有 spacetime CLI 路径')
string(name: 'SPACETIME_ROOT', defaultValue: '/stdb', description: 'SpacetimeDB root-dir')
string(name: 'RELEASE_ROOT', defaultValue: '/opt/genarrative/releases', description: 'release 根目录')
string(name: 'CURRENT_LINK', defaultValue: '/opt/genarrative/current', description: '当前版本软链接')
string(name: 'WEB_LINK', defaultValue: '/srv/genarrative/web', description: 'Nginx 静态站点目录或软链接')
string(name: 'API_ENV_FILE', defaultValue: '/etc/genarrative/api-server.env', description: 'api-server 环境文件')
string(name: 'API_PORT', defaultValue: '8082', description: 'api-server 本机监听端口')
choice(name: 'NGINX_CONFIG_MODE', choices: ['none', 'production-https', 'development-http'], description: 'Nginx 配置模式;开发服无域名时选 development-httprelease 正式入口选 production-https')
booleanParam(name: 'ENABLE_SERVICES', defaultValue: true, description: '启用并启动 spacetimedb 与 api-server systemd 服务')
}
stages {
stage('Prepare') {
agent {
label 'linux && genarrative-build'
}
steps {
script {
if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) {
error('release provision 需要先配置独立 release 部署 agent并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。')
}
if (!params.DRY_RUN && !params.CONFIRM_PROVISION) {
error('执行服务器初始化前必须勾选 CONFIRM_PROVISION否则请保持 DRY_RUN=true。')
}
if (!params.SERVER_NAME?.trim()) {
error('SERVER_NAME 不能为空。')
}
if (!params.SPACETIME_BIN_SOURCE?.trim()) {
error('SPACETIME_BIN_SOURCE 不能为空。')
}
def nginxMode = params.NGINX_CONFIG_MODE?.trim()
if (!(nginxMode in ['none', 'production-https', 'development-http'])) {
error("NGINX_CONFIG_MODE 只能是 none、production-https 或 development-http当前值: ${params.NGINX_CONFIG_MODE}")
}
if (params.DEPLOY_TARGET == 'release' && nginxMode == 'development-http') {
error('release 目标禁止安装 development-http Nginx 配置;无证书初始化请使用 NGINX_CONFIG_MODE=none。')
}
if (!params.DRY_RUN && nginxMode == 'production-https' && params.SERVER_NAME?.trim() == 'genarrative.example.com') {
error('真实初始化安装 Nginx 配置时必须把 SERVER_NAME 改成真实域名,不能使用 genarrative.example.com 占位值。证书未准备好时请先保持 NGINX_CONFIG_MODE=none。')
}
}
}
}
stage('Checkout Provision Files') {
agent {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
}
steps {
checkout([
$class: 'GitSCM',
branches: [[name: "*/${params.SOURCE_BRANCH}"]],
doGenerateSubmoduleConfigurations: false,
extensions: [[$class: 'CleanBeforeCheckout']],
userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]],
])
sh '''
bash -lc '
set -euo pipefail
chmod +x scripts/jenkins-checkout-source.sh
SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \
COMMIT_HASH="${COMMIT_HASH:-}" \
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
scripts/jenkins-checkout-source.sh
'
'''
}
}
stage('Provision Server') {
agent {
label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-build' : 'linux && genarrative-release-deploy'}"
}
steps {
sh '''
bash -lc '
set -euo pipefail
require_path() {
local path="$1"
if [[ ! -e "${path}" ]]; then
echo "[server-provision] 缺少必要文件: ${path}" >&2
exit 1
fi
}
run_cmd() {
echo "+ $*"
if [[ "${DRY_RUN}" != "true" ]]; then
"$@"
fi
}
install_file() {
local source="$1"
local target="$2"
local mode="$3"
echo "+ install -m ${mode} ${source} ${target}"
if [[ "${DRY_RUN}" != "true" ]]; then
install -m "${mode}" "${source}" "${target}"
fi
}
install_build_dependencies() {
echo "[server-provision] 安装 Linux 构建依赖: clang, lld, pkg-config, OpenSSL headers"
if command -v apt-get >/dev/null 2>&1; then
run_cmd apt-get update
run_cmd apt-get install -y clang lld pkg-config libssl-dev ca-certificates
elif command -v dnf >/dev/null 2>&1; then
run_cmd dnf install -y clang lld pkgconf-pkg-config openssl-devel ca-certificates
elif command -v yum >/dev/null 2>&1; then
run_cmd yum install -y clang lld pkgconf-pkg-config openssl-devel ca-certificates
else
echo "[server-provision] 未找到 apt-get/dnf/yum无法自动安装 clang/lld。请手动安装后重跑构建。" >&2
exit 1
fi
}
install_sccache() {
for tool_dir in "${HOME:-}/.cargo/bin" /root/.cargo/bin /usr/local/cargo/bin; do
if [[ -d "${tool_dir}" && ":${PATH}:" != *":${tool_dir}:"* ]]; then
export PATH="${tool_dir}:${PATH}"
fi
done
if command -v sccache >/dev/null 2>&1; then
echo "[server-provision] sccache 已存在: $(command -v sccache)"
return
fi
if [[ -x /root/.cargo/bin/sccache ]]; then
echo "[server-provision] sccache 已存在: /root/.cargo/bin/sccache"
return
fi
echo "[server-provision] 未找到 sccache准备通过 cargo install sccache 安装。"
if ! command -v cargo >/dev/null 2>&1; then
echo "[server-provision] 未找到 cargo无法自动安装 sccache。请先安装 Rust 工具链后重跑 Server-Provision。" >&2
exit 1
fi
if [[ "${DRY_RUN}" == "true" ]]; then
echo "+ cargo install sccache --locked"
return
fi
cargo install sccache --locked
if ! command -v sccache >/dev/null 2>&1 && [[ ! -x /root/.cargo/bin/sccache ]]; then
echo "[server-provision] sccache 安装后仍不可用,请检查 cargo bin 目录是否在 PATH 中。" >&2
exit 1
fi
}
render_nginx_https_config() {
sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative.conf
}
render_nginx_development_http_config() {
sed "s/genarrative.example.com/${SERVER_NAME}/g" deploy/nginx/genarrative-dev-http.conf
}
render_api_env_example() {
sed \
-e "s|^GENARRATIVE_API_PORT=.*|GENARRATIVE_API_PORT=${API_PORT}|" \
-e "s|^GENARRATIVE_SPACETIME_SERVER_URL=.*|GENARRATIVE_SPACETIME_SERVER_URL=http://127.0.0.1:3000|" \
deploy/env/api-server.env.example
}
validate_nginx_tls() {
local cert_dir="/etc/letsencrypt/live/${SERVER_NAME}"
if [[ "${SERVER_NAME}" == "genarrative.example.com" ]]; then
echo "[server-provision] SERVER_NAME 仍是占位域名,拒绝写入 Nginx HTTPS 配置。请填写真实域名,或先设置 NGINX_CONFIG_MODE=none。" >&2
exit 1
fi
if [[ ! -f "${cert_dir}/fullchain.pem" || ! -f "${cert_dir}/privkey.pem" ]]; then
echo "[server-provision] 未找到 Nginx HTTPS 证书: ${cert_dir}/fullchain.pem 或 ${cert_dir}/privkey.pem" >&2
echo "[server-provision] 请先完成证书申请,或首次初始化时设置 NGINX_CONFIG_MODE=none避免写入无法通过 nginx -t 的配置。" >&2
exit 1
fi
}
install_nginx_config_with_rollback() {
local config_target="/etc/nginx/conf.d/genarrative.conf"
local snippet_target="/etc/nginx/snippets/genarrative-maintenance.conf"
local config_source
local rendered_config rendered_snippet config_backup snippet_backup
local had_config="false"
local had_snippet="false"
run_cmd mkdir -p /etc/nginx/snippets /etc/nginx/conf.d
if [[ "${NGINX_CONFIG_MODE}" == "production-https" ]]; then
config_source="deploy/nginx/genarrative.conf"
elif [[ "${NGINX_CONFIG_MODE}" == "development-http" ]]; then
config_source="deploy/nginx/genarrative-dev-http.conf"
else
echo "[server-provision] NGINX_CONFIG_MODE=${NGINX_CONFIG_MODE} 不需要安装 Nginx 配置。"
return
fi
echo "+ render ${config_source} -> ${config_target}"
echo "+ install -m 0644 deploy/nginx/snippets/genarrative-maintenance.conf ${snippet_target}"
if [[ "${DRY_RUN}" == "true" ]]; then
echo "+ nginx -t"
echo "+ nginx -s reload"
return
fi
rendered_config="$(mktemp)"
rendered_snippet="$(mktemp)"
config_backup="$(mktemp)"
snippet_backup="$(mktemp)"
if [[ "${NGINX_CONFIG_MODE}" == "production-https" ]]; then
validate_nginx_tls
render_nginx_https_config >"${rendered_config}"
else
render_nginx_development_http_config >"${rendered_config}"
fi
cp deploy/nginx/snippets/genarrative-maintenance.conf "${rendered_snippet}"
if [[ -f "${config_target}" ]]; then
cp -p "${config_target}" "${config_backup}"
had_config="true"
fi
if [[ -f "${snippet_target}" ]]; then
cp -p "${snippet_target}" "${snippet_backup}"
had_snippet="true"
fi
install -m 0644 "${rendered_config}" "${config_target}"
install -m 0644 "${rendered_snippet}" "${snippet_target}"
if ! nginx -t; then
echo "[server-provision] nginx -t 失败,恢复写入前的 Nginx 配置。" >&2
if [[ "${had_config}" == "true" ]]; then
cp -p "${config_backup}" "${config_target}"
else
rm -f "${config_target}"
fi
if [[ "${had_snippet}" == "true" ]]; then
cp -p "${snippet_backup}" "${snippet_target}"
else
rm -f "${snippet_target}"
fi
rm -f "${rendered_config}" "${rendered_snippet}" "${config_backup}" "${snippet_backup}"
exit 1
fi
echo "+ nginx -s reload"
nginx -s reload
rm -f "${rendered_config}" "${rendered_snippet}" "${config_backup}" "${snippet_backup}"
}
cleanup_placeholder_nginx_config() {
local config_target="/etc/nginx/conf.d/genarrative.conf"
local disabled_target
if [[ ! -f "${config_target}" ]]; then
return
fi
if ! grep -q "/etc/letsencrypt/live/genarrative.example.com/" "${config_target}"; then
return
fi
disabled_target="${config_target}.disabled-placeholder-$(date +%Y%m%d%H%M%S)"
echo "[server-provision] 发现上一轮初始化留下的占位域名 Nginx 配置,禁用: ${config_target} -> ${disabled_target}"
if [[ "${DRY_RUN}" != "true" ]]; then
mv "${config_target}" "${disabled_target}"
if command -v nginx >/dev/null 2>&1; then
if ! nginx -t; then
echo "[server-provision] 占位配置已禁用,但 nginx -t 仍失败;请检查其他 Nginx 配置。" >&2
else
echo "+ nginx -s reload"
nginx -s reload
fi
fi
fi
}
escape_sed_replacement() {
printf "%s" "$1" | sed "s/[&|]/\\\\&/g"
}
render_spacetimedb_service() {
local root_escaped
root_escaped="$(escape_sed_replacement "${SPACETIME_ROOT}")"
sed \
-e "s|/stdb|${root_escaped}|g" \
deploy/systemd/spacetimedb.service
}
render_api_service() {
local current_escaped env_escaped
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")"
sed \
-e "s|/opt/genarrative/current|${current_escaped}|g" \
-e "s|/etc/genarrative/api-server.env|${env_escaped}|g" \
deploy/systemd/genarrative-api.service
}
require_path deploy/systemd/spacetimedb.service
require_path deploy/systemd/genarrative-api.service
require_path deploy/nginx/genarrative.conf
require_path deploy/nginx/genarrative-dev-http.conf
require_path deploy/nginx/snippets/genarrative-maintenance.conf
require_path deploy/env/api-server.env.example
require_path scripts/deploy/maintenance-on.sh
require_path scripts/deploy/maintenance-off.sh
require_path scripts/deploy/maintenance-status.sh
echo "[server-provision] target=${DEPLOY_TARGET}, dry_run=${DRY_RUN}, nginx_config_mode=${NGINX_CONFIG_MODE}, source_commit=$(cat .jenkins-source-commit)"
run_cmd id
install_build_dependencies
install_sccache
run_cmd mkdir -p "${SPACETIME_ROOT}" "${RELEASE_ROOT}" "$(dirname "${CURRENT_LINK}")" "$(dirname "${WEB_LINK}")" /etc/genarrative /var/lib/genarrative/maintenance /var/lib/genarrative/auth
if ! id spacetimedb >/dev/null 2>&1; then
run_cmd useradd --system --home-dir "${SPACETIME_ROOT}" --shell /usr/sbin/nologin spacetimedb
else
echo "[server-provision] 用户已存在: spacetimedb"
fi
if ! id genarrative >/dev/null 2>&1; then
run_cmd useradd --system --home-dir /opt/genarrative --shell /usr/sbin/nologin genarrative
else
echo "[server-provision] 用户已存在: genarrative"
fi
run_cmd chown -R spacetimedb:spacetimedb "${SPACETIME_ROOT}"
run_cmd chown -R genarrative:genarrative /opt/genarrative /var/lib/genarrative /srv/genarrative
if [[ ! -x "${SPACETIME_BIN_SOURCE}" ]]; then
echo "[server-provision] spacetime CLI 不存在或不可执行: ${SPACETIME_BIN_SOURCE}" >&2
exit 1
fi
echo "+ install -m 0755 ${SPACETIME_BIN_SOURCE} ${SPACETIME_ROOT}/spacetime"
if [[ "${DRY_RUN}" != "true" ]]; then
install -m 0755 "${SPACETIME_BIN_SOURCE}" "${SPACETIME_ROOT}/spacetime"
chown spacetimedb:spacetimedb "${SPACETIME_ROOT}/spacetime"
fi
spacetimedb_service="$(mktemp)"
api_service="$(mktemp)"
render_spacetimedb_service >"${spacetimedb_service}"
render_api_service >"${api_service}"
install_file "${spacetimedb_service}" /etc/systemd/system/spacetimedb.service 0644
install_file "${api_service}" /etc/systemd/system/genarrative-api.service 0644
rm -f "${spacetimedb_service}" "${api_service}"
if [[ ! -f "${API_ENV_FILE}" ]]; then
echo "+ create ${API_ENV_FILE} from example"
if [[ "${DRY_RUN}" != "true" ]]; then
render_api_env_example >"${API_ENV_FILE}"
chmod 0600 "${API_ENV_FILE}"
chown root:root "${API_ENV_FILE}"
fi
else
echo "[server-provision] 已存在环境文件,保留不覆盖: ${API_ENV_FILE}"
fi
if [[ "${NGINX_CONFIG_MODE}" != "none" ]]; then
install_nginx_config_with_rollback
else
cleanup_placeholder_nginx_config
fi
run_cmd systemctl daemon-reload
if [[ "${ENABLE_SERVICES}" == "true" ]]; then
run_cmd systemctl enable spacetimedb.service genarrative-api.service
run_cmd systemctl restart spacetimedb.service
if [[ -x "${CURRENT_LINK}/api-server" ]]; then
run_cmd systemctl restart genarrative-api.service
else
echo "[server-provision] 尚未发现 ${CURRENT_LINK}/api-server跳过 api-server 首次启动。后续 API deploy 会重启服务。"
fi
fi
echo "[server-provision] 完成。若是首次初始化,请补齐 ${API_ENV_FILE} 的真实密钥后再启动 api-server。"
'
'''
}
}
}
post {
always {
script {
def notificationParameters = [
string(name: 'SOURCE_JOB_NAME', value: env.JOB_NAME),
string(name: 'SOURCE_BUILD_NUMBER', value: env.BUILD_NUMBER),
string(name: 'SOURCE_BUILD_URL', value: env.BUILD_URL ?: ''),
string(name: 'SOURCE_RESULT', value: currentBuild.currentResult ?: 'UNKNOWN'),
string(name: 'SOURCE_BRANCH', value: params.SOURCE_BRANCH ?: ''),
string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT ?: (params.COMMIT_HASH ?: '')),
string(name: 'BUILD_VERSION', value: env.EFFECTIVE_BUILD_VERSION ?: (params.BUILD_VERSION ?: '')),
string(name: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''),
string(name: 'DATABASE', value: params.DATABASE ?: ''),
string(name: 'SUMMARY', value: '服务器初始化流水线结束'),
]
def notificationRecipients = params.NOTIFICATION_EMAILS?.trim()
if (notificationRecipients) {
notificationParameters.add(string(name: 'EMAIL_RECIPIENTS', value: notificationRecipients))
}
try {
build job: 'Genarrative-Notify-Email',
wait: false,
propagate: false,
parameters: notificationParameters
} catch (error) {
echo "邮件通知触发失败: ${error.message}"
}
}
}
success {
echo "Server provision 完成: target=${params.DEPLOY_TARGET}, dryRun=${params.DRY_RUN}, nginxConfigMode=${params.NGINX_CONFIG_MODE}"
}
}
}