pipeline { agent none options { disableConcurrentBuilds() skipDefaultCheckout(true) buildDiscarder(logRotator(numToKeepStr: '20', artifactNumToKeepStr: '20')) } parameters { choice(name: 'DEPLOY_TARGET', choices: ['development', 'release'], description: '逻辑部署目标;development 使用 dev 服务器部署 agent,release 使用正式服务器部署 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_GIT_REMOTE_URL', defaultValue: '', description: '部署脚本 Git 来源;必须是目标 agent 可访问的内网/本机 Gitea 地址,不配置公网备用') 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: 'SERVER_ALIASES', defaultValue: '', description: '可选,额外 Nginx server_name,多个用空格或逗号分隔,例如 www.genarrative.world') string(name: 'PROVISION_DOWNLOADS_DIR', defaultValue: 'provision-tool-downloads', description: '目标服务器工作区内暂存 SpacetimeDB/otelcol 安装包的相对目录') string(name: 'PROVISION_TOOLS_DIR', defaultValue: 'provision-tools', description: '目标机工作区内由已下载安装包生成的工具包目录') string(name: 'PROVISION_DOWNLOAD_PROXY', defaultValue: '', description: '可选,目标服务器下载 SpacetimeDB 和 otelcol-contrib 时使用的代理地址,例如 http://127.0.0.1:7890;留空不设置代理') string(name: 'SPACETIME_DOWNLOAD_ROOT', defaultValue: 'https://github.com/clockworklabs/SpacetimeDB/releases/latest/download', description: '目标服务器使用的 SpacetimeDB Linux release tarball 根地址') string(name: 'SPACETIME_TARGET_HOST', defaultValue: 'x86_64-unknown-linux-gnu', description: 'SpacetimeDB 预编译包 host triple,development/release Linux amd64 使用默认值') 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 服务') booleanParam(name: 'ENABLE_OTELCOL', defaultValue: true, description: '安装并启用本机 OpenTelemetry Collector;api-server 模板默认开启 OTLP,如需关闭请在 API_ENV_FILE 中将 GENARRATIVE_OTEL_ENABLED 改为 false') string(name: 'OTELCOL_VERSION', defaultValue: '0.151.0', description: 'otelcol-contrib 版本') } stages { stage('Provision Target') { agent { label "${params.DEPLOY_TARGET == 'development' ? 'linux && genarrative-dev-deploy' : 'linux && genarrative-release-deploy'}" } stages { stage('Prepare') { 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 不能为空。') } def sourceGitRemoteUrl = params.SOURCE_GIT_REMOTE_URL?.trim() if (!sourceGitRemoteUrl) { error('SOURCE_GIT_REMOTE_URL 不能为空。') } def isLocalGitPath = sourceGitRemoteUrl ==~ /^\/[0-9A-Za-z._\/-]+$/ def isLocalGitFileUrl = sourceGitRemoteUrl ==~ /^file:\/\/\/\S+$/ def isPrivateHttpGitUrl = sourceGitRemoteUrl ==~ /^https?:\/\/(localhost|127(?:\.[0-9]{1,3}){3}|10(?:\.[0-9]{1,3}){3}|192\.168(?:\.[0-9]{1,3}){2}|172\.(?:1[6-9]|2[0-9]|3[0-1])(?:\.[0-9]{1,3}){2}|[A-Za-z0-9-]+|[A-Za-z0-9.-]+\.(?:local|lan|internal))(?::[0-9]+)?\/\S+$/ if (!isLocalGitPath && !isLocalGitFileUrl && !isPrivateHttpGitUrl) { error('Genarrative-Server-Provision 不允许使用公网 Git 仓库;SOURCE_GIT_REMOTE_URL 只能是目标 agent 可访问的本机路径、file:/// 地址、localhost/127.0.0.1、RFC1918 内网 HTTP 地址、单标签内网主机名或 .local/.lan/.internal 地址。') } env.EFFECTIVE_GIT_REMOTE_URL = sourceGitRemoteUrl if (!(params.SERVER_NAME.trim() ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) { error("SERVER_NAME 只能填写单个域名或 IP,不能包含空格、路径或协议: ${params.SERVER_NAME}") } def serverAliases = params.SERVER_ALIASES?.trim() if (serverAliases) { serverAliases.split(/[,\s]+/).findAll { it }.each { aliasName -> if (!(aliasName ==~ /^[A-Za-z0-9][A-Za-z0-9.-]*$/)) { error("SERVER_ALIASES 只能填写域名或 IP,多个用空格或逗号分隔: ${aliasName}") } } } if (!params.PROVISION_TOOLS_DIR?.trim()) { error('PROVISION_TOOLS_DIR 不能为空。') } if (!(params.PROVISION_TOOLS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_TOOLS_DIR.startsWith('/') || params.PROVISION_TOOLS_DIR.contains('..') || params.PROVISION_TOOLS_DIR.trim() == '.') { error("PROVISION_TOOLS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_TOOLS_DIR}") } if (!params.PROVISION_DOWNLOADS_DIR?.trim()) { error('PROVISION_DOWNLOADS_DIR 不能为空。') } if (!(params.PROVISION_DOWNLOADS_DIR.trim() ==~ /^[0-9A-Za-z._\/-]+$/) || params.PROVISION_DOWNLOADS_DIR.startsWith('/') || params.PROVISION_DOWNLOADS_DIR.contains('..') || params.PROVISION_DOWNLOADS_DIR.trim() == '.') { error("PROVISION_DOWNLOADS_DIR 只能是工作区内的相对目录,不能包含绝对路径或连续点号: ${params.PROVISION_DOWNLOADS_DIR}") } def provisionToolsDir = params.PROVISION_TOOLS_DIR.trim() def provisionDownloadsDir = params.PROVISION_DOWNLOADS_DIR.trim() if (provisionToolsDir == provisionDownloadsDir || provisionDownloadsDir.startsWith("${provisionToolsDir}/")) { error("PROVISION_DOWNLOADS_DIR 不能等于或位于 PROVISION_TOOLS_DIR 内,否则目标机生成工具包时会删除下载缓存: ${provisionDownloadsDir}") } def provisionDownloadProxy = params.PROVISION_DOWNLOAD_PROXY?.trim() if (provisionDownloadProxy && !(provisionDownloadProxy ==~ /^https?:\/\/\S+$/)) { error("PROVISION_DOWNLOAD_PROXY 只能填写 http:// 或 https:// 开头的代理地址,当前值: ${params.PROVISION_DOWNLOAD_PROXY}") } if (!(params.OTELCOL_VERSION?.trim() ==~ /^[0-9]+\.[0-9]+\.[0-9]+$/)) { error("OTELCOL_VERSION 格式应为 x.y.z: ${params.OTELCOL_VERSION}") } if (!(params.SPACETIME_DOWNLOAD_ROOT?.trim() ==~ /^https?:\/\/\S+$/)) { error('SPACETIME_DOWNLOAD_ROOT 不能为空。') } if (!(params.SPACETIME_TARGET_HOST?.trim() ==~ /^[0-9A-Za-z._-]+$/)) { error("SPACETIME_TARGET_HOST 只能包含字母、数字、点号、下划线和短横线: ${params.SPACETIME_TARGET_HOST}") } 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') { steps { script { checkout([ $class: 'GitSCM', branches: [[name: "*/${params.SOURCE_BRANCH}"]], doGenerateSubmoduleConfigurations: false, extensions: [ [$class: 'CleanBeforeCheckout'], [$class: 'CloneOption', shallow: true, depth: 1, noTags: true, timeout: 30, honorRefspec: true], ], userRemoteConfigs: [[url: env.EFFECTIVE_GIT_REMOTE_URL, refspec: "+refs/heads/${params.SOURCE_BRANCH}:refs/remotes/origin/${params.SOURCE_BRANCH}"]], ]) } sh ''' bash <<'BASH' set -euo pipefail chmod +x scripts/jenkins-checkout-source.sh SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ COMMIT_HASH="${COMMIT_HASH:-${SOURCE_COMMIT:-}}" \ GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh BASH ''' script { env.SOURCE_COMMIT = readFile('.jenkins-source-commit').trim() echo "Provision 源码 commit=${env.SOURCE_COMMIT}" } } } stage('Prepare Provision Tools') { steps { sh ''' bash -lc ' set -euo pipefail chmod +x scripts/prepare-server-provision-tools.sh PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \ PROVISION_DOWNLOADS_DIR="${PROVISION_DOWNLOADS_DIR:-provision-tool-downloads}" \ OTELCOL_VERSION="${OTELCOL_VERSION:-0.151.0}" \ PREPARE_OTELCOL="${ENABLE_OTELCOL:-true}" \ PROVISION_DOWNLOAD_PROXY="${PROVISION_DOWNLOAD_PROXY:-}" \ SPACETIME_DOWNLOAD_ROOT="${SPACETIME_DOWNLOAD_ROOT:-https://github.com/clockworklabs/SpacetimeDB/releases/latest/download}" \ SPACETIME_TARGET_HOST="${SPACETIME_TARGET_HOST:-x86_64-unknown-linux-gnu}" \ scripts/prepare-server-provision-tools.sh ' ''' } } stage('Provision Server') { steps { sh ''' bash <<'BASH' set -euo pipefail if [[ "${ENABLE_OTELCOL:-true}" == "true" ]]; then chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib" fi chmod +x "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \ "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-cli" \ "${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/bin/current/spacetimedb-standalone" chmod +x scripts/jenkins-server-provision.sh PROVISION_TOOLS_DIR="${PROVISION_TOOLS_DIR:-provision-tools}" \ SPACETIME_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/spacetime/spacetime" \ OTELCOL_BIN_SOURCE="${PROVISION_TOOLS_DIR:-provision-tools}/otelcol-contrib" \ scripts/jenkins-server-provision.sh BASH ''' } } } } } 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: 'DEPLOY_TARGET', value: params.DEPLOY_TARGET ?: ''), 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}" } } }