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 23436d15c..cb68b9542 100644
--- a/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md
+++ b/docs/technical/【开发运维】Jenkins容器预览部署控制面技术方案-2026-08-15.md
@@ -43,6 +43,10 @@ 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。
+
+预览 Compose override 将 SpacetimeDB 内存上限设为 `2g`。基础 loadtest Compose 的 `896m` 是压测采样口径,当前完整模块首次发布和实例化会超过该上限;预览环境若沿用该值,容器会被 cgroup OOM 杀死并使模块上传中断。该覆盖只作用于分支预览实例,不修改生产或压测基线。
+
## Jenkins 参数与产物
固定 Job:`shared/Genarrative-Preview-Deployer`。
@@ -88,6 +92,12 @@ SpacetimeDB 与 OTLP 不映射宿主端口;Jenkins 通过受控 Compose 网络
页面状态统一为 `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 自动重启并在身份就绪后稳定运行。
+
## 安全边界
- 服务端缺少控制面访问口令或 Jenkins service account 凭据时必须拒绝启动,不允许退化成匿名写接口。
diff --git a/jenkins/Jenkinsfile.preview-deployer b/jenkins/Jenkinsfile.preview-deployer
index 3f4e5961e..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 {
@@ -93,13 +94,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..4680d1d0a 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,
@@ -91,6 +106,21 @@ assertIncludes(
'ports: !reset []',
'预览 compose override 必须取消 SpacetimeDB 和 OTLP 宿主端口映射。',
);
+assertIncludes(
+ deployer,
+ '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}$',
@@ -103,14 +133,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..0171628b8 100644
--- a/scripts/jenkins-preview-deployer.sh
+++ b/scripts/jenkins-preview-deployer.sh
@@ -296,11 +296,13 @@ 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}
dockerfile: ${GENARRATIVE_PREVIEW_CONTROLLER_ROOT}/deploy/container/api-server.Dockerfile
spacetimedb:
+ mem_limit: 2g
ports: !reset []
otelcol:
ports: !reset []
@@ -416,7 +418,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
)
diff --git a/server-rs/crates/preview-deployer-server/src/jenkins.rs b/server-rs/crates/preview-deployer-server/src/jenkins.rs
index fb4f45437..9bb553a68 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};
@@ -52,14 +52,13 @@ 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,
pub phase: Option,
pub health_status: Option,
+ pub web_port: Option,
pub web_url: Option,
pub message: Option,
}
@@ -205,11 +204,14 @@ impl JenkinsClient {
Fut: Future