445 lines
20 KiB
Plaintext
445 lines
20 KiB
Plaintext
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-http,release 正式入口选 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}"
|
||
}
|
||
}
|
||
}
|