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;当前 Linux 开发/构建/开发部署 agent 不可冒充 release 部署机') string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '导出脚本来源分支') string(name: 'COMMIT_HASH', defaultValue: '', description: '导出脚本来源 commit') string(name: 'NOTIFICATION_EMAILS', defaultValue: '', description: '本次运行追加通知邮箱;会与 Jenkins Secret Text 凭据 genarrative-notification-emails 合并发送') string(name: 'DATABASE', defaultValue: 'genarrative-prod', description: 'SpacetimeDB database') string(name: 'SPACETIME_SERVER', defaultValue: 'local', description: 'SpacetimeDB server alias') string(name: 'SPACETIME_SERVER_URL', defaultValue: 'http://127.0.0.1:3101', description: '显式 SpacetimeDB server URL,填写后优先于 SPACETIME_SERVER') string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'spacetime CLI root-dir;release 自托管默认 /stdb') string(name: 'INCLUDE_TABLES', defaultValue: '', description: '可选,逗号分隔的表名白名单') string(name: 'WORKSPACE_EXPORT_DIRECTORY', defaultValue: 'database-exports', description: 'Jenkins workspace 内的导出目录,用于归档') string(name: 'SERVER_BACKUP_DIRECTORY', defaultValue: '/var/lib/genarrative/database-exports', description: '可选,额外保存在目标机器上的备份目录;留空则不保存服务器副本') string(name: 'EXPORT_NAME', defaultValue: '', description: '导出文件名,留空则使用 spacetime-migration-.json') string(name: 'TOKEN_CREDENTIAL_ID', defaultValue: '', description: '可选,SpacetimeDB 客户端连接 token 的 Jenkins Secret Text 凭据 ID') string(name: 'BOOTSTRAP_SECRET_CREDENTIAL_ID', defaultValue: '', description: '可选,迁移 bootstrap secret 的 Jenkins Secret Text 凭据 ID') } stages { stage('Prepare') { agent { label 'linux && genarrative-build' } steps { script { if (params.DEPLOY_TARGET == 'release' && !params.CONFIRM_RELEASE_DEPLOY_AGENT) { error('release 数据库导出需要先配置独立 release 部署 agent,并勾选 CONFIRM_RELEASE_DEPLOY_AGENT。') } if (!params.DATABASE?.trim()) { error('DATABASE 不能为空。') } if (!(params.DATABASE.trim() ==~ /^[a-z0-9]+(-[a-z0-9]+)*$/)) { error("DATABASE 必须匹配 ^[a-z0-9]+(-[a-z0-9]+)*$: ${params.DATABASE}") } def spacetimeRootDir = params.SPACETIME_ROOT_DIR?.trim() ? params.SPACETIME_ROOT_DIR.trim() : '/stdb' if (!spacetimeRootDir.startsWith('/') || spacetimeRootDir.contains('..')) { error("SPACETIME_ROOT_DIR 必须是 Linux 绝对路径且不能包含 ..: ${spacetimeRootDir}") } def serverBackupDirectory = params.SERVER_BACKUP_DIRECTORY?.trim() if (serverBackupDirectory && (!serverBackupDirectory.startsWith('/') || serverBackupDirectory.contains('..'))) { error("SERVER_BACKUP_DIRECTORY 必须是 Linux 绝对路径且不能包含 ..: ${serverBackupDirectory}") } def exportDirectory = params.WORKSPACE_EXPORT_DIRECTORY?.trim() ? params.WORKSPACE_EXPORT_DIRECTORY.trim() : 'database-exports' if (exportDirectory.startsWith('/') || exportDirectory.contains('..') || !(exportDirectory ==~ /^[A-Za-z0-9._\/-]+$/)) { error("WORKSPACE_EXPORT_DIRECTORY 必须是安全的相对路径: ${exportDirectory}") } def exportName = params.EXPORT_NAME?.trim() if (!exportName) { exportName = "spacetime-migration-${env.BUILD_NUMBER}.json" } if (!(exportName ==~ /^[A-Za-z0-9._-]+$/)) { error("EXPORT_NAME 只能包含字母、数字、点、下划线和短横线: ${exportName}") } env.WORKSPACE_EXPORT_DIRECTORY = exportDirectory env.EFFECTIVE_EXPORT_NAME = exportName env.EFFECTIVE_SPACETIME_ROOT_DIR = spacetimeRootDir env.EFFECTIVE_SERVER_BACKUP_DIRECTORY = serverBackupDirectory ?: '' } } } stage('Export Database') { 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 ' ''' script { def credentialBindings = [] if (params.TOKEN_CREDENTIAL_ID?.trim()) { credentialBindings.add(string(credentialsId: params.TOKEN_CREDENTIAL_ID.trim(), variable: 'GENARRATIVE_SPACETIME_TOKEN')) } if (params.BOOTSTRAP_SECRET_CREDENTIAL_ID?.trim()) { credentialBindings.add(string(credentialsId: params.BOOTSTRAP_SECRET_CREDENTIAL_ID.trim(), variable: 'GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET')) } def exportStep = { sh ''' bash -lc ' set -euo pipefail chmod +x scripts/deploy/maintenance-on.sh scripts/deploy/maintenance-off.sh export_dir="${WORKSPACE_EXPORT_DIRECTORY}" output_path="${export_dir}/${EFFECTIVE_EXPORT_NAME}" mkdir -p "${export_dir}" maintenance_entered=0 on_exit() { local exit_code=$? if [[ "${exit_code}" -ne 0 && "${maintenance_entered}" -eq 1 ]]; then echo "[database-export] 导出失败,保持维护模式。" >&2 elif [[ "${exit_code}" -ne 0 ]]; then echo "[database-export] 导出准备失败,尚未进入维护模式。" >&2 fi exit "${exit_code}" } trap on_exit EXIT scripts/deploy/maintenance-on.sh "database export ${DATABASE}" maintenance_entered=1 args=(scripts/spacetime-export-migration-json.mjs --out "${output_path}" --database "${DATABASE}") if [[ -n "${SPACETIME_SERVER_URL}" ]]; then args+=(--server-url "${SPACETIME_SERVER_URL}") elif [[ -n "${SPACETIME_SERVER}" ]]; then args+=(--server "${SPACETIME_SERVER}") fi if [[ -n "${EFFECTIVE_SPACETIME_ROOT_DIR}" ]]; then args+=(--root-dir "${EFFECTIVE_SPACETIME_ROOT_DIR}") fi if [[ -n "${INCLUDE_TABLES}" ]]; then args+=(--include "${INCLUDE_TABLES}") fi args+=(--note "jenkins database export ${BUILD_TAG}") node "${args[@]}" test -s "${output_path}" sha256sum "${output_path}" >"${output_path}.sha256" if [[ -n "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}" ]]; then mkdir -p "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}" install -m 0640 "${output_path}" "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_EXPORT_NAME}" install -m 0640 "${output_path}.sha256" "${EFFECTIVE_SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_EXPORT_NAME}.sha256" fi echo "[database-export] 完成: ${output_path}, source_commit=$(cat .jenkins-source-commit)" ' ''' } boolean archiveSucceeded = false try { if (credentialBindings) { withCredentials(credentialBindings) { exportStep() } } else { exportStep() } archiveArtifacts artifacts: "${env.WORKSPACE_EXPORT_DIRECTORY}/${env.EFFECTIVE_EXPORT_NAME},${env.WORKSPACE_EXPORT_DIRECTORY}/${env.EFFECTIVE_EXPORT_NAME}.sha256", fingerprint: true archiveSucceeded = true } finally { if (archiveSucceeded) { // 先确认导出和归档都已完成,再退出维护模式,避免归档异常把站点留在维护页。 sh ''' bash -lc ' set -euo pipefail scripts/deploy/maintenance-off.sh ' ''' } } } } } } 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 "数据库导出完成: target=${params.DEPLOY_TARGET}, database=${params.DATABASE}, file=${env.EFFECTIVE_EXPORT_NAME}" } } }