From a92dc2b7b048b10a0b826e72ab9bab440ac463dd Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 13 May 2026 16:07:54 +0800 Subject: [PATCH] fix(jenkins): add git fallback and nginx aliases --- .hermes/shared-memory/pitfalls.md | 8 +++ .../PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md | 13 ++--- jenkins/Jenkinsfile.production-api-deploy | 30 ++++++++--- .../Jenkinsfile.production-database-export | 30 ++++++++--- .../Jenkinsfile.production-database-import | 30 ++++++++--- .../Jenkinsfile.production-server-provision | 44 +++++++++++---- ...Jenkinsfile.production-stdb-module-publish | 30 ++++++++--- jenkins/Jenkinsfile.production-web-deploy | 30 ++++++++--- scripts/jenkins-checkout-source.sh | 53 +++++++++++++++++-- scripts/jenkins-server-provision.sh | 33 +++++++++++- 10 files changed, 239 insertions(+), 62 deletions(-) diff --git a/.hermes/shared-memory/pitfalls.md b/.hermes/shared-memory/pitfalls.md index cbd9c0b1..0c11427d 100644 --- a/.hermes/shared-memory/pitfalls.md +++ b/.hermes/shared-memory/pitfalls.md @@ -438,6 +438,14 @@ - 验证:发布链路使用当前 `deploy/systemd`、`deploy/nginx`、`scripts/deploy` 和 `jenkins/Jenkinsfile.production-*`。 - 关联:`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 +## Jenkins 生产流水线拉 Git 先本机再内网备用 + +- 现象:生产发布、数据库导入导出或服务器配置流水线在目标 Linux agent 上执行 `GitSCM checkout` 时,`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git` 不可达,导致脚本还没拉下来就失败。 +- 原因:`127.0.0.1` 只代表当前执行阶段的 agent 自身;当 release agent 与 Git 服务不在同一台机器,或本机 Git/Web 服务临时不可用时,固定写死 localhost 会阻断 Jenkinsfile 内部源码/脚本 checkout。 +- 处理:需要在运行于内网 Linux agent 的 Jenkinsfile 首次 `checkout([$class: 'GitSCM', ...])` 层先尝试 `GIT_REMOTE_URL=http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`,失败后再尝试 `GIT_REMOTE_FALLBACK_URL=http://10.2.0.10:3000/GenarrativeAI/Genarrative.git`;后续统一走 `scripts/jenkins-checkout-source.sh`,该脚本也按主地址、备用地址顺序重新 fetch 并把 `origin` 切到实际可用地址。`10.2.0.10` 是内网地址,Windows controller 或 Windows 构建节点不要接入这个 fallback。 +- 验证:扫描所有以 `127.0.0.1:3000` 拉 Git 且运行在 Linux agent 的生产 Jenkinsfile,确认存在 `GIT_REMOTE_FALLBACK_URL`、`EFFECTIVE_GIT_REMOTE_URL` 和脚本层 `GIT_REMOTE_FALLBACK_URL` 透传;运行 `bash -n scripts/jenkins-checkout-source.sh`。 +- 关联:`jenkins/Jenkinsfile.production-web-deploy`、`jenkins/Jenkinsfile.production-api-deploy`、`jenkins/Jenkinsfile.production-stdb-module-publish`、`jenkins/Jenkinsfile.production-server-provision`、`jenkins/Jenkinsfile.production-database-export`、`jenkins/Jenkinsfile.production-database-import`、`scripts/jenkins-checkout-source.sh`、`docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md`。 + ## Jenkins 可选参数在 set -u 下不能裸读 - 现象:数据库导入或导出流水线报 `INCLUDE_TABLES: unbound variable`,或其它可选参数在 Bash 中未定义即退出。 diff --git a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md index a1375337..ca5e6ed3 100644 --- a/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md +++ b/docs/technical/PRODUCTION_DEPLOYMENT_PLAN_2026-05-02.md @@ -132,8 +132,8 @@ SpacetimeDB 公网路由默认保持收敛,只按实际前端 SDK 需要暴露 Nginx 配置文件分为两类: -- `deploy/nginx/genarrative.conf`:生产正式域名 HTTPS 配置,`genarrative.example.com` 只是占位域名,安装时必须替换为真实 `SERVER_NAME`,并要求 `/etc/letsencrypt/live//fullchain.pem` 与 `privkey.pem` 已存在。 -- `deploy/nginx/genarrative-dev-http.conf`:开发服无域名时的 HTTP-only 配置,只允许 `DEPLOY_TARGET=development` 使用。没有域名时,`SERVER_NAME` 填开发机 IP 或临时主机名。它仍复用同一套静态目录、后台 API 反代、临时主站 `/api/*` 反代和 SpacetimeDB SDK 最小公网路由,不恢复旧 `/generated-*` 或公网 `/healthz`。 +- `deploy/nginx/genarrative.conf`:生产正式域名 HTTPS 配置,`genarrative.example.com` 只是占位域名,安装时必须替换为真实 `SERVER_NAME`,并要求 `/etc/letsencrypt/live//fullchain.pem` 与 `privkey.pem` 已存在。`SERVER_NAME` 只填证书主目录名对应的单个域名;`www` 等额外域名通过 `SERVER_ALIASES` 写入 Nginx `server_name`,不参与证书目录拼接。 +- `deploy/nginx/genarrative-dev-http.conf`:开发服无域名时的 HTTP-only 配置,只允许 `DEPLOY_TARGET=development` 使用。没有域名时,`SERVER_NAME` 填开发机 IP 或临时主机名;如有多个入口,额外域名或 IP 填 `SERVER_ALIASES`。它仍复用同一套静态目录、后台 API 反代、临时主站 `/api/*` 反代和 SpacetimeDB SDK 最小公网路由,不恢复旧 `/generated-*` 或公网 `/healthz`。 ## 维护模式 @@ -273,12 +273,13 @@ journalctl -u 'jenkins-agent@*.service' -f Jenkins controller 与 Linux agent 看到的 Git 服务地址不同,必须拆成两层配置: - Jenkins Job 的 `Pipeline script from SCM` 由 controller 执行,SCM URL 使用 controller 可访问的公网地址:`http://82.157.175.59:3000/GenarrativeAI/Genarrative.git`。 -- Jenkinsfile 内部的源码、脚本 checkout 在 Linux agent 上执行,`GIT_REMOTE_URL` 使用 agent 本机可访问地址:`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`。 +- Jenkinsfile 内部的源码、脚本 checkout 在 Linux agent 上执行,`GIT_REMOTE_URL` 优先使用 agent 本机可访问地址:`http://127.0.0.1:3000/GenarrativeAI/Genarrative.git`。 +- 若 `127.0.0.1` Git 服务在当前 Linux agent 上不可达,发布、数据库和服务器配置类 Jenkinsfile 会用 `GIT_REMOTE_FALLBACK_URL=http://10.2.0.10:3000/GenarrativeAI/Genarrative.git` 重新 checkout;`scripts/jenkins-checkout-source.sh` 后续 fetch 也会按主地址、备用地址顺序重试,并在日志中输出最终使用的远端。`10.2.0.10` 是内网地址,Windows controller 或 Windows 构建节点不使用该 fallback。 - 这里的 `3000` 是 Git/Web 服务端口,不是 SpacetimeDB 端口;生产 SpacetimeDB 固定使用 `http://127.0.0.1:3101`,避免流水线部署时与本机 Git 服务抢端口。 -因此生产 Jenkinsfile 不使用 `checkout scm` 作为构建源码入口,而是显式 `checkout([$class: 'GitSCM', userRemoteConfigs: [[url: "${GIT_REMOTE_URL}"]], ...])`。后续 `scripts/jenkins-checkout-source.sh` 会继续把 `origin` 设置为 `GIT_REMOTE_URL`,并按 `SOURCE_BRANCH` / `COMMIT_HASH` 拉取和校验目标提交。 +因此生产 Jenkinsfile 不使用 `checkout scm` 作为构建源码入口,而是显式 `checkout([$class: 'GitSCM', userRemoteConfigs: [[url: remoteUrl]], ...])`。首次 checkout 先尝试 `GIT_REMOTE_URL`,失败后尝试 `GIT_REMOTE_FALLBACK_URL`;后续 `scripts/jenkins-checkout-source.sh` 会继续把 `origin` 设置为实际可用远端,并按 `SOURCE_BRANCH` / `COMMIT_HASH` 拉取和校验目标提交。 -`127.0.0.1` 只代表当前执行该阶段的 Linux agent 自身;如果 release agent 与 Git 服务不在同一台机器,必须把对应 Jenkinsfile 的 `GIT_REMOTE_URL` 改成 release agent 可访问的内网地址,不能让 release 发布阶段回退到 controller 公网拉取。 +`127.0.0.1` 只代表当前执行该阶段的 Linux agent 自身;如果 release agent 与 Git 服务不在同一台机器,应优先确认 `10.2.0.10` 这类内网备用地址是否可达,并按实际网络拓扑更新对应 Jenkinsfile 的 `GIT_REMOTE_FALLBACK_URL`。release 发布阶段不能回退到 controller 公网拉取。 ### SSH PEM 凭证 @@ -462,7 +463,7 @@ WASM_SOURCE="${CARGO_TARGET_DIR}/wasm32-unknown-unknown/release/spacetime_module 该流水线属于高风险操作,默认要求人工确认后执行。 已落地的 Jenkinsfile 为 `jenkins/Jenkinsfile.production-server-provision`。该流水线默认 `DRY_RUN=true`,只打印将执行的初始化动作;真正写入系统用户、目录、systemd、环境文件并启动服务时,必须设置 `DRY_RUN=false` 且勾选 `CONFIRM_PROVISION`。当 `DEPLOY_TARGET=release` 时,还必须勾选 `CONFIRM_RELEASE_DEPLOY_AGENT`,并通过 `linux && genarrative-release-deploy` 调度到独立 release 部署 agent。 -首次真实初始化默认保持 `NGINX_CONFIG_MODE=none`,先完成系统用户、目录、SpacetimeDB、systemd unit 与 `/etc/genarrative/api-server.env` 落盘。开发服没有域名时,使用 `DEPLOY_TARGET=development` + `NGINX_CONFIG_MODE=development-http` 安装 `deploy/nginx/genarrative-dev-http.conf`,并把 `SERVER_NAME` 填为开发机 IP 或临时主机名。等正式域名确定,并且目标机已经存在 `/etc/letsencrypt/live//fullchain.pem` 与 `/etc/letsencrypt/live//privkey.pem` 后,再把 `SERVER_NAME` 改成真实域名,并设置 `NGINX_CONFIG_MODE=production-https` 安装 Nginx HTTPS 配置。流水线会拒绝 release 目标安装 `development-http`,也会拒绝用占位域名或缺失证书安装 `production-https`。Nginx 配置写入后必须先 `nginx -t`,再 `nginx -s reload`,不能只验证配置而不重载当前进程。 +首次真实初始化默认保持 `NGINX_CONFIG_MODE=none`,先完成系统用户、目录、SpacetimeDB、systemd unit 与 `/etc/genarrative/api-server.env` 落盘。开发服没有域名时,使用 `DEPLOY_TARGET=development` + `NGINX_CONFIG_MODE=development-http` 安装 `deploy/nginx/genarrative-dev-http.conf`,并把 `SERVER_NAME` 填为开发机 IP 或临时主机名。等正式域名确定,并且目标机已经存在 `/etc/letsencrypt/live//fullchain.pem` 与 `/etc/letsencrypt/live//privkey.pem` 后,再把 `SERVER_NAME` 改成证书主域名,并设置 `NGINX_CONFIG_MODE=production-https` 安装 Nginx HTTPS 配置。如果同一张证书同时覆盖根域名和 `www` 域名,`SERVER_NAME` 仍只填证书目录名,例如 `genarrative.world`,`SERVER_ALIASES` 填 `www.genarrative.world`。流水线会拒绝 release 目标安装 `development-http`,也会拒绝用占位域名或缺失证书安装 `production-https`。Nginx 配置写入后必须先 `nginx -t`,再 `nginx -s reload`,不能只验证配置而不重载当前进程。 若误用占位域名执行过真实初始化,失败通常发生在 `nginx -t`,错误表现为找不到 `/etc/letsencrypt/live/genarrative.example.com/fullchain.pem` 或 `privkey.pem`。新版初始化在 `NGINX_CONFIG_MODE=none` 时会检测并禁用上一轮留下的占位域名 Nginx 配置,避免它继续影响后续 `nginx -t`。 diff --git a/jenkins/Jenkinsfile.production-api-deploy b/jenkins/Jenkinsfile.production-api-deploy index ff48f52b..91449547 100644 --- a/jenkins/Jenkinsfile.production-api-deploy +++ b/jenkins/Jenkinsfile.production-api-deploy @@ -9,6 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' } parameters { @@ -66,13 +67,25 @@ pipeline { 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}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: remoteUrl]], + ]) + } + try { + checkoutFromRemote(env.GIT_REMOTE_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL + } catch (error) { + echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" + checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL + } + } script { if (params.COMMIT_HASH?.trim()) { echo "API 发布脚本 checkout 将忽略上游构建 commit=${params.COMMIT_HASH},改用 ${params.SOURCE_BRANCH ?: 'master'} 最新提交,避免发布阶段回退到旧部署脚本。构建产物仍由 BUILD_NUMBER_TO_DEPLOY 决定。" @@ -84,7 +97,8 @@ pipeline { chmod +x scripts/jenkins-checkout-source.sh SOURCE_BRANCH="${SOURCE_BRANCH:-master}" \ COMMIT_HASH="" \ - GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ + GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh ' diff --git a/jenkins/Jenkinsfile.production-database-export b/jenkins/Jenkinsfile.production-database-export index e19af792..c261e669 100644 --- a/jenkins/Jenkinsfile.production-database-export +++ b/jenkins/Jenkinsfile.production-database-export @@ -9,6 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' } parameters { @@ -82,20 +83,33 @@ pipeline { 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}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: remoteUrl]], + ]) + } + try { + checkoutFromRemote(env.GIT_REMOTE_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL + } catch (error) { + echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" + checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_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}" \ + GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ + GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh ' diff --git a/jenkins/Jenkinsfile.production-database-import b/jenkins/Jenkinsfile.production-database-import index 8013cddb..f30f61e8 100644 --- a/jenkins/Jenkinsfile.production-database-import +++ b/jenkins/Jenkinsfile.production-database-import @@ -9,6 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' } parameters { @@ -140,20 +141,33 @@ pipeline { 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}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: remoteUrl]], + ]) + } + try { + checkoutFromRemote(env.GIT_REMOTE_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL + } catch (error) { + echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" + checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_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}" \ + GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ + GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh ' diff --git a/jenkins/Jenkinsfile.production-server-provision b/jenkins/Jenkinsfile.production-server-provision index b809254f..157f4f0a 100644 --- a/jenkins/Jenkinsfile.production-server-provision +++ b/jenkins/Jenkinsfile.production-server-provision @@ -9,6 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' } parameters { @@ -19,7 +20,8 @@ pipeline { 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: 'SERVER_NAME', defaultValue: 'genarrative.example.com', description: '证书主域名;也作为 Nginx server_name 的第一个域名') + string(name: 'SERVER_ALIASES', defaultValue: '', description: '可选,额外 Nginx server_name,多个用空格或逗号分隔,例如 www.genarrative.world') 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 根目录') @@ -47,6 +49,17 @@ pipeline { if (!params.SERVER_NAME?.trim()) { error('SERVER_NAME 不能为空。') } + 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.SPACETIME_BIN_SOURCE?.trim()) { error('SPACETIME_BIN_SOURCE 不能为空。') } @@ -69,20 +82,33 @@ pipeline { 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}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: remoteUrl]], + ]) + } + try { + checkoutFromRemote(env.GIT_REMOTE_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL + } catch (error) { + echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" + checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_URL + } + } sh ''' bash <<'BASH' 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}" \ + GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ + GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh BASH diff --git a/jenkins/Jenkinsfile.production-stdb-module-publish b/jenkins/Jenkinsfile.production-stdb-module-publish index 5aee3862..2f178f1f 100644 --- a/jenkins/Jenkinsfile.production-stdb-module-publish +++ b/jenkins/Jenkinsfile.production-stdb-module-publish @@ -9,6 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' } parameters { @@ -78,20 +79,33 @@ pipeline { 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}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: remoteUrl]], + ]) + } + try { + checkoutFromRemote(env.GIT_REMOTE_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL + } catch (error) { + echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" + checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_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}" \ + GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ + GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh ' diff --git a/jenkins/Jenkinsfile.production-web-deploy b/jenkins/Jenkinsfile.production-web-deploy index d2350236..e6b5a563 100644 --- a/jenkins/Jenkinsfile.production-web-deploy +++ b/jenkins/Jenkinsfile.production-web-deploy @@ -9,6 +9,7 @@ pipeline { environment { GIT_REMOTE_URL = 'http://127.0.0.1:3000/GenarrativeAI/Genarrative.git' + GIT_REMOTE_FALLBACK_URL = 'http://10.2.0.10:3000/GenarrativeAI/Genarrative.git' WEB_ARTIFACT_ROOT = '/var/cache/genarrative-build/web-artifacts' } @@ -54,20 +55,33 @@ pipeline { 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}"]], - ]) + script { + def checkoutFromRemote = { String remoteUrl -> + checkout([ + $class: 'GitSCM', + branches: [[name: "*/${params.SOURCE_BRANCH}"]], + doGenerateSubmoduleConfigurations: false, + extensions: [[$class: 'CleanBeforeCheckout']], + userRemoteConfigs: [[url: remoteUrl]], + ]) + } + try { + checkoutFromRemote(env.GIT_REMOTE_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_URL + } catch (error) { + echo "Git 主地址拉取失败: ${env.GIT_REMOTE_URL},改用备用地址: ${env.GIT_REMOTE_FALLBACK_URL}" + checkoutFromRemote(env.GIT_REMOTE_FALLBACK_URL) + env.EFFECTIVE_GIT_REMOTE_URL = env.GIT_REMOTE_FALLBACK_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}" \ + GIT_REMOTE_URL="${EFFECTIVE_GIT_REMOTE_URL:-${GIT_REMOTE_URL}}" \ + GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ scripts/jenkins-checkout-source.sh ' diff --git a/scripts/jenkins-checkout-source.sh b/scripts/jenkins-checkout-source.sh index 509eb6d8..38e8c2d6 100644 --- a/scripts/jenkins-checkout-source.sh +++ b/scripts/jenkins-checkout-source.sh @@ -5,11 +5,14 @@ set -euo pipefail SOURCE_BRANCH="${SOURCE_BRANCH:-master}" COMMIT_HASH="${COMMIT_HASH:-}" GIT_REMOTE_URL="${GIT_REMOTE_URL:-}" +GIT_REMOTE_FALLBACK_URL="${GIT_REMOTE_FALLBACK_URL:-}" SOURCE_COMMIT_FILE="${SOURCE_COMMIT_FILE:-.jenkins-source-commit}" # Windows PowerShell 5.1 的 UTF-8 输出可能带 BOM;下游参数校验前先剥离不可见字节。 SOURCE_BRANCH="$(printf "%s" "${SOURCE_BRANCH}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')" COMMIT_HASH="$(printf "%s" "${COMMIT_HASH}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')" +GIT_REMOTE_URL="$(printf "%s" "${GIT_REMOTE_URL}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')" +GIT_REMOTE_FALLBACK_URL="$(printf "%s" "${GIT_REMOTE_FALLBACK_URL}" | sed $'s/^\xef\xbb\xbf//' | tr -d '\r\n')" if [[ ! "${SOURCE_BRANCH}" =~ ^[0-9A-Za-z._/-]+$ ]]; then echo "[jenkins-checkout-source] SOURCE_BRANCH 只能包含数字、字母、点、下划线、短横线和斜杠: ${SOURCE_BRANCH}" >&2 @@ -26,12 +29,52 @@ if [[ -n "${COMMIT_HASH}" && ! "${COMMIT_HASH}" =~ ^[0-9a-fA-F]{7,40}$ ]]; then exit 1 fi -if [[ -n "${GIT_REMOTE_URL}" ]]; then - git remote set-url origin "${GIT_REMOTE_URL}" -fi +GIT_REMOTE_CANDIDATES=() +add_git_remote_candidate() { + local candidate="$1" + local existing + if [[ -z "${candidate}" ]]; then + return + fi + for existing in "${GIT_REMOTE_CANDIDATES[@]}"; do + if [[ "${existing}" == "${candidate}" ]]; then + return + fi + done + GIT_REMOTE_CANDIDATES+=("${candidate}") +} + +fetch_source_branch() { + local remote_url="$1" + if [[ -n "${remote_url}" ]]; then + git remote set-url origin "${remote_url}" + fi + + echo "[jenkins-checkout-source] 尝试 Git 远端: ${remote_url:-origin}" + git fetch --tags --prune origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" +} + +add_git_remote_candidate "${GIT_REMOTE_URL}" +add_git_remote_candidate "${GIT_REMOTE_FALLBACK_URL}" git reset --hard HEAD -git fetch --tags --prune origin "+refs/heads/${SOURCE_BRANCH}:refs/remotes/origin/${SOURCE_BRANCH}" +if [[ "${#GIT_REMOTE_CANDIDATES[@]}" -eq 0 ]]; then + fetch_source_branch "" +else + fetch_ok=0 + for git_remote_candidate in "${GIT_REMOTE_CANDIDATES[@]}"; do + if fetch_source_branch "${git_remote_candidate}"; then + GIT_REMOTE_URL="${git_remote_candidate}" + fetch_ok=1 + break + fi + echo "[jenkins-checkout-source] Git 远端拉取失败: ${git_remote_candidate}" >&2 + done + if [[ "${fetch_ok}" -ne 1 ]]; then + echo "[jenkins-checkout-source] 所有 Git 远端均拉取失败。" >&2 + exit 1 + fi +fi if [[ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" == "true" ]]; then git fetch --unshallow --tags || true @@ -55,4 +98,4 @@ git reset --hard HEAD git clean -fd printf "%s\n" "${RESOLVED_COMMIT}" >"${SOURCE_COMMIT_FILE}" -echo "[jenkins-checkout-source] 使用源码: branch=${SOURCE_BRANCH} commit=${RESOLVED_COMMIT}" +echo "[jenkins-checkout-source] 使用源码: branch=${SOURCE_BRANCH} commit=${RESOLVED_COMMIT} remote=${GIT_REMOTE_URL:-origin}" diff --git a/scripts/jenkins-server-provision.sh b/scripts/jenkins-server-provision.sh index 8551a738..203518d4 100755 --- a/scripts/jenkins-server-provision.sh +++ b/scripts/jenkins-server-provision.sh @@ -9,6 +9,28 @@ require_path() { fi } +normalize_server_aliases() { + printf "%s" "${SERVER_ALIASES:-}" | tr ',' ' ' | xargs +} + +validate_server_names() { + local alias_name + if [[ -z "${SERVER_NAME:-}" ]]; then + echo "[server-provision] SERVER_NAME 不能为空。" >&2 + exit 1 + fi + if [[ ! "${SERVER_NAME}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]]; then + echo "[server-provision] SERVER_NAME 只能填写单个域名或 IP,不能包含空格、路径或协议: ${SERVER_NAME}" >&2 + exit 1 + fi + for alias_name in $(normalize_server_aliases); do + if [[ ! "${alias_name}" =~ ^[A-Za-z0-9][A-Za-z0-9.-]*$ ]]; then + echo "[server-provision] SERVER_ALIASES 只能填写域名或 IP,多个用空格或逗号分隔: ${alias_name}" >&2 + exit 1 + fi + done +} + run_cmd() { echo "+ $*" if [[ "${DRY_RUN}" != "true" ]]; then @@ -336,10 +358,15 @@ EOF render_nginx_template() { local template="$1" - local rendered_brotli + local rendered_brotli server_names rendered_brotli="$(render_nginx_brotli_directives)" + server_names="${SERVER_NAME}" + if [[ -n "${SERVER_ALIASES:-}" ]]; then + server_names="${server_names} $(normalize_server_aliases)" + fi sed \ - -e "s/genarrative.example.com/${SERVER_NAME}/g" \ + -e "s/server_name genarrative.example.com;/server_name ${server_names};/g" \ + -e "s|/etc/letsencrypt/live/genarrative.example.com/|/etc/letsencrypt/live/${SERVER_NAME}/|g" \ -e "/# __GENARRATIVE_BROTLI_DIRECTIVES__/r /dev/stdin" \ -e "/# __GENARRATIVE_BROTLI_DIRECTIVES__/d" \ "${template}" <<<"${rendered_brotli}" @@ -504,6 +531,8 @@ require_path scripts/deploy/maintenance-on.sh require_path scripts/deploy/maintenance-off.sh require_path scripts/deploy/maintenance-status.sh +validate_server_names + 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