Files
Genarrative/jenkins/Jenkinsfile.production-database-import

316 lines
17 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()
timestamps()
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: 'DATABASE', defaultValue: 'genarrative-prod', description: 'SpacetimeDB database')
string(name: 'SPACETIME_SERVER', defaultValue: 'local', description: 'SpacetimeDB server alias')
string(name: 'SPACETIME_SERVER_URL', defaultValue: '', description: '显式 SpacetimeDB server URL填写后优先于 SPACETIME_SERVER')
string(name: 'SPACETIME_ROOT_DIR', defaultValue: '/stdb', description: 'spacetime CLI root-dirrelease 自托管默认 /stdb')
choice(name: 'INPUT_SOURCE', choices: ['pipeline_archive', 'manual_upload'], description: '导入数据源pipeline_archive 从导出流水线归档获取manual_upload 使用本次构建手动上传文件')
string(name: 'INPUT_FILE', defaultValue: '', description: 'pipeline_archive 模式可选;留空时使用导出流水线默认归档路径 database-exports/spacetime-migration-<导出构建号>.json')
string(name: 'EXPORT_JOB_NAME', defaultValue: 'Genarrative-Database-Export', description: 'pipeline_archive 模式使用的数据库导出流水线作业名')
string(name: 'EXPORT_BUILD_NUMBER_TO_IMPORT', defaultValue: '', description: 'pipeline_archive 模式必填,要复制 INPUT_FILE 的导出构建号')
stashedFile 'MANUAL_INPUT_FILE'
string(name: 'INCLUDE_TABLES', defaultValue: '', description: '可选,逗号分隔的表名白名单')
string(name: 'CHUNK_SIZE', defaultValue: '524288', description: '迁移 JSON 分片大小,默认 512KiB用于规避 HTTP 413')
booleanParam(name: 'DRY_RUN', defaultValue: true, description: '只校验导入,不写入数据')
booleanParam(name: 'INCREMENTAL', defaultValue: true, description: '增量导入,跳过已存在或冲突的行')
booleanParam(name: 'REPLACE_EXISTING', defaultValue: false, description: '覆盖本次文件内涉及的表,不可与 INCREMENTAL 同时启用')
booleanParam(name: 'CONFIRM_IMPORT', defaultValue: false, description: 'DRY_RUN=false 时必须勾选')
string(name: 'CONFIRM_DATABASE', defaultValue: '', description: 'DRY_RUN=false 时必须填写与 DATABASE 完全一致')
string(name: 'CONFIRM_INPUT_FILE', defaultValue: '', description: 'DRY_RUN=false 时必须确认输入文件pipeline_archive 填实际归档输入路径manual_upload 填上传原始文件名')
booleanParam(name: 'CONFIRM_REPLACE_EXISTING', defaultValue: false, description: 'REPLACE_EXISTING=true 且 DRY_RUN=false 时必须勾选')
string(name: 'PRE_IMPORT_BACKUP_DIRECTORY', defaultValue: 'database-pre-import-backups', description: 'Jenkins workspace 内的导入前备份目录,用于归档')
string(name: 'SERVER_BACKUP_DIRECTORY', defaultValue: '/var/lib/genarrative/database-backups', description: '可选,额外保存在目标机器上的导入前备份目录;留空则不保存服务器副本')
booleanParam(name: 'RUN_SMOKE_TEST', defaultValue: true, description: '导入成功后是否执行服务健康检查')
string(name: 'SMOKE_HEALTH_URL', defaultValue: 'http://127.0.0.1:8082/healthz', description: '目标机器本机健康检查地址')
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 inputSource = params.INPUT_SOURCE?.trim()
if (!(inputSource in ['pipeline_archive', 'manual_upload'])) {
error("INPUT_SOURCE 只能是 pipeline_archive 或 manual_upload当前值: ${params.INPUT_SOURCE}")
}
def manualInputFilename = env.MANUAL_INPUT_FILE_FILENAME?.trim()
if (inputSource == 'pipeline_archive') {
if (!params.EXPORT_JOB_NAME?.trim()) {
error('INPUT_SOURCE=pipeline_archive 时 EXPORT_JOB_NAME 不能为空。')
}
if (!params.EXPORT_BUILD_NUMBER_TO_IMPORT?.trim()) {
error('INPUT_SOURCE=pipeline_archive 时 EXPORT_BUILD_NUMBER_TO_IMPORT 不能为空。')
}
if (!(params.EXPORT_BUILD_NUMBER_TO_IMPORT.trim() ==~ /^[1-9][0-9]*$/)) {
error("INPUT_SOURCE=pipeline_archive 时 EXPORT_BUILD_NUMBER_TO_IMPORT 必须是导出流水线构建号: ${params.EXPORT_BUILD_NUMBER_TO_IMPORT}")
}
def pipelineInputFile = params.INPUT_FILE?.trim()
if (!pipelineInputFile) {
pipelineInputFile = "database-exports/spacetime-migration-${params.EXPORT_BUILD_NUMBER_TO_IMPORT.trim()}.json"
}
if (pipelineInputFile.startsWith('/')) {
error('INPUT_SOURCE=pipeline_archive 时 INPUT_FILE 必须是 Jenkins 归档内的 workspace 相对路径。')
}
if (pipelineInputFile.contains('..') || !(pipelineInputFile ==~ /^[A-Za-z0-9._\/-]+$/)) {
error("INPUT_SOURCE=pipeline_archive 时 INPUT_FILE 必须是安全的归档相对路径: ${pipelineInputFile}")
}
if (manualInputFilename) {
error('INPUT_SOURCE=pipeline_archive 时不能同时上传 MANUAL_INPUT_FILE。')
}
env.EFFECTIVE_PIPELINE_ARCHIVE_INPUT_FILE = pipelineInputFile
} else {
if (!manualInputFilename) {
error('INPUT_SOURCE=manual_upload 时必须上传 MANUAL_INPUT_FILE。')
}
if (params.EXPORT_BUILD_NUMBER_TO_IMPORT?.trim()) {
error('INPUT_SOURCE=manual_upload 时不能填写 EXPORT_BUILD_NUMBER_TO_IMPORT。')
}
if (params.INPUT_FILE?.trim()) {
error('INPUT_SOURCE=manual_upload 时不能填写 INPUT_FILE请使用 MANUAL_INPUT_FILE 上传数据源。')
}
}
if (params.INCREMENTAL && params.REPLACE_EXISTING) {
error('INCREMENTAL 不能和 REPLACE_EXISTING 同时启用。')
}
if (!params.DRY_RUN) {
if (!params.CONFIRM_IMPORT) {
error('DRY_RUN=false 时必须勾选 CONFIRM_IMPORT。')
}
if (params.CONFIRM_DATABASE?.trim() != params.DATABASE.trim()) {
error('DRY_RUN=false 时 CONFIRM_DATABASE 必须与 DATABASE 完全一致。')
}
if (inputSource == 'pipeline_archive' && params.CONFIRM_INPUT_FILE?.trim() != env.EFFECTIVE_PIPELINE_ARCHIVE_INPUT_FILE) {
error('DRY_RUN=false 时 CONFIRM_INPUT_FILE 必须与实际归档输入路径完全一致。')
}
if (inputSource == 'manual_upload' && !params.CONFIRM_INPUT_FILE?.trim()) {
error('DRY_RUN=false 且 INPUT_SOURCE=manual_upload 时 CONFIRM_INPUT_FILE 必须填写上传文件原始文件名。')
}
if (inputSource == 'manual_upload' && params.CONFIRM_INPUT_FILE?.trim() != manualInputFilename) {
error('DRY_RUN=false 且 INPUT_SOURCE=manual_upload 时 CONFIRM_INPUT_FILE 必须与上传文件原始文件名完全一致。')
}
if (params.REPLACE_EXISTING && !params.CONFIRM_REPLACE_EXISTING) {
error('REPLACE_EXISTING=true 且 DRY_RUN=false 时必须勾选 CONFIRM_REPLACE_EXISTING。')
}
}
def backupDirectory = params.PRE_IMPORT_BACKUP_DIRECTORY?.trim() ? params.PRE_IMPORT_BACKUP_DIRECTORY.trim() : 'database-pre-import-backups'
if (backupDirectory.startsWith('/') || backupDirectory.contains('..') || !(backupDirectory ==~ /^[A-Za-z0-9._\/-]+$/)) {
error("PRE_IMPORT_BACKUP_DIRECTORY 必须是安全的相对路径: ${backupDirectory}")
}
env.PRE_IMPORT_BACKUP_DIRECTORY = backupDirectory
env.EFFECTIVE_PRE_IMPORT_BACKUP_NAME = "pre-import-${env.BUILD_NUMBER}.json"
}
}
}
stage('Import 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}" \
COMMIT_HASH="${COMMIT_HASH}" \
GIT_REMOTE_URL="${GIT_REMOTE_URL}" \
SOURCE_COMMIT_FILE=".jenkins-source-commit" \
scripts/jenkins-checkout-source.sh
'
'''
script {
if (params.INPUT_SOURCE == 'pipeline_archive') {
echo "[database-import] 使用归档数据源: job=${params.EXPORT_JOB_NAME}, build=${params.EXPORT_BUILD_NUMBER_TO_IMPORT}, file=${env.EFFECTIVE_PIPELINE_ARCHIVE_INPUT_FILE}"
copyArtifacts(
projectName: params.EXPORT_JOB_NAME,
selector: specific(params.EXPORT_BUILD_NUMBER_TO_IMPORT.trim()),
filter: "${env.EFFECTIVE_PIPELINE_ARCHIVE_INPUT_FILE},${env.EFFECTIVE_PIPELINE_ARCHIVE_INPUT_FILE}.sha256",
target: '.',
fingerprintArtifacts: true
)
env.EFFECTIVE_INPUT_FILE = env.EFFECTIVE_PIPELINE_ARCHIVE_INPUT_FILE
} else {
echo "[database-import] 使用手动上传数据源: original_filename=${env.MANUAL_INPUT_FILE_FILENAME}"
sh 'bash -lc "rm -rf manual-import-upload && mkdir -p manual-import-upload"'
dir('manual-import-upload') {
unstash 'MANUAL_INPUT_FILE'
}
env.EFFECTIVE_INPUT_FILE = 'manual-import-upload/MANUAL_INPUT_FILE'
if (!params.DRY_RUN) {
sh '''
bash -lc '
set -euo pipefail
manual_filename="${MANUAL_INPUT_FILE_FILENAME:-}"
if [[ -z "${manual_filename}" ]]; then
echo "[database-import] 无法读取 MANUAL_INPUT_FILE_FILENAME不能确认手动上传文件名。" >&2
exit 1
fi
if [[ "${CONFIRM_INPUT_FILE}" != "${manual_filename}" ]]; then
echo "[database-import] CONFIRM_INPUT_FILE 必须与手动上传文件原始文件名一致: ${manual_filename}" >&2
exit 1
fi
'
'''
}
}
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 importStep = {
sh '''
bash -lc '
set -euo pipefail
chmod +x scripts/deploy/maintenance-on.sh scripts/deploy/maintenance-off.sh
input_path="${EFFECTIVE_INPUT_FILE}"
if [[ "${input_path}" != /* ]]; then
input_path="${WORKSPACE}/${input_path}"
fi
if [[ ! -s "${input_path}" ]]; then
echo "[database-import] INPUT_FILE 不存在或为空: ${input_path}" >&2
exit 1
fi
backup_dir="${PRE_IMPORT_BACKUP_DIRECTORY}"
backup_path="${backup_dir}/${EFFECTIVE_PRE_IMPORT_BACKUP_NAME}"
mkdir -p "${backup_dir}"
completed=0
on_exit() {
local exit_code=$?
if [[ "${exit_code}" -ne 0 && "${completed}" -ne 1 ]]; then
echo "[database-import] 导入失败,保持维护模式。导入前备份如已生成,会保留在 ${backup_path}。" >&2
fi
exit "${exit_code}"
}
trap on_exit EXIT
scripts/deploy/maintenance-on.sh "database import ${DATABASE}"
backup_args=(scripts/spacetime-export-migration-json.mjs --out "${backup_path}" --database "${DATABASE}")
import_args=(scripts/spacetime-import-migration-json.mjs --in "${input_path}" --database "${DATABASE}")
for args_name in backup_args import_args; do
declare -n current_args="${args_name}"
# server-url 明确指向目标实例时,不再同时透传默认 alias避免 CLI 授权与 HTTP 导入落到不同目标。
if [[ -n "${SPACETIME_SERVER_URL}" ]]; then
current_args+=(--server-url "${SPACETIME_SERVER_URL}")
elif [[ -n "${SPACETIME_SERVER}" ]]; then
current_args+=(--server "${SPACETIME_SERVER}")
fi
if [[ -n "${SPACETIME_ROOT_DIR}" ]]; then
current_args+=(--root-dir "${SPACETIME_ROOT_DIR}")
fi
done
backup_args+=(--note "jenkins pre-import backup ${BUILD_TAG}")
node "${backup_args[@]}"
test -s "${backup_path}"
sha256sum "${backup_path}" >"${backup_path}.sha256"
if [[ -n "${SERVER_BACKUP_DIRECTORY}" ]]; then
mkdir -p "${SERVER_BACKUP_DIRECTORY}"
install -m 0640 "${backup_path}" "${SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_PRE_IMPORT_BACKUP_NAME}"
install -m 0640 "${backup_path}.sha256" "${SERVER_BACKUP_DIRECTORY}/${EFFECTIVE_PRE_IMPORT_BACKUP_NAME}.sha256"
fi
if [[ -n "${INCLUDE_TABLES}" ]]; then
import_args+=(--include "${INCLUDE_TABLES}")
fi
if [[ -n "${CHUNK_SIZE}" ]]; then
import_args+=(--chunk-size "${CHUNK_SIZE}")
fi
if [[ "${DRY_RUN}" == "true" ]]; then
import_args+=(--dry-run)
fi
if [[ "${INCREMENTAL}" == "true" ]]; then
import_args+=(--incremental)
fi
if [[ "${REPLACE_EXISTING}" == "true" ]]; then
import_args+=(--replace-existing)
fi
import_args+=(--note "jenkins database import ${BUILD_TAG}")
node "${import_args[@]}"
# 导入成功后只做本机健康检查;业务级数据核验仍以迁移脚本的表级统计为准。
if [[ "${RUN_SMOKE_TEST}" == "true" && -n "${SMOKE_HEALTH_URL}" ]]; then
curl -fsS --max-time 10 "${SMOKE_HEALTH_URL}" >/dev/null
fi
scripts/deploy/maintenance-off.sh
completed=1
echo "[database-import] 完成: dry_run=${DRY_RUN}, database=${DATABASE}, source_commit=$(cat .jenkins-source-commit)"
'
'''
}
if (credentialBindings) {
withCredentials(credentialBindings) {
importStep()
}
} else {
importStep()
}
}
}
post {
always {
archiveArtifacts artifacts: "${env.PRE_IMPORT_BACKUP_DIRECTORY}/${env.EFFECTIVE_PRE_IMPORT_BACKUP_NAME},${env.PRE_IMPORT_BACKUP_DIRECTORY}/${env.EFFECTIVE_PRE_IMPORT_BACKUP_NAME}.sha256", allowEmptyArchive: true, fingerprint: true
}
}
}
}
post {
success {
echo "数据库导入流水线完成: target=${params.DEPLOY_TARGET}, database=${params.DATABASE}, dryRun=${params.DRY_RUN}"
}
}
}