From d2366bc40a417a0b762303e03db883b296fb2d3d Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 17:59:27 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E5=8F=AF=E9=80=89=E6=8F=90=E4=BA=A4=E5=8F=82?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 Checkout 阶段在严格 shell 下展开未定义 COMMIT_HASH 的失败 增加预览流水线参数归一化防回归门禁 同步预览部署参数合同与项目决策文档 --- .../shared-memory/decision-log.md | 6 +++++ ...Jenkins容器预览部署控制面技术方案-2026-08-15.md | 2 ++ jenkins/Jenkinsfile.preview-deployer | 10 +++++--- scripts/check-preview-deployer.mjs | 25 +++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index bc671a867..1394bf4f0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -14161,3 +14161,9 @@ - 实例与端口:分支规范化后形成稳定 `deploymentId`,同一分支换 commit 复用实例和 Web 端口;不同分支使用独立 Compose project。Web 端口在全局文件锁内从 `8400..8499` 分配,状态表与宿主监听同时空闲才可占用,卸载后释放。SpacetimeDB 与 OTLP 不映射宿主端口,Jenkins 通过受控 Compose 网络发布模块;页面只展示 Web 内网地址。 - 来源与卸载:部署只接受 `SOURCE_BRANCH` 和可选 `COMMIT_HASH`,Jenkins 必须证明 commit 属于目标分支。卸载只接受受控状态中存在的 `deploymentId`,客户端不能传 Jenkins URL、Job、Compose project、容器名或端口。状态通过固定 `preview-result.json` artifact 返回,不解析或向浏览器暴露完整 console。 - 关联文档:`docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + +## 2026-08-15 Jenkins 预览部署可选 commit 的跨 shell 参数合同 + +- 决策:`COMMIT_HASH` 在 Preview Deployer 全链路保持可选。控制服务正常请求显式传空字符串;Jenkins 流水线同时兼容首次参数未物化和旧构建重跑,在每个独立 `sh` 中使用 `${COMMIT_HASH:-}` 归一化,禁止依赖前一 shell 的局部变量。 +- `DEPLOY` 缺少 `SOURCE_BRANCH` 必须失败关闭;分支明确而 commit 为空时才表示选择该目标分支的远端最新提交,仍统一经过 `scripts/jenkins-checkout-source.sh`,不得改用控制器 `master`、忽略 commit 归属或关闭严格 shell。 +- 验证方式:`npm run check:preview-deployer` 必须拒绝 checkout 阶段的裸 `${COMMIT_HASH}`;现场重跑不带 commit 的 DEPLOY 后,流水线应通过 `Checkout Requested Source` 并在结果中记录目标分支解析出的 `resolvedCommit`。 diff --git a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md index 23436d15c..2e4c04978 100644 --- a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md +++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md @@ -56,6 +56,8 @@ SpacetimeDB 与 OTLP 不映射宿主端口;Jenkins 通过受控 Compose 网络 分支名只允许数字、字母、点、下划线、短横线和斜杠,不允许首尾斜杠或连续点号。Jenkins 必须复用 `scripts/jenkins-checkout-source.sh`,验证 commit 真实属于目标远端分支;SPA 和代理服务的输入校验不能代替流水线校验。 +`COMMIT_HASH` 是真正可选的参数:控制服务应显式传空字符串,但 Jenkins 也必须兼容首次参数尚未物化、旧构建重跑等没有该环境变量的调用。每个独立 `sh` 步骤都要在 `set -u` 下用空值默认完成归一化,不能依赖前一个 `sh` 中的局部赋值。`DEPLOY` 缺少 `SOURCE_BRANCH` 必须在动态 checkout 前失败关闭;分支明确而 commit 为空时,仍由 `scripts/jenkins-checkout-source.sh` 解析为该目标分支的远端最新提交,不得回退到控制器 `master` 或跳过分支归属校验。 + 每次运行归档 `preview-result.json`,至少包含: ```json diff --git a/jenkins/Jenkinsfile.preview-deployer b/jenkins/Jenkinsfile.preview-deployer index 3f4e5961e..7ab5b038a 100644 --- a/jenkins/Jenkinsfile.preview-deployer +++ b/jenkins/Jenkinsfile.preview-deployer @@ -52,6 +52,10 @@ pipeline { COMMIT_HASH="${COMMIT_HASH:-}" DEPLOYMENT_ID="${DEPLOYMENT_ID:-}" case "${ACTION}" in DEPLOY|STATUS|UNINSTALL) ;; *) exit 1 ;; esac + if [ "${ACTION}" = 'DEPLOY' ] && [ -z "${SOURCE_BRANCH}" ]; then + echo 'DEPLOY 必须提供 SOURCE_BRANCH。' >&2 + exit 1 + fi if [ -n "${SOURCE_BRANCH}" ]; then git check-ref-format --branch "${SOURCE_BRANCH}" >/dev/null fi @@ -93,9 +97,9 @@ pipeline { withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) { sh ''' set -euo pipefail - SOURCE_BRANCH="${SOURCE_BRANCH}" \ - COMMIT_HASH="${COMMIT_HASH}" \ - GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + SOURCE_BRANCH="${SOURCE_BRANCH:-}" \ + COMMIT_HASH="${COMMIT_HASH:-}" \ + GIT_REMOTE_URL="${GIT_REMOTE_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT="true" \ GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \ diff --git a/scripts/check-preview-deployer.mjs b/scripts/check-preview-deployer.mjs index 32d1b0497..e3ce90a2f 100644 --- a/scripts/check-preview-deployer.mjs +++ b/scripts/check-preview-deployer.mjs @@ -55,6 +55,31 @@ assertIncludes( "string(name: 'DEPLOYMENT_ID'", 'Jenkins 必须暴露 DEPLOYMENT_ID。', ); +assertIncludes( + jenkinsfile, + 'SOURCE_BRANCH="${SOURCE_BRANCH:-}"', + 'Jenkins 目标源码 checkout 必须兼容旧构建缺少 SOURCE_BRANCH 环境变量。', +); +assertIncludes( + jenkinsfile, + 'COMMIT_HASH="${COMMIT_HASH:-}"', + 'Jenkins 目标源码 checkout 必须兼容可选 COMMIT_HASH 未物化。', +); +assertIncludes( + jenkinsfile, + 'GIT_REMOTE_URL="${GIT_REMOTE_URL:-}"', + 'Jenkins 目标源码 checkout 必须在严格 shell 下安全读取固定 Git 地址。', +); +assertExcludes( + jenkinsfile, + 'COMMIT_HASH="${COMMIT_HASH}"', + 'Jenkins 不得在 set -u 下严格展开可选 COMMIT_HASH。', +); +assertIncludes( + jenkinsfile, + `if [ "${'${ACTION}'}" = 'DEPLOY' ] && [ -z "${'${SOURCE_BRANCH}'}" ]; then`, + 'Jenkins DEPLOY 缺少 SOURCE_BRANCH 时必须失败关闭。', +); assertIncludes( jenkinsfile, "archiveArtifacts artifacts: 'preview-result.json'", From 7fc739fad0b082844b337c14a31eace2492b2c5e Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 18:03:49 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC=E6=89=A7=E8=A1=8C=E6=96=B9?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过 bash 显式调用 Jenkins 源码检出脚本 增加脚本执行方式防回归门禁 同步预览部署执行合同文档 --- docs/project-memory/shared-memory/decision-log.md | 3 ++- ...开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md | 2 +- jenkins/Jenkinsfile.preview-deployer | 2 +- scripts/check-preview-deployer.mjs | 5 +++++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1394bf4f0..d740cf677 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -14166,4 +14166,5 @@ - 决策:`COMMIT_HASH` 在 Preview Deployer 全链路保持可选。控制服务正常请求显式传空字符串;Jenkins 流水线同时兼容首次参数未物化和旧构建重跑,在每个独立 `sh` 中使用 `${COMMIT_HASH:-}` 归一化,禁止依赖前一 shell 的局部变量。 - `DEPLOY` 缺少 `SOURCE_BRANCH` 必须失败关闭;分支明确而 commit 为空时才表示选择该目标分支的远端最新提交,仍统一经过 `scripts/jenkins-checkout-source.sh`,不得改用控制器 `master`、忽略 commit 归属或关闭严格 shell。 -- 验证方式:`npm run check:preview-deployer` 必须拒绝 checkout 阶段的裸 `${COMMIT_HASH}`;现场重跑不带 commit 的 DEPLOY 后,流水线应通过 `Checkout Requested Source` 并在结果中记录目标分支解析出的 `resolvedCommit`。 +- 调用方式:Preview Deployer 通过 `bash` 显式执行 `scripts/jenkins-checkout-source.sh`,不依赖仓库文件可执行位;GitSCM checkout、归档或文件模式漂移不得让合法部署停在 exit code 126。 +- 验证方式:`npm run check:preview-deployer` 必须拒绝 checkout 阶段的裸 `${COMMIT_HASH}`,并要求 helper 经 `bash` 调用;现场重跑不带 commit 的 DEPLOY 后,流水线应通过 `Checkout Requested Source` 并在结果中记录目标分支解析出的 `resolvedCommit`。 diff --git a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md index 2e4c04978..a81e45436 100644 --- a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md +++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md @@ -56,7 +56,7 @@ SpacetimeDB 与 OTLP 不映射宿主端口;Jenkins 通过受控 Compose 网络 分支名只允许数字、字母、点、下划线、短横线和斜杠,不允许首尾斜杠或连续点号。Jenkins 必须复用 `scripts/jenkins-checkout-source.sh`,验证 commit 真实属于目标远端分支;SPA 和代理服务的输入校验不能代替流水线校验。 -`COMMIT_HASH` 是真正可选的参数:控制服务应显式传空字符串,但 Jenkins 也必须兼容首次参数尚未物化、旧构建重跑等没有该环境变量的调用。每个独立 `sh` 步骤都要在 `set -u` 下用空值默认完成归一化,不能依赖前一个 `sh` 中的局部赋值。`DEPLOY` 缺少 `SOURCE_BRANCH` 必须在动态 checkout 前失败关闭;分支明确而 commit 为空时,仍由 `scripts/jenkins-checkout-source.sh` 解析为该目标分支的远端最新提交,不得回退到控制器 `master` 或跳过分支归属校验。 +`COMMIT_HASH` 是真正可选的参数:控制服务应显式传空字符串,但 Jenkins 也必须兼容首次参数尚未物化、旧构建重跑等没有该环境变量的调用。每个独立 `sh` 步骤都要在 `set -u` 下用空值默认完成归一化,不能依赖前一个 `sh` 中的局部赋值。`DEPLOY` 缺少 `SOURCE_BRANCH` 必须在动态 checkout 前失败关闭;分支明确而 commit 为空时,仍由 `scripts/jenkins-checkout-source.sh` 解析为该目标分支的远端最新提交,不得回退到控制器 `master` 或跳过分支归属校验。流水线通过 `bash` 显式调用该 helper,不依赖 Git checkout 后的文件可执行位。 每次运行归档 `preview-result.json`,至少包含: diff --git a/jenkins/Jenkinsfile.preview-deployer b/jenkins/Jenkinsfile.preview-deployer index 7ab5b038a..5e0691726 100644 --- a/jenkins/Jenkinsfile.preview-deployer +++ b/jenkins/Jenkinsfile.preview-deployer @@ -103,7 +103,7 @@ pipeline { SOURCE_COMMIT_FILE=".jenkins-source-commit" \ GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT="true" \ GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \ - "${WORKSPACE}/scripts/jenkins-checkout-source.sh" + bash "${WORKSPACE}/scripts/jenkins-checkout-source.sh" ''' } } diff --git a/scripts/check-preview-deployer.mjs b/scripts/check-preview-deployer.mjs index e3ce90a2f..0d8bbce18 100644 --- a/scripts/check-preview-deployer.mjs +++ b/scripts/check-preview-deployer.mjs @@ -70,6 +70,11 @@ assertIncludes( 'GIT_REMOTE_URL="${GIT_REMOTE_URL:-}"', 'Jenkins 目标源码 checkout 必须在严格 shell 下安全读取固定 Git 地址。', ); +assertIncludes( + jenkinsfile, + 'bash "${WORKSPACE}/scripts/jenkins-checkout-source.sh"', + 'Jenkins 必须通过 bash 调用 checkout helper,不能依赖仓库文件可执行位。', +); assertExcludes( jenkinsfile, 'COMMIT_HASH="${COMMIT_HASH}"', From 5e7ff450b68293e886bff994620884aa364654bc Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 18:09:04 +0800 Subject: [PATCH 03/12] =?UTF-8?q?Revert=20"=E4=BF=AE=E5=A4=8D=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E9=83=A8=E7=BD=B2=E8=84=9A=E6=9C=AC=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E6=96=B9=E5=BC=8F"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 7fc739fad0b082844b337c14a31eace2492b2c5e. --- docs/project-memory/shared-memory/decision-log.md | 3 +-- ...开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md | 2 +- jenkins/Jenkinsfile.preview-deployer | 2 +- scripts/check-preview-deployer.mjs | 5 ----- 4 files changed, 3 insertions(+), 9 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index d740cf677..1394bf4f0 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -14166,5 +14166,4 @@ - 决策:`COMMIT_HASH` 在 Preview Deployer 全链路保持可选。控制服务正常请求显式传空字符串;Jenkins 流水线同时兼容首次参数未物化和旧构建重跑,在每个独立 `sh` 中使用 `${COMMIT_HASH:-}` 归一化,禁止依赖前一 shell 的局部变量。 - `DEPLOY` 缺少 `SOURCE_BRANCH` 必须失败关闭;分支明确而 commit 为空时才表示选择该目标分支的远端最新提交,仍统一经过 `scripts/jenkins-checkout-source.sh`,不得改用控制器 `master`、忽略 commit 归属或关闭严格 shell。 -- 调用方式:Preview Deployer 通过 `bash` 显式执行 `scripts/jenkins-checkout-source.sh`,不依赖仓库文件可执行位;GitSCM checkout、归档或文件模式漂移不得让合法部署停在 exit code 126。 -- 验证方式:`npm run check:preview-deployer` 必须拒绝 checkout 阶段的裸 `${COMMIT_HASH}`,并要求 helper 经 `bash` 调用;现场重跑不带 commit 的 DEPLOY 后,流水线应通过 `Checkout Requested Source` 并在结果中记录目标分支解析出的 `resolvedCommit`。 +- 验证方式:`npm run check:preview-deployer` 必须拒绝 checkout 阶段的裸 `${COMMIT_HASH}`;现场重跑不带 commit 的 DEPLOY 后,流水线应通过 `Checkout Requested Source` 并在结果中记录目标分支解析出的 `resolvedCommit`。 diff --git a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md index a81e45436..2e4c04978 100644 --- a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md +++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md @@ -56,7 +56,7 @@ SpacetimeDB 与 OTLP 不映射宿主端口;Jenkins 通过受控 Compose 网络 分支名只允许数字、字母、点、下划线、短横线和斜杠,不允许首尾斜杠或连续点号。Jenkins 必须复用 `scripts/jenkins-checkout-source.sh`,验证 commit 真实属于目标远端分支;SPA 和代理服务的输入校验不能代替流水线校验。 -`COMMIT_HASH` 是真正可选的参数:控制服务应显式传空字符串,但 Jenkins 也必须兼容首次参数尚未物化、旧构建重跑等没有该环境变量的调用。每个独立 `sh` 步骤都要在 `set -u` 下用空值默认完成归一化,不能依赖前一个 `sh` 中的局部赋值。`DEPLOY` 缺少 `SOURCE_BRANCH` 必须在动态 checkout 前失败关闭;分支明确而 commit 为空时,仍由 `scripts/jenkins-checkout-source.sh` 解析为该目标分支的远端最新提交,不得回退到控制器 `master` 或跳过分支归属校验。流水线通过 `bash` 显式调用该 helper,不依赖 Git checkout 后的文件可执行位。 +`COMMIT_HASH` 是真正可选的参数:控制服务应显式传空字符串,但 Jenkins 也必须兼容首次参数尚未物化、旧构建重跑等没有该环境变量的调用。每个独立 `sh` 步骤都要在 `set -u` 下用空值默认完成归一化,不能依赖前一个 `sh` 中的局部赋值。`DEPLOY` 缺少 `SOURCE_BRANCH` 必须在动态 checkout 前失败关闭;分支明确而 commit 为空时,仍由 `scripts/jenkins-checkout-source.sh` 解析为该目标分支的远端最新提交,不得回退到控制器 `master` 或跳过分支归属校验。 每次运行归档 `preview-result.json`,至少包含: diff --git a/jenkins/Jenkinsfile.preview-deployer b/jenkins/Jenkinsfile.preview-deployer index 5e0691726..7ab5b038a 100644 --- a/jenkins/Jenkinsfile.preview-deployer +++ b/jenkins/Jenkinsfile.preview-deployer @@ -103,7 +103,7 @@ pipeline { SOURCE_COMMIT_FILE=".jenkins-source-commit" \ GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT="true" \ GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \ - bash "${WORKSPACE}/scripts/jenkins-checkout-source.sh" + "${WORKSPACE}/scripts/jenkins-checkout-source.sh" ''' } } diff --git a/scripts/check-preview-deployer.mjs b/scripts/check-preview-deployer.mjs index 0d8bbce18..e3ce90a2f 100644 --- a/scripts/check-preview-deployer.mjs +++ b/scripts/check-preview-deployer.mjs @@ -70,11 +70,6 @@ assertIncludes( 'GIT_REMOTE_URL="${GIT_REMOTE_URL:-}"', 'Jenkins 目标源码 checkout 必须在严格 shell 下安全读取固定 Git 地址。', ); -assertIncludes( - jenkinsfile, - 'bash "${WORKSPACE}/scripts/jenkins-checkout-source.sh"', - 'Jenkins 必须通过 bash 调用 checkout helper,不能依赖仓库文件可执行位。', -); assertExcludes( jenkinsfile, 'COMMIT_HASH="${COMMIT_HASH}"', From 7cd8b39593e3a93e2a0be3e3ae42efe23059afdd Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 18:09:04 +0800 Subject: [PATCH 04/12] =?UTF-8?q?Revert=20"=E4=BF=AE=E5=A4=8D=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E9=83=A8=E7=BD=B2=E5=8F=AF=E9=80=89=E6=8F=90=E4=BA=A4?= =?UTF-8?q?=E5=8F=82=E6=95=B0"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit d2366bc40a417a0b762303e03db883b296fb2d3d. --- .../shared-memory/decision-log.md | 6 ----- ...Jenkins容器预览部署控制面技术方案-2026-08-15.md | 2 -- jenkins/Jenkinsfile.preview-deployer | 10 +++----- scripts/check-preview-deployer.mjs | 25 ------------------- 4 files changed, 3 insertions(+), 40 deletions(-) diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1394bf4f0..bc671a867 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -14161,9 +14161,3 @@ - 实例与端口:分支规范化后形成稳定 `deploymentId`,同一分支换 commit 复用实例和 Web 端口;不同分支使用独立 Compose project。Web 端口在全局文件锁内从 `8400..8499` 分配,状态表与宿主监听同时空闲才可占用,卸载后释放。SpacetimeDB 与 OTLP 不映射宿主端口,Jenkins 通过受控 Compose 网络发布模块;页面只展示 Web 内网地址。 - 来源与卸载:部署只接受 `SOURCE_BRANCH` 和可选 `COMMIT_HASH`,Jenkins 必须证明 commit 属于目标分支。卸载只接受受控状态中存在的 `deploymentId`,客户端不能传 Jenkins URL、Job、Compose project、容器名或端口。状态通过固定 `preview-result.json` artifact 返回,不解析或向浏览器暴露完整 console。 - 关联文档:`docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 - -## 2026-08-15 Jenkins 预览部署可选 commit 的跨 shell 参数合同 - -- 决策:`COMMIT_HASH` 在 Preview Deployer 全链路保持可选。控制服务正常请求显式传空字符串;Jenkins 流水线同时兼容首次参数未物化和旧构建重跑,在每个独立 `sh` 中使用 `${COMMIT_HASH:-}` 归一化,禁止依赖前一 shell 的局部变量。 -- `DEPLOY` 缺少 `SOURCE_BRANCH` 必须失败关闭;分支明确而 commit 为空时才表示选择该目标分支的远端最新提交,仍统一经过 `scripts/jenkins-checkout-source.sh`,不得改用控制器 `master`、忽略 commit 归属或关闭严格 shell。 -- 验证方式:`npm run check:preview-deployer` 必须拒绝 checkout 阶段的裸 `${COMMIT_HASH}`;现场重跑不带 commit 的 DEPLOY 后,流水线应通过 `Checkout Requested Source` 并在结果中记录目标分支解析出的 `resolvedCommit`。 diff --git a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md index 2e4c04978..23436d15c 100644 --- a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md +++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md @@ -56,8 +56,6 @@ SpacetimeDB 与 OTLP 不映射宿主端口;Jenkins 通过受控 Compose 网络 分支名只允许数字、字母、点、下划线、短横线和斜杠,不允许首尾斜杠或连续点号。Jenkins 必须复用 `scripts/jenkins-checkout-source.sh`,验证 commit 真实属于目标远端分支;SPA 和代理服务的输入校验不能代替流水线校验。 -`COMMIT_HASH` 是真正可选的参数:控制服务应显式传空字符串,但 Jenkins 也必须兼容首次参数尚未物化、旧构建重跑等没有该环境变量的调用。每个独立 `sh` 步骤都要在 `set -u` 下用空值默认完成归一化,不能依赖前一个 `sh` 中的局部赋值。`DEPLOY` 缺少 `SOURCE_BRANCH` 必须在动态 checkout 前失败关闭;分支明确而 commit 为空时,仍由 `scripts/jenkins-checkout-source.sh` 解析为该目标分支的远端最新提交,不得回退到控制器 `master` 或跳过分支归属校验。 - 每次运行归档 `preview-result.json`,至少包含: ```json diff --git a/jenkins/Jenkinsfile.preview-deployer b/jenkins/Jenkinsfile.preview-deployer index 7ab5b038a..3f4e5961e 100644 --- a/jenkins/Jenkinsfile.preview-deployer +++ b/jenkins/Jenkinsfile.preview-deployer @@ -52,10 +52,6 @@ pipeline { COMMIT_HASH="${COMMIT_HASH:-}" DEPLOYMENT_ID="${DEPLOYMENT_ID:-}" case "${ACTION}" in DEPLOY|STATUS|UNINSTALL) ;; *) exit 1 ;; esac - if [ "${ACTION}" = 'DEPLOY' ] && [ -z "${SOURCE_BRANCH}" ]; then - echo 'DEPLOY 必须提供 SOURCE_BRANCH。' >&2 - exit 1 - fi if [ -n "${SOURCE_BRANCH}" ]; then git check-ref-format --branch "${SOURCE_BRANCH}" >/dev/null fi @@ -97,9 +93,9 @@ pipeline { withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) { sh ''' set -euo pipefail - SOURCE_BRANCH="${SOURCE_BRANCH:-}" \ - COMMIT_HASH="${COMMIT_HASH:-}" \ - GIT_REMOTE_URL="${GIT_REMOTE_URL:-}" \ + SOURCE_BRANCH="${SOURCE_BRANCH}" \ + COMMIT_HASH="${COMMIT_HASH}" \ + GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT="true" \ GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \ diff --git a/scripts/check-preview-deployer.mjs b/scripts/check-preview-deployer.mjs index e3ce90a2f..32d1b0497 100644 --- a/scripts/check-preview-deployer.mjs +++ b/scripts/check-preview-deployer.mjs @@ -55,31 +55,6 @@ assertIncludes( "string(name: 'DEPLOYMENT_ID'", 'Jenkins 必须暴露 DEPLOYMENT_ID。', ); -assertIncludes( - jenkinsfile, - 'SOURCE_BRANCH="${SOURCE_BRANCH:-}"', - 'Jenkins 目标源码 checkout 必须兼容旧构建缺少 SOURCE_BRANCH 环境变量。', -); -assertIncludes( - jenkinsfile, - 'COMMIT_HASH="${COMMIT_HASH:-}"', - 'Jenkins 目标源码 checkout 必须兼容可选 COMMIT_HASH 未物化。', -); -assertIncludes( - jenkinsfile, - 'GIT_REMOTE_URL="${GIT_REMOTE_URL:-}"', - 'Jenkins 目标源码 checkout 必须在严格 shell 下安全读取固定 Git 地址。', -); -assertExcludes( - jenkinsfile, - 'COMMIT_HASH="${COMMIT_HASH}"', - 'Jenkins 不得在 set -u 下严格展开可选 COMMIT_HASH。', -); -assertIncludes( - jenkinsfile, - `if [ "${'${ACTION}'}" = 'DEPLOY' ] && [ -z "${'${SOURCE_BRANCH}'}" ]; then`, - 'Jenkins DEPLOY 缺少 SOURCE_BRANCH 时必须失败关闭。', -); assertIncludes( jenkinsfile, "archiveArtifacts artifacts: 'preview-result.json'", From 1a61c0bea7f3b0561305305f5f39d8701bd08cfd Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 18:12:39 +0800 Subject: [PATCH 05/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8DJenkins=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E5=9C=B0=E5=9D=80=E5=BD=92=E4=B8=80=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 Jenkins 返回的外部构建路径重绑定到受信本机 origin 增加外部 Jenkins URL 的状态与产物轮询回归测试 --- .../preview-deployer-server/src/jenkins.rs | 22 ++++++++++++++++--- .../preview-deployer-server/src/tests.rs | 2 +- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/server-rs/crates/preview-deployer-server/src/jenkins.rs b/server-rs/crates/preview-deployer-server/src/jenkins.rs index fb4f45437..840c4a0f0 100644 --- a/server-rs/crates/preview-deployer-server/src/jenkins.rs +++ b/server-rs/crates/preview-deployer-server/src/jenkins.rs @@ -274,11 +274,27 @@ impl JenkinsClient { } fn resolve_trusted_url(&self, value: &str) -> Result { - let url = Url::parse(value) + let returned = Url::parse(value) .or_else(|_| self.job_url.join(value)) .map_err(|_| "Jenkins 返回了无效 URL".to_string())?; - self.ensure_same_origin(&url)?; - Ok(url) + if !returned.username().is_empty() + || returned.password().is_some() + || !returned.path().starts_with(self.root_url.path()) + { + return Err("Jenkins 返回了非受信源 URL".to_string()); + } + let relative_path = returned + .path() + .strip_prefix(self.root_url.path()) + .ok_or_else(|| "Jenkins 返回了非受信路径".to_string())?; + let mut trusted = self + .root_url + .join(relative_path) + .map_err(|_| "Jenkins 返回了无效 URL".to_string())?; + trusted.set_query(returned.query()); + trusted.set_fragment(None); + self.ensure_same_origin(&trusted)?; + Ok(trusted) } fn ensure_same_origin(&self, url: &Url) -> Result<(), String> { diff --git a/server-rs/crates/preview-deployer-server/src/tests.rs b/server-rs/crates/preview-deployer-server/src/tests.rs index cbb117687..d78e065e2 100644 --- a/server-rs/crates/preview-deployer-server/src/tests.rs +++ b/server-rs/crates/preview-deployer-server/src/tests.rs @@ -95,7 +95,7 @@ async fn mock_trigger( async fn mock_queue(axum::extract::Path(id): axum::extract::Path) -> Json { Json( - json!({"cancelled": false, "executable": {"url": format!("/jenkins/job/shared/job/Genarrative-Preview-Deployer/{id}/")}}), + json!({"cancelled": false, "executable": {"url": format!("http://192.168.35.82:8080/jenkins/job/shared/job/Genarrative-Preview-Deployer/{id}/")}}), ) } From a312ee20b5cbdaa312861dab763f82fab45ca718 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 18:17:21 +0800 Subject: [PATCH 06/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=B5=81=E6=B0=B4=E7=BA=BF=E7=A9=BA=E6=8F=90=E4=BA=A4=E5=8F=82?= =?UTF-8?q?=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 允许 Jenkins 检出阶段安全读取未导出的可选参数 显式使用 bash 执行源码检出脚本 增加空提交参数与脚本执行方式防回归检查 --- jenkins/Jenkinsfile.preview-deployer | 8 ++++---- scripts/check-preview-deployer.mjs | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/jenkins/Jenkinsfile.preview-deployer b/jenkins/Jenkinsfile.preview-deployer index 3f4e5961e..3d187edc4 100644 --- a/jenkins/Jenkinsfile.preview-deployer +++ b/jenkins/Jenkinsfile.preview-deployer @@ -93,13 +93,13 @@ pipeline { withCredentials([sshUserPrivateKey(credentialsId: env.GIT_REMOTE_CREDENTIAL_ID, keyFileVariable: 'GENARRATIVE_GIT_SSH_KEY')]) { sh ''' set -euo pipefail - SOURCE_BRANCH="${SOURCE_BRANCH}" \ - COMMIT_HASH="${COMMIT_HASH}" \ - GIT_REMOTE_URL="${GIT_REMOTE_URL}" \ + SOURCE_BRANCH="${SOURCE_BRANCH:-}" \ + COMMIT_HASH="${COMMIT_HASH:-}" \ + GIT_REMOTE_URL="${GIT_REMOTE_URL:-}" \ SOURCE_COMMIT_FILE=".jenkins-source-commit" \ GENARRATIVE_JENKINS_REUSE_EXISTING_CHECKOUT="true" \ GIT_SSH_COMMAND="ssh -i ${GENARRATIVE_GIT_SSH_KEY} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new" \ - "${WORKSPACE}/scripts/jenkins-checkout-source.sh" + bash "${WORKSPACE}/scripts/jenkins-checkout-source.sh" ''' } } diff --git a/scripts/check-preview-deployer.mjs b/scripts/check-preview-deployer.mjs index 32d1b0497..472f472fa 100644 --- a/scripts/check-preview-deployer.mjs +++ b/scripts/check-preview-deployer.mjs @@ -70,6 +70,21 @@ assertIncludes( 'disableConcurrentBuilds()', 'Jenkins Job 必须禁止并发构建。', ); +assertIncludes( + jenkinsfile, + 'COMMIT_HASH="${COMMIT_HASH:-}"', + 'Jenkins Checkout 阶段必须允许 COMMIT_HASH 未导出或为空。', +); +assertIncludes( + jenkinsfile, + 'bash "${WORKSPACE}/scripts/jenkins-checkout-source.sh"', + 'Jenkins Checkout 阶段必须显式使用 bash 执行源码检出脚本。', +); +assertExcludes( + jenkinsfile, + 'COMMIT_HASH="${COMMIT_HASH}"', + 'Jenkins Checkout 阶段不得在 set -u 下直接展开未导出的 COMMIT_HASH。', +); assertIncludes(deployer, 'flock -x 9', '部署脚本必须使用跨进程独占锁。'); assertIncludes( deployer, From d0a8f254b30852a40099015f35bac06eda71f996 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 18:30:08 +0800 Subject: [PATCH 07/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E7=8E=AF=E5=A2=83=E9=9D=9E=E4=BA=A4=E4=BA=92=E5=8F=91=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 允许 SpacetimeDB 向受控容器网络地址非交互发布 固定预览结果使用内网主机地址 补充发布参数与内网地址防回归检查和文档 --- ...发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md | 2 ++ jenkins/Jenkinsfile.preview-deployer | 1 + scripts/check-preview-deployer.mjs | 9 +++++++-- scripts/jenkins-preview-deployer.sh | 2 +- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md index 23436d15c..cc1c484a1 100644 --- a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md +++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md @@ -43,6 +43,8 @@ Web 端口池固定为 `8400..8499`: SpacetimeDB 与 OTLP 不映射宿主端口;Jenkins 通过受控 Compose 网络中的 SpacetimeDB 容器地址完成模块发布,运行服务之间继续使用 Compose DNS。页面只展示 Web 内网地址 `http://<预览宿主>:`。 +SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=remote,migrate,break-clients`,避免 Jenkins 等待非本地目标交互确认;该预览路径不传 `--delete-data`。Jenkins 同时固定 `GENARRATIVE_PREVIEW_WEB_HOST=192.168.35.82`,不得用默认路由自动探测结果生成页面链接,以免 VPN 或容器网卡地址泄漏到同事可见 URL。 + ## Jenkins 参数与产物 固定 Job:`shared/Genarrative-Preview-Deployer`。 diff --git a/jenkins/Jenkinsfile.preview-deployer b/jenkins/Jenkinsfile.preview-deployer index 3d187edc4..0808dc5ed 100644 --- a/jenkins/Jenkinsfile.preview-deployer +++ b/jenkins/Jenkinsfile.preview-deployer @@ -14,6 +14,7 @@ pipeline { GIT_REMOTE_URL = 'ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git' GIT_REMOTE_CREDENTIAL_ID = 'genarrative-local-gitea-ssh' GENARRATIVE_PREVIEW_STATE_ROOT = '/data/jenkins/preview-deployments' + GENARRATIVE_PREVIEW_WEB_HOST = '192.168.35.82' } parameters { diff --git a/scripts/check-preview-deployer.mjs b/scripts/check-preview-deployer.mjs index 472f472fa..b8990447c 100644 --- a/scripts/check-preview-deployer.mjs +++ b/scripts/check-preview-deployer.mjs @@ -118,14 +118,19 @@ assertIncludes( ); assertIncludes( deployer, - '--yes=migrate,break-clients', - 'SpacetimeDB 发布不得默认删除实例数据。', + '--yes=remote,migrate,break-clients', + 'SpacetimeDB 预览发布必须显式确认受控容器网络目标,且不得默认删除实例数据。', ); assertExcludes( deployer, '--delete-data', '预览部署不得使用 SpacetimeDB 删除数据参数。', ); +assertIncludes( + jenkinsfile, + "GENARRATIVE_PREVIEW_WEB_HOST = '192.168.35.82'", + 'Jenkins 必须固定输出可供同事访问的内网 Web 主机地址。', +); assertIncludes(webConfig, "?? '/build/'", 'SPA 默认 base 必须固定为 /build/。'); assertIncludes( systemd, diff --git a/scripts/jenkins-preview-deployer.sh b/scripts/jenkins-preview-deployer.sh index 6a92d6293..8de77344c 100644 --- a/scripts/jenkins-preview-deployer.sh +++ b/scripts/jenkins-preview-deployer.sh @@ -416,7 +416,7 @@ deploy() { publish genarrative-loadtest \ --server "${SPACETIME_PUBLISH_URL}" \ --module-path server-rs/crates/spacetime-module \ - --yes=migrate,break-clients \ + --yes=remote,migrate,break-clients \ --build-options="--debug" \ --no-config ) From ea81d8ebb537af265bee87e8ea5694f3afd70d7e Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 18:33:30 +0800 Subject: [PATCH 08/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=95=B0=E6=8D=AE=E5=BA=93=E5=AE=B9=E5=99=A8=E5=86=85=E5=AD=98?= =?UTF-8?q?=E4=B8=8D=E8=B6=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将分支预览 SpacetimeDB 内存上限覆盖为 2 GiB 保留生产与压测 Compose 的原有资源基线 增加预览内存覆盖防回归检查和故障说明 --- ...开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md | 2 ++ scripts/check-preview-deployer.mjs | 5 +++++ scripts/jenkins-preview-deployer.sh | 1 + 3 files changed, 8 insertions(+) diff --git a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md index cc1c484a1..3c441be27 100644 --- a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md +++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md @@ -45,6 +45,8 @@ SpacetimeDB 与 OTLP 不映射宿主端口;Jenkins 通过受控 Compose 网络 SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=remote,migrate,break-clients`,避免 Jenkins 等待非本地目标交互确认;该预览路径不传 `--delete-data`。Jenkins 同时固定 `GENARRATIVE_PREVIEW_WEB_HOST=192.168.35.82`,不得用默认路由自动探测结果生成页面链接,以免 VPN 或容器网卡地址泄漏到同事可见 URL。 +预览 Compose override 将 SpacetimeDB 内存上限设为 `2g`。基础 loadtest Compose 的 `896m` 是压测采样口径,当前完整模块首次发布和实例化会超过该上限;预览环境若沿用该值,容器会被 cgroup OOM 杀死并使模块上传中断。该覆盖只作用于分支预览实例,不修改生产或压测基线。 + ## Jenkins 参数与产物 固定 Job:`shared/Genarrative-Preview-Deployer`。 diff --git a/scripts/check-preview-deployer.mjs b/scripts/check-preview-deployer.mjs index b8990447c..a4dd379a8 100644 --- a/scripts/check-preview-deployer.mjs +++ b/scripts/check-preview-deployer.mjs @@ -106,6 +106,11 @@ assertIncludes( 'ports: !reset []', '预览 compose override 必须取消 SpacetimeDB 和 OTLP 宿主端口映射。', ); +assertIncludes( + deployer, + 'spacetimedb:\n mem_limit: 2g\n ports: !reset []', + '预览 SpacetimeDB 必须覆盖压测基线内存限制,避免模块首次加载时 OOM。', +); assertIncludes( deployer, '^preview-[0-9a-f]{16}$', diff --git a/scripts/jenkins-preview-deployer.sh b/scripts/jenkins-preview-deployer.sh index 8de77344c..ed942b973 100644 --- a/scripts/jenkins-preview-deployer.sh +++ b/scripts/jenkins-preview-deployer.sh @@ -301,6 +301,7 @@ services: context: ${GENARRATIVE_PREVIEW_SOURCE_DIR} dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile spacetimedb: + mem_limit: 2g ports: !reset [] otelcol: ports: !reset [] From cf5eac1c63d3e6c618fc6f7a22fd89badf484c76 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 18:38:59 +0800 Subject: [PATCH 09/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8DJenkins=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E6=94=B6=E5=B0=BE=E7=8A=B6=E6=80=81=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为队列、构建状态和部署产物查询增加有限重试 避免 Jenkins 瞬态无效响应永久覆盖成功部署状态 增加构建收尾竞态回归测试和技术说明 --- ...Jenkins容器预览部署控制面技术方案-2026-08-15.md | 2 + .../preview-deployer-server/src/jenkins.rs | 23 ++++++-- .../preview-deployer-server/src/tests.rs | 57 ++++++++++++++++++- 3 files changed, 75 insertions(+), 7 deletions(-) diff --git a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md index 3c441be27..de41c048d 100644 --- a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md +++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md @@ -92,6 +92,8 @@ SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=r 页面状态统一为 `queued / building / deploying / running / uninstalling / stopped / failed / cancelled`,健康状态统一为 `pending / healthy / unhealthy / unknown`。 +Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短暂返回不可解析的状态正文。控制服务对队列、构建状态和 artifact 查询执行有限重试;单次瞬态响应不得把已经成功并健康的部署永久写成 `failed`。 + ## 安全边界 - 服务端缺少控制面访问口令或 Jenkins service account 凭据时必须拒绝启动,不允许退化成匿名写接口。 diff --git a/server-rs/crates/preview-deployer-server/src/jenkins.rs b/server-rs/crates/preview-deployer-server/src/jenkins.rs index 840c4a0f0..27f8538c3 100644 --- a/server-rs/crates/preview-deployer-server/src/jenkins.rs +++ b/server-rs/crates/preview-deployer-server/src/jenkins.rs @@ -1,7 +1,7 @@ use std::{future::Future, time::Duration}; use reqwest::{Client, StatusCode, header}; -use serde::Deserialize; +use serde::{Deserialize, de::DeserializeOwned}; use url::Url; use crate::{Config, DeploymentStatus, HealthStatus}; @@ -209,7 +209,7 @@ impl JenkinsClient { .queue_url .join("api/json") .map_err(|error| error.to_string())?; - let item: QueueItem = self.get_json(item_url).await?; + let item: QueueItem = self.get_json_with_retry(item_url).await?; if item.cancelled.unwrap_or(false) { return Ok(JenkinsOutcome { success: false, @@ -229,7 +229,7 @@ impl JenkinsClient { let state_url = build_url .join("api/json") .map_err(|error| error.to_string())?; - let state: BuildState = self.get_json(state_url).await?; + let state: BuildState = self.get_json_with_retry(state_url).await?; if state.building { tokio::time::sleep(self.poll_interval).await; continue; @@ -247,7 +247,7 @@ impl JenkinsClient { let artifact_url = build_url .join("artifact/preview-result.json") .map_err(|error| error.to_string())?; - let result = self.get_json(artifact_url).await?; + let result = self.get_json_with_retry(artifact_url).await?; Ok(JenkinsOutcome { success: true, cancelled: false, @@ -273,6 +273,21 @@ impl JenkinsClient { .map_err(|_| "Jenkins 状态响应格式无效".to_string()) } + async fn get_json_with_retry(&self, url: Url) -> Result { + const MAX_ATTEMPTS: usize = 10; + let mut last_error = None; + for attempt in 1..=MAX_ATTEMPTS { + match self.get_json(url.clone()).await { + Ok(value) => return Ok(value), + Err(error) => last_error = Some(error), + } + if attempt < MAX_ATTEMPTS { + tokio::time::sleep(self.poll_interval).await; + } + } + Err(last_error.unwrap_or_else(|| "Jenkins 状态请求失败".to_string())) + } + fn resolve_trusted_url(&self, value: &str) -> Result { let returned = Url::parse(value) .or_else(|_| self.job_url.join(value)) diff --git a/server-rs/crates/preview-deployer-server/src/tests.rs b/server-rs/crates/preview-deployer-server/src/tests.rs index d78e065e2..03f90e6ed 100644 --- a/server-rs/crates/preview-deployer-server/src/tests.rs +++ b/server-rs/crates/preview-deployer-server/src/tests.rs @@ -1,5 +1,8 @@ use std::{ - sync::{Arc, Mutex}, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + }, time::Duration, }; @@ -25,6 +28,7 @@ const ORIGIN: &str = "http://preview.internal:8080"; #[derive(Clone, Default)] struct MockJenkinsState { requests: Arc>>, + invalid_build_responses: Arc, } #[derive(Debug)] @@ -99,8 +103,17 @@ async fn mock_queue(axum::extract::Path(id): axum::extract::Path) -> Json Json { - Json(json!({"building": false, "result": "SUCCESS"})) +async fn mock_build(State(state): State) -> impl IntoResponse { + if state + .invalid_build_responses + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + return (StatusCode::OK, "Jenkins is finalizing the build").into_response(); + } + Json(json!({"building": false, "result": "SUCCESS"})).into_response() } async fn mock_artifact(axum::extract::Path(build): axum::extract::Path) -> Json { @@ -424,6 +437,44 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() { std::fs::remove_file(state_file).unwrap(); } +#[tokio::test] +async fn transient_invalid_build_status_is_retried_before_reading_artifact() { + let (jenkins_url, mock) = start_mock_jenkins().await; + mock.invalid_build_responses.store(1, Ordering::SeqCst); + let state = AppState::new(test_config(jenkins_url)).unwrap(); + let app = build_router(state.clone()); + let cookie = login_cookie(&app).await; + let id = super::derive_deployment_id("feature/preview-ui"); + let deploy_request = axum::http::Request::builder() + .method("POST") + .uri("/api/preview-deployer/deployments") + .header(header::HOST, HOST) + .header(header::ORIGIN, ORIGIN) + .header(header::COOKIE, &cookie) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(r#"{"branch":"feature/preview-ui"}"#)) + .unwrap(); + assert_eq!( + app.oneshot(deploy_request).await.unwrap().status(), + StatusCode::ACCEPTED + ); + wait_for_status(&state, &id, super::DeploymentStatus::Running) + .await + .expect("transient invalid Jenkins response is retried"); + assert_eq!( + state + .deployments + .read() + .await + .get(&id) + .unwrap() + .public + .health, + super::HealthStatus::Healthy + ); + std::fs::remove_file(&state.config.state_file).unwrap(); +} + #[tokio::test] async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() { let (jenkins_url, mock) = start_mock_jenkins().await; From c75fcbf0236e149190ccfe9e17aa4f313dbacd01 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 18:52:10 +0800 Subject: [PATCH 10/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E6=9E=84=E5=BB=BA=E7=8A=B6=E6=80=81=E8=A7=A3=E6=9E=90=E4=B8=8E?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E8=BF=9B=E7=A8=8B=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 限制 Jenkins 状态接口只返回监控所需字段 避免完整构建 JSON 触发反序列化递归深度限制 让预览外部生成工作进程在身份初始化后自动重启 补充定向测试、防回归检查和技术说明 --- ...运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md | 2 ++ scripts/check-preview-deployer.mjs | 10 ++++++++++ scripts/jenkins-preview-deployer.sh | 1 + .../crates/preview-deployer-server/src/jenkins.rs | 10 ++++++++-- server-rs/crates/preview-deployer-server/src/tests.rs | 8 +++++++- 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md index de41c048d..786295289 100644 --- a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md +++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md @@ -94,6 +94,8 @@ SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=r Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短暂返回不可解析的状态正文。控制服务对队列、构建状态和 artifact 查询执行有限重试;单次瞬态响应不得把已经成功并健康的部署永久写成 `failed`。 +控制服务查询 Jenkins 队列与构建状态时必须使用 `tree` 参数限制到所需字段,避免完整 `api/json` 的大体积深层对象触发 JSON 递归深度限制。预览 Compose 中的外部生成 worker 使用 `restart: on-failure`;它若早于 API 完成模型定价运行时身份初始化而启动失败,应由 Docker 自动重启并在身份就绪后稳定运行。 + ## 安全边界 - 服务端缺少控制面访问口令或 Jenkins service account 凭据时必须拒绝启动,不允许退化成匿名写接口。 diff --git a/scripts/check-preview-deployer.mjs b/scripts/check-preview-deployer.mjs index a4dd379a8..4680d1d0a 100644 --- a/scripts/check-preview-deployer.mjs +++ b/scripts/check-preview-deployer.mjs @@ -111,6 +111,16 @@ assertIncludes( 'spacetimedb:\n mem_limit: 2g\n ports: !reset []', '预览 SpacetimeDB 必须覆盖压测基线内存限制,避免模块首次加载时 OOM。', ); +assertIncludes( + deployer, + 'external-generation-worker:\n build:', + '预览 compose override 必须配置外部生成 worker。', +); +assertIncludes( + deployer, + 'restart: on-failure', + '预览外部生成 worker 必须在运行时身份初始化竞态后自动重启。', +); assertIncludes( deployer, '^preview-[0-9a-f]{16}$', diff --git a/scripts/jenkins-preview-deployer.sh b/scripts/jenkins-preview-deployer.sh index ed942b973..0171628b8 100644 --- a/scripts/jenkins-preview-deployer.sh +++ b/scripts/jenkins-preview-deployer.sh @@ -296,6 +296,7 @@ services: build: context: ${GENARRATIVE_PREVIEW_SOURCE_DIR} dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile + restart: on-failure nginx: build: context: ${GENARRATIVE_PREVIEW_SOURCE_DIR} diff --git a/server-rs/crates/preview-deployer-server/src/jenkins.rs b/server-rs/crates/preview-deployer-server/src/jenkins.rs index 27f8538c3..19d9e8be3 100644 --- a/server-rs/crates/preview-deployer-server/src/jenkins.rs +++ b/server-rs/crates/preview-deployer-server/src/jenkins.rs @@ -205,10 +205,13 @@ impl JenkinsClient { Fut: Future, { let build_url = loop { - let item_url = reference + let mut item_url = reference .queue_url .join("api/json") .map_err(|error| error.to_string())?; + item_url + .query_pairs_mut() + .append_pair("tree", "cancelled,executable[url]"); let item: QueueItem = self.get_json_with_retry(item_url).await?; if item.cancelled.unwrap_or(false) { return Ok(JenkinsOutcome { @@ -226,9 +229,12 @@ impl JenkinsClient { on_build(build_url.as_str()).await; let successful = loop { - let state_url = build_url + let mut state_url = build_url .join("api/json") .map_err(|error| error.to_string())?; + state_url + .query_pairs_mut() + .append_pair("tree", "building,result"); let state: BuildState = self.get_json_with_retry(state_url).await?; if state.building { tokio::time::sleep(self.poll_interval).await; diff --git a/server-rs/crates/preview-deployer-server/src/tests.rs b/server-rs/crates/preview-deployer-server/src/tests.rs index 03f90e6ed..615981c31 100644 --- a/server-rs/crates/preview-deployer-server/src/tests.rs +++ b/server-rs/crates/preview-deployer-server/src/tests.rs @@ -103,7 +103,13 @@ async fn mock_queue(axum::extract::Path(id): axum::extract::Path) -> Json) -> impl IntoResponse { +async fn mock_build( + State(state): State, + uri: axum::http::Uri, +) -> impl IntoResponse { + if uri.query() != Some("tree=building%2Cresult") { + return (StatusCode::BAD_REQUEST, "missing bounded tree query").into_response(); + } if state .invalid_build_responses .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { From 080c3ebc4fc1f231f85ce5c73b1538801e0c194f Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 15 Aug 2026 19:18:49 +0800 Subject: [PATCH 11/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E9=83=A8=E7=BD=B2=E7=BB=93=E6=9E=9C=E5=AD=97=E6=AE=B5=E5=86=B2?= =?UTF-8?q?=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用权威 branch 和 resolvedCommit 字段解析 Jenkins 产物 移除与附加 source 字段冲突的旧别名 保留状态解析错误上下文以便安全排障 --- server-rs/crates/preview-deployer-server/src/jenkins.rs | 9 ++++----- server-rs/crates/preview-deployer-server/src/tests.rs | 2 +- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/server-rs/crates/preview-deployer-server/src/jenkins.rs b/server-rs/crates/preview-deployer-server/src/jenkins.rs index 19d9e8be3..74cf27217 100644 --- a/server-rs/crates/preview-deployer-server/src/jenkins.rs +++ b/server-rs/crates/preview-deployer-server/src/jenkins.rs @@ -52,9 +52,7 @@ pub struct PreviewResult { #[serde(alias = "id")] pub deployment_id: Option, pub project_name: Option, - #[serde(alias = "sourceBranch")] pub branch: Option, - #[serde(alias = "sourceCommit")] pub resolved_commit: Option, pub status: Option, pub health: Option, @@ -273,10 +271,11 @@ impl JenkinsClient { if !response.status().is_success() { return Err(format!("Jenkins 状态返回 HTTP {}", response.status())); } - response - .json() + let bytes = response + .bytes() .await - .map_err(|_| "Jenkins 状态响应格式无效".to_string()) + .map_err(|error| format!("Jenkins 状态响应正文读取失败: {error}"))?; + serde_json::from_slice(&bytes).map_err(|error| format!("Jenkins 状态响应格式无效: {error}")) } async fn get_json_with_retry(&self, url: Url) -> Result { diff --git a/server-rs/crates/preview-deployer-server/src/tests.rs b/server-rs/crates/preview-deployer-server/src/tests.rs index 615981c31..dc13bf960 100644 --- a/server-rs/crates/preview-deployer-server/src/tests.rs +++ b/server-rs/crates/preview-deployer-server/src/tests.rs @@ -130,7 +130,7 @@ async fn mock_artifact(axum::extract::Path(build): axum::extract::Path) -> "deploymentId": "preview-63d38d3da6bc9b06", "projectName": "genarrative-preview-63d38d3da6bc9b06", "branch": "feature/preview-ui", - "sourceCommit": "0123456789abcdef0123456789abcdef01234567", + "resolvedCommit": "0123456789abcdef0123456789abcdef01234567", "phase": "RUNNING", "healthStatus": "HEALTHY", "webUrl": "http://192.168.35.82:8400", From ced4b56dee0e70bf56edb69ed1d6513ba6c69f84 Mon Sep 17 00:00:00 2001 From: kdletters Date: Mon, 17 Aug 2026 10:47:58 +0800 Subject: [PATCH 12/12] =?UTF-8?q?=E4=BC=98=E5=8C=96=E9=A2=84=E8=A7=88?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E8=AE=B0=E5=BD=95=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 发布记录增加后端校验后的 Web 端口号 列表隐藏已成功卸载的容器记录 兼容恢复旧运行记录并回填 Web 端口 补充后端、前端测试和技术说明 --- .../src/PreviewDeployerApp.test.tsx | 2 + .../src/PreviewDeployerApp.tsx | 3 + apps/preview-deployer-web/src/styles.css | 5 ++ apps/preview-deployer-web/src/types.ts | 1 + ...Jenkins容器预览部署控制面技术方案-2026-08-15.md | 2 + .../preview-deployer-server/src/jenkins.rs | 1 + .../crates/preview-deployer-server/src/lib.rs | 47 +++++++++++++- .../preview-deployer-server/src/tests.rs | 62 +++++++++++++++++++ 8 files changed, 122 insertions(+), 1 deletion(-) diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx index d0b68bc21..261e5a6ae 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.test.tsx @@ -86,6 +86,7 @@ test('shows health and web url, then confirms uninstall', async () => { resolvedCommit: '1234567890abcdef', status: 'running', health: 'healthy', + webPort: 8400, webUrl: 'http://192.168.35.82:8400', createdAt: 1_787_270_400, updatedAt: 1_787_270_460, @@ -94,6 +95,7 @@ test('shows health and web url, then confirms uninstall', async () => { render(); expect(await screen.findByText('健康')).toBeTruthy(); + expect(screen.getByText('端口 8400')).toBeTruthy(); expect( screen.getByRole('link', { name: /打开 Web/u }).getAttribute('href'), ).toBe('http://192.168.35.82:8400'); diff --git a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx index 290e6d3cf..2bfd2482a 100644 --- a/apps/preview-deployer-web/src/PreviewDeployerApp.tsx +++ b/apps/preview-deployer-web/src/PreviewDeployerApp.tsx @@ -530,6 +530,9 @@ function DeploymentCard({ {HEALTH_LABELS[deployment.health]} + {deployment.webPort ? ( + 端口 {deployment.webPort} + ) : null} diff --git a/apps/preview-deployer-web/src/styles.css b/apps/preview-deployer-web/src/styles.css index 3316766b6..a7c3a531c 100644 --- a/apps/preview-deployer-web/src/styles.css +++ b/apps/preview-deployer-web/src/styles.css @@ -381,6 +381,11 @@ a { font-size: 11px; font-weight: 700; } +.port-badge { + color: #475569; + background: #f1f5f9; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; +} .badge-running { color: #14734a; background: #e9f9f0; diff --git a/apps/preview-deployer-web/src/types.ts b/apps/preview-deployer-web/src/types.ts index d538aee26..da08df216 100644 --- a/apps/preview-deployer-web/src/types.ts +++ b/apps/preview-deployer-web/src/types.ts @@ -17,6 +17,7 @@ export interface PreviewDeployment { resolvedCommit?: string | null; status: DeploymentStatus; health: DeploymentHealth; + webPort?: number | null; webUrl?: string | null; jenkinsBuildUrl?: string | null; createdAt: string | number; diff --git a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md index 786295289..cb68b9542 100644 --- a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md +++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md @@ -92,6 +92,8 @@ SpacetimeDB 2.7 CLI 发布到受控 Compose 网络地址时固定使用 `--yes=r 页面状态统一为 `queued / building / deploying / running / uninstalling / stopped / failed / cancelled`,健康状态统一为 `pending / healthy / unhealthy / unknown`。 +发布记录卡片直接展示后端校验后的 `webPort`。卸载成功的 `stopped` 记录仍保留在服务端持久状态中用于审计和所有权校验,但列表 API 不再返回,页面刷新后立即从发布记录中消失。 + Jenkins 在构建完成、归档 artifact 和更新 REST 状态之间可能短暂返回不可解析的状态正文。控制服务对队列、构建状态和 artifact 查询执行有限重试;单次瞬态响应不得把已经成功并健康的部署永久写成 `failed`。 控制服务查询 Jenkins 队列与构建状态时必须使用 `tree` 参数限制到所需字段,避免完整 `api/json` 的大体积深层对象触发 JSON 递归深度限制。预览 Compose 中的外部生成 worker 使用 `restart: on-failure`;它若早于 API 完成模型定价运行时身份初始化而启动失败,应由 Docker 自动重启并在身份就绪后稳定运行。 diff --git a/server-rs/crates/preview-deployer-server/src/jenkins.rs b/server-rs/crates/preview-deployer-server/src/jenkins.rs index 74cf27217..9bb553a68 100644 --- a/server-rs/crates/preview-deployer-server/src/jenkins.rs +++ b/server-rs/crates/preview-deployer-server/src/jenkins.rs @@ -58,6 +58,7 @@ pub struct PreviewResult { pub health: Option, pub phase: Option, pub health_status: Option, + pub web_port: Option, pub web_url: Option, pub message: Option, } diff --git a/server-rs/crates/preview-deployer-server/src/lib.rs b/server-rs/crates/preview-deployer-server/src/lib.rs index 11e79db54..0c021b2b0 100644 --- a/server-rs/crates/preview-deployer-server/src/lib.rs +++ b/server-rs/crates/preview-deployer-server/src/lib.rs @@ -24,6 +24,7 @@ use tower_http::{ trace::TraceLayer, }; use tracing::{error, warn}; +use url::Url; use uuid::Uuid; pub use config::Config; @@ -86,6 +87,8 @@ pub struct Deployment { pub resolved_commit: Option, pub status: DeploymentStatus, pub health: HealthStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub web_port: Option, #[serde(skip_serializing_if = "Option::is_none")] pub web_url: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -385,6 +388,7 @@ async fn list_deployments( .read() .await .values() + .filter(|record| record.public.status != DeploymentStatus::Stopped) .map(|record| record.public.clone()) .collect(); deployments.sort_by(|left, right| right.created_at.cmp(&left.created_at)); @@ -494,6 +498,7 @@ async fn create_deployment( resolved_commit: None, status: DeploymentStatus::Queued, health: HealthStatus::Pending, + web_port: None, web_url: None, jenkins_build_url: None, created_at: now, @@ -770,8 +775,23 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) { record.public.status = DeploymentStatus::Failed; record.public.health = HealthStatus::Unknown; record.public.message = Some("Jenkins 返回了无效 Web 地址".to_string()); + } else if result + .web_port + .is_some_and(|value| !(8400..=8499).contains(&value)) + || result + .web_url + .as_deref() + .zip(result.web_port) + .is_some_and(|(url, port)| { + Url::parse(url).ok().and_then(|url| url.port()) != Some(port) + }) + { + record.public.status = DeploymentStatus::Failed; + record.public.health = HealthStatus::Unknown; + record.public.message = Some("Jenkins 返回了无效 Web 端口".to_string()); } else if record.operation == Operation::Deploy && (result.resolved_commit.is_none() + || result.web_port.is_none() || result.web_url.is_none() || result.phase.as_deref() != Some("RUNNING") || result.health_status.as_deref() != Some("HEALTHY")) @@ -794,6 +814,9 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) { if let Some(value) = result.web_url { record.public.web_url = Some(value); } + if let Some(value) = result.web_port { + record.public.web_port = Some(value); + } if let Some(value) = result.health { record.public.health = value; } @@ -838,6 +861,7 @@ async fn apply_outcome(state: &AppState, id: &str, outcome: JenkinsOutcome) { Operation::Uninstall => { record.public.status = DeploymentStatus::Stopped; record.public.health = HealthStatus::Unknown; + record.public.web_port = None; record.public.web_url = None; record.public.can_uninstall = false; if record.public.message.is_none() { @@ -877,7 +901,7 @@ fn load_deployments(config: &Config) -> Result return Err("预览部署状态文件 schemaVersion 不受支持".to_string()); } let mut deployments = HashMap::new(); - for record in persisted.deployments { + for mut record in persisted.deployments { validate_deployment_id(&record.public.id) .map_err(|_| "状态文件包含无效部署 ID".to_string())?; let branch = validate_branch(&record.public.branch) @@ -896,6 +920,27 @@ fn load_deployments(config: &Config) -> Result { return Err("状态文件包含无效 Web 地址".to_string()); } + let url_port = record + .public + .web_url + .as_deref() + .and_then(|value| Url::parse(value).ok()) + .and_then(|url| url.port()); + if record + .public + .web_port + .is_some_and(|value| !(8400..=8499).contains(&value)) + || record + .public + .web_port + .zip(url_port) + .is_some_and(|(saved, parsed)| saved != parsed) + { + return Err("状态文件包含无效 Web 端口".to_string()); + } + if record.public.web_port.is_none() { + record.public.web_port = url_port; + } if deployments .insert(record.public.id.clone(), record) .is_some() diff --git a/server-rs/crates/preview-deployer-server/src/tests.rs b/server-rs/crates/preview-deployer-server/src/tests.rs index dc13bf960..a7d25d42d 100644 --- a/server-rs/crates/preview-deployer-server/src/tests.rs +++ b/server-rs/crates/preview-deployer-server/src/tests.rs @@ -133,6 +133,7 @@ async fn mock_artifact(axum::extract::Path(build): axum::extract::Path) -> "resolvedCommit": "0123456789abcdef0123456789abcdef01234567", "phase": "RUNNING", "healthStatus": "HEALTHY", + "webPort": 8400, "webUrl": "http://192.168.35.82:8400", "message": "预览实例已发布" })) @@ -365,6 +366,7 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() { .clone(); assert_eq!(deployment.status, super::DeploymentStatus::Running); assert_eq!(deployment.health, super::HealthStatus::Healthy); + assert_eq!(deployment.web_port, Some(8400)); assert_eq!( deployment.web_url.as_deref(), Some("http://192.168.35.82:8400") @@ -421,8 +423,27 @@ async fn deploy_and_uninstall_use_fixed_job_and_apply_owned_artifacts() { .clone(); assert_eq!(deployment.status, super::DeploymentStatus::Stopped); assert_eq!(deployment.health, super::HealthStatus::Unknown); + assert_eq!(deployment.web_port, None); assert_eq!(deployment.web_url, None); assert!(!deployment.can_uninstall); + + let list_request = axum::http::Request::builder() + .method("GET") + .uri("/api/preview-deployer/deployments") + .header(header::HOST, HOST) + .header(header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(); + let list_response = app.clone().oneshot(list_request).await.unwrap(); + assert_eq!(list_response.status(), StatusCode::OK); + let list_body = list_response + .into_body() + .collect() + .await + .unwrap() + .to_bytes(); + let list: Value = serde_json::from_slice(&list_body).unwrap(); + assert_eq!(list["deployments"], json!([])); let state_file = state.config.state_file.clone(); let mut recovered_config = test_config(state.config.jenkins_root_url.clone()); recovered_config.state_file = state_file.clone(); @@ -499,6 +520,7 @@ async fn duplicate_active_branch_is_rejected_without_second_jenkins_trigger() { resolved_commit: None, status: super::DeploymentStatus::Building, health: super::HealthStatus::Pending, + web_port: None, web_url: None, jenkins_build_url: None, created_at: now, @@ -576,3 +598,43 @@ fn branch_commit_and_web_url_validation_are_strict() { "192.168.35.82" )); } + +#[test] +fn legacy_running_state_recovers_web_port_from_validated_url() { + let config = test_config(Url::parse("http://127.0.0.1:18080/jenkins/").unwrap()); + let id = super::derive_deployment_id("feature/legacy-running"); + std::fs::write( + &config.state_file, + serde_json::to_vec(&json!({ + "schemaVersion": 1, + "deployments": [{ + "public": { + "id": id, + "branch": "feature/legacy-running", + "status": "running", + "health": "healthy", + "webUrl": "http://192.168.35.82:8407", + "createdAt": 1, + "updatedAt": 2, + "canUninstall": true + }, + "operation": "deploy" + }] + })) + .unwrap(), + ) + .unwrap(); + + let state = AppState::new(config).unwrap(); + assert_eq!( + state + .deployments + .blocking_read() + .get(&id) + .unwrap() + .public + .web_port, + Some(8407) + ); + std::fs::remove_file(&state.config.state_file).unwrap(); +}